Skip to content

Commit 00a3382

Browse files
Fixes #11978 - Create new article about parallel execution (#12074)
* Create new article about parallel execution * Fix xref * Fix toc link * Apply suggestions from review --------- Co-authored-by: Mikey Lombardi (He/Him) <[email protected]>
1 parent 2a07ffb commit 00a3382

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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

reference/docs-conceptual/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ items:
397397
href: dev-cross-plat/performance/script-authoring-considerations.md
398398
- name: Module performance considerations
399399
href: dev-cross-plat/performance/module-authoring-considerations.md
400+
- name: Optimize performance using parallel execution
401+
href: dev-cross-plat/performance/parallel-execution.md
400402
- name: Developing modern modules
401403
items:
402404
- name: Writing portable modules

0 commit comments

Comments
 (0)