|
| 1 | +--- |
| 2 | +description: The article discusses the performance characteristics of parallel execution to help you choose the best approach for your code. |
| 3 | +ms.date: 05/15/2025 |
| 4 | +title: Optimize performance using parallel execution |
| 5 | +--- |
| 6 | +# Optimize performance using parallel execution |
| 7 | + |
| 8 | +PowerShell provides several options for the creation of parallel invocations. |
| 9 | + |
| 10 | +- `Start-Job` runs each job in a separate process, each with a new instance of PowerShell. In many |
| 11 | + cases, a linear loop is faster. Also, serialization and deserialization can limit the usefulness |
| 12 | + of the objects returned. This command is built in to all versions of PowerShell. |
| 13 | +- `Start-ThreadJob` is a cmdlet found in the **ThreadJob** module. This command uses PowerShell |
| 14 | + runspaces to create and manage thread-based jobs. These jobs are lighter-weight than the jobs |
| 15 | + created by `Start-Job` and avoid potential loss of type fidelity required by cross-process |
| 16 | + serialization and deserialization. The **ThreadJob** module comes with PowerShell 7 and higher. |
| 17 | + For Windows PowerShell 5.1, you can install this module from the PowerShell Gallery. |
| 18 | +- Use the **System.Management.Automation.Runspaces** namespace from the PowerShell SDK to create |
| 19 | + your own parallel logic. Both `ForEach-Object -Parallel` and `Start-ThreadJob` use PowerShell |
| 20 | + runspaces to execute the code in parallel. |
| 21 | +- Workflows are a feature of Windows PowerShell 5.1. Workflows aren't available in PowerShell 7.0 |
| 22 | + and higher. Workflows are a special type of PowerShell script that can run in parallel. They're |
| 23 | + designed for long-running tasks and can be paused and resumed. Workflows aren't recommended for |
| 24 | + new development. For more information, see [about_Workflows][02]. |
| 25 | +- `ForEach-Object -Parallel` is a feature of PowerShell 7.0 and higher. Like `Start-ThreadJob`, it |
| 26 | + uses PowerShell runspaces to create and manage thread-based jobs. This command is designed for use |
| 27 | + in a pipeline. |
| 28 | + |
| 29 | +## Limit execution concurrency |
| 30 | + |
| 31 | +Running scripts in parallel doesn't guarantee improved performance. For example, the following |
| 32 | +scenarios can benefit from parallel execution: |
| 33 | + |
| 34 | +- Compute intensive scripts on multi-threaded multi-core processors |
| 35 | +- Scripts that spend time waiting for results or doing file operations, as long as those operations |
| 36 | + don't block each other. |
| 37 | + |
| 38 | +It's important to balance the overhead of parallel execution with the type of work done. Also, there |
| 39 | +are limits to the number of invocations that can run in parallel. |
| 40 | + |
| 41 | +The `Start-ThreadJob` and `ForEach-Object -Parallel` commands have a **ThrottleLimit** parameter to |
| 42 | +limit the number of jobs running at one time. As more jobs are started, they're queued and wait |
| 43 | +until the current number of jobs drops below the throttle limit. As of PowerShell 7.1, |
| 44 | +`ForEach-Object -Parallel` reuses runspaces from a runspace pool by default. The **ThrottleLimit** |
| 45 | +parameter sets the runspace pool size. The default runspace pool size is 5. You can still create a |
| 46 | +new runspace for each iteration using the UseNewRunspace switch. |
| 47 | + |
| 48 | +The `Start-Job` command doesn't have a **ThrottleLimit** parameter. You have to manage the number of |
| 49 | +jobs running at one time. |
| 50 | + |
| 51 | +## Measure performance |
| 52 | + |
| 53 | +The following function, `Measure-Parallel`, compares the speed of the following parallel execution |
| 54 | +approaches: |
| 55 | + |
| 56 | +- `Start-Job` - creates a child PowerShell process behind the scenes |
| 57 | +- `Start-ThreadJob` - runs each job in a separate thread |
| 58 | +- `ForEach-Object -Parallel` - runs each job in a separate thread |
| 59 | +- `Start-Process` - invokes an external program asynchronously |
| 60 | + |
| 61 | + > [!NOTE] |
| 62 | + > This approach only makes sense if your parallel tasks only consist of a single call to an |
| 63 | + > external program, as opposed to running a block of PowerShell code. Also, the only way to |
| 64 | + > capture output with this approach is by redirecting to a file. |
| 65 | +
|
| 66 | +```powershell |
| 67 | +function Measure-Parallel { |
| 68 | + [CmdletBinding()] |
| 69 | + param( |
| 70 | + [ValidateRange(2, 2147483647)] |
| 71 | + [int] $BatchSize = 5, |
| 72 | +
|
| 73 | + [ValidateSet('Job', 'ThreadJob', 'Process', 'ForEachParallel', 'All')] |
| 74 | + [string[]] $Approach, |
| 75 | +
|
| 76 | + # pass a higher count to run multiple batches |
| 77 | + [ValidateRange(2, 2147483647)] |
| 78 | + [int] $JobCount = $BatchSize |
| 79 | + ) |
| 80 | +
|
| 81 | + $noForEachParallel = $PSVersionTable.PSVersion.Major -lt 7 |
| 82 | + $noStartThreadJob = -not (Get-Command -ErrorAction Ignore Start-ThreadJob) |
| 83 | +
|
| 84 | + # Translate the approach arguments into their corresponding hashtable keys (see below). |
| 85 | + if ('All' -eq $Approach) { $Approach = 'Job', 'ThreadJob', 'Process', 'ForEachParallel' } |
| 86 | + $approaches = $Approach.ForEach({ |
| 87 | + if ($_ -eq 'ForEachParallel') { 'ForEach-Object -Parallel' } |
| 88 | + else { $_ -replace '^', 'Start-' } |
| 89 | + }) |
| 90 | +
|
| 91 | + if ($noStartThreadJob) { |
| 92 | + if ($interactive -or $approaches -contains 'Start-ThreadJob') { |
| 93 | + Write-Warning "Start-ThreadJob is not installed, omitting its test." |
| 94 | + $approaches = $approaches.Where({ $_ -ne 'Start-ThreadJob' }) |
| 95 | + } |
| 96 | + } |
| 97 | + if ($noForEachParallel) { |
| 98 | + if ($interactive -or $approaches -contains 'ForEach-Object -Parallel') { |
| 99 | + Write-Warning 'ForEach-Object -Parallel require PowerShell v7+, omitting its test.' |
| 100 | + $approaches = $approaches.Where({ $_ -ne 'ForEach-Object -Parallel' }) |
| 101 | + } |
| 102 | + } |
| 103 | +
|
| 104 | + # Simulated input: Create 'f0.zip', 'f1'.zip', ... file names. |
| 105 | + $zipFiles = 0..($JobCount - 1) -replace '^', 'f' -replace '$', '.zip' |
| 106 | +
|
| 107 | + # Sample executables to run - here, the native shell is called to simply |
| 108 | + # echo the argument given. |
| 109 | + $exe = if ($env:OS -eq 'Windows_NT') { 'cmd.exe' } else { 'sh' } |
| 110 | +
|
| 111 | + # The list of its arguments *as a single string* - use '{0}' as the placeholder |
| 112 | + # for where the input object should go. |
| 113 | + $exeArgList = if ($env:OS -eq 'Windows_NT') { |
| 114 | + '/c "echo {0} > NUL:"' |
| 115 | + } else { |
| 116 | + '-c "echo {0} > /dev/null"' |
| 117 | + } |
| 118 | +
|
| 119 | + # A hashtable with script blocks that implement the 3 approaches to parallelism. |
| 120 | + $approachImpl = [ordered] @{} |
| 121 | +
|
| 122 | + # child-process-based job |
| 123 | + $approachImpl['Start-Job'] = { |
| 124 | + param([array] $batch) |
| 125 | + $batch | |
| 126 | + ForEach-Object { |
| 127 | + Start-Job { |
| 128 | + Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $args[0])) |
| 129 | + } -ArgumentList $_ |
| 130 | + } | |
| 131 | + Receive-Job -Wait -AutoRemoveJob | Out-Null |
| 132 | + } |
| 133 | +
|
| 134 | + # thread-based job - requires the ThreadJob module |
| 135 | + if (-not $noStartThreadJob) { |
| 136 | + # If Start-ThreadJob is available, add an approach for it. |
| 137 | + $approachImpl['Start-ThreadJob'] = { |
| 138 | + param([array] $batch) |
| 139 | + $batch | |
| 140 | + ForEach-Object { |
| 141 | + Start-ThreadJob -ThrottleLimit $BatchSize { |
| 142 | + Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $args[0])) |
| 143 | + } -ArgumentList $_ |
| 144 | + } | |
| 145 | + Receive-Job -Wait -AutoRemoveJob | Out-Null |
| 146 | + } |
| 147 | + } |
| 148 | +
|
| 149 | + # ForEach-Object -Parallel job |
| 150 | + if (-not $noForEachParallel) { |
| 151 | + $approachImpl['ForEach-Object -Parallel'] = { |
| 152 | + param([array] $batch) |
| 153 | + $batch | ForEach-Object -ThrottleLimit $BatchSize -Parallel { |
| 154 | + Invoke-Expression ($using:exe + ' ' + ($using:exeArgList -f $_)) |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | +
|
| 159 | + # direct execution of an external program |
| 160 | + $approachImpl['Start-Process'] = { |
| 161 | + param([array] $batch) |
| 162 | + $batch | |
| 163 | + ForEach-Object { |
| 164 | + Start-Process -NoNewWindow -PassThru $exe -ArgumentList ($exeArgList -f $_) |
| 165 | + } | |
| 166 | + Wait-Process |
| 167 | + } |
| 168 | +
|
| 169 | + # Partition the array of all indices into subarrays (batches) |
| 170 | + $batches = @( |
| 171 | + 0..([math]::Ceiling($zipFiles.Count / $batchSize) - 1) | ForEach-Object { |
| 172 | + , $zipFiles[($_ * $batchSize)..($_ * $batchSize + $batchSize - 1)] |
| 173 | + } |
| 174 | + ) |
| 175 | +
|
| 176 | + $tsTotals = foreach ($appr in $approaches) { |
| 177 | + $i = 0 |
| 178 | + $tsTotal = [timespan] 0 |
| 179 | + $batches | ForEach-Object { |
| 180 | + Write-Verbose "$batchSize-element '$appr' batch" |
| 181 | + $ts = Measure-Command { & $approachImpl[$appr] $_ | Out-Null } |
| 182 | + $tsTotal += $ts |
| 183 | + if (++$i -eq $batches.Count) { |
| 184 | + # last batch processed. |
| 185 | + if ($batches.Count -gt 1) { |
| 186 | + Write-Verbose ("'$appr' processing $JobCount items finished in " + |
| 187 | + "$($tsTotal.TotalSeconds.ToString('N2')) secs.") |
| 188 | + } |
| 189 | + $tsTotal # output the overall timing for this approach |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | +
|
| 194 | + # Output a result object with the overall timings. |
| 195 | + $oht = [ordered] @{} |
| 196 | + $oht['JobCount'] = $JobCount |
| 197 | + $oht['BatchSize'] = $BatchSize |
| 198 | + $oht['BatchCount'] = $batches.Count |
| 199 | + $i = 0 |
| 200 | + foreach ($appr in $approaches) { |
| 201 | + $oht[($appr + ' (secs.)')] = $tsTotals[$i++].TotalSeconds.ToString('N2') |
| 202 | + } |
| 203 | + [pscustomobject] $oht |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +The following example uses `Measure-Parallel` to run 20 jobs in parallel, 5 at a time, using all |
| 208 | +available approaches. |
| 209 | + |
| 210 | +```powershell |
| 211 | +Measure-Parallel -Approach All -BatchSize 5 -JobCount 20 -Verbose |
| 212 | +``` |
| 213 | + |
| 214 | +The following output comes from a Windows computer running PowerShell 7.5.1. Your timing can vary |
| 215 | +based on many factors, but the ratios should provide a sense of relative performance. |
| 216 | + |
| 217 | +```Output |
| 218 | +VERBOSE: 5-element 'Start-Job' batch |
| 219 | +VERBOSE: 5-element 'Start-Job' batch |
| 220 | +VERBOSE: 5-element 'Start-Job' batch |
| 221 | +VERBOSE: 5-element 'Start-Job' batch |
| 222 | +VERBOSE: 'Start-Job' processing 20 items finished in 7.58 secs. |
| 223 | +VERBOSE: 5-element 'Start-ThreadJob' batch |
| 224 | +VERBOSE: 5-element 'Start-ThreadJob' batch |
| 225 | +VERBOSE: 5-element 'Start-ThreadJob' batch |
| 226 | +VERBOSE: 5-element 'Start-ThreadJob' batch |
| 227 | +VERBOSE: 'Start-ThreadJob' processing 20 items finished in 2.37 secs. |
| 228 | +VERBOSE: 5-element 'Start-Process' batch |
| 229 | +VERBOSE: 5-element 'Start-Process' batch |
| 230 | +VERBOSE: 5-element 'Start-Process' batch |
| 231 | +VERBOSE: 5-element 'Start-Process' batch |
| 232 | +VERBOSE: 'Start-Process' processing 20 items finished in 0.26 secs. |
| 233 | +VERBOSE: 5-element 'ForEach-Object -Parallel' batch |
| 234 | +VERBOSE: 5-element 'ForEach-Object -Parallel' batch |
| 235 | +VERBOSE: 5-element 'ForEach-Object -Parallel' batch |
| 236 | +VERBOSE: 5-element 'ForEach-Object -Parallel' batch |
| 237 | +VERBOSE: 'ForEach-Object -Parallel' processing 20 items finished in 0.79 secs. |
| 238 | +
|
| 239 | +JobCount : 20 |
| 240 | +BatchSize : 5 |
| 241 | +BatchCount : 4 |
| 242 | +Start-Job (secs.) : 7.58 |
| 243 | +Start-ThreadJob (secs.) : 2.37 |
| 244 | +Start-Process (secs.) : 0.26 |
| 245 | +ForEach-Object -Parallel (secs.) : 0.79 |
| 246 | +``` |
| 247 | + |
| 248 | +Conclusions |
| 249 | + |
| 250 | +- The `Start-Process` approach performs best because it doesn't have the overhead of job management. |
| 251 | + However, as previously noted, this approach has fundamental limitations. |
| 252 | +- The `ForEach-Object -Parallel` adds the least overhead, followed by `Start-ThreadJob`. |
| 253 | +- `Start-Job` has the most overhead because of the hidden PowerShell instances it creates for each |
| 254 | + job. |
| 255 | + |
| 256 | +## Acknowledgments |
| 257 | + |
| 258 | +Much of the information is this article is based on the answers from [Santiago Squarzon][04] and |
| 259 | +[mklement0][05] in this [Stack Overflow post][03]. |
| 260 | + |
| 261 | +You may also be interested in the [PSParallelPipeline][06] module created by Santiago Squarzon. |
| 262 | + |
| 263 | +## Further reading |
| 264 | + |
| 265 | +- [Start-Job][08] |
| 266 | +- [about_Jobs][01] |
| 267 | +- [Start-ThreadJob][10] |
| 268 | +- [ForEach-Object][07] |
| 269 | +- [Start-Process][09] |
| 270 | + |
| 271 | +<!-- link references --> |
| 272 | +[01]: /powershell/module/microsoft.powershell.core/about/about_jobs |
| 273 | +[02]: /powershell/module/psworkflow/about/about_workflows |
| 274 | +[03]: https://stackoverflow.com/questions/73997250/powershell-test-the-performance-efficiency-of-asynchronous-tasks-with-start-job/ |
| 275 | +[04]: https://stackoverflow.com/users/15339544/santiago-squarzon |
| 276 | +[05]: https://stackoverflow.com/users/45375/mklement0 |
| 277 | +[06]: https://www.powershellgallery.com/packages/PSParallelPipeline/ |
| 278 | +[07]: xref:Microsoft.PowerShell.Core.ForEach-Object |
| 279 | +[08]: xref:Microsoft.PowerShell.Core.Start-Job |
| 280 | +[09]: xref:Microsoft.PowerShell.Management.Start-Process |
| 281 | +[10]: xref:ThreadJob.Start-ThreadJob |
0 commit comments