Measure Performance Before Optimizing
Measure PowerShell performance before optimizing, including timing methods, streaming and buffering, current array-addition behavior, formatting/process/serialization overhead, source filtering, and empirical serial-versus-parallel comparison.
Learning objectives
- Use Measure-Command and Stopwatch to create meaningful baselines.
- Explain streaming, buffering, formatting, process-launch, and serialization costs.
- Teach the PowerShell 7.5+ array-addition optimization instead of outdated blanket += guidance.
- Filter at the source when semantics and command/API support it.
- Compare memory and speed tradeoffs rather than optimizing one metric blindly.
- Profile/refactor a workload and decide whether the measured optimization is worthwhile.
1. Optimization starts with a measurable question
“Make it faster” is not a testable requirement. Define a workload, input size, environment, and metric first. Then measure a baseline, change one meaningful thing, and measure again. PowerShell performance advice is particularly sensitive to version, module, OS, cache state, and whether work is CPU-bound or waiting on I/O.
2. Measure-Command returns a TimeSpan
Measure-Command executes a script block and returns its elapsed time as a TimeSpan. The measured command’s ordinary output is not the value returned by Measure-Command; capture the workload output separately if you need to validate correctness.
$timing = Measure-Command {
$result = 1..10000 | ForEach-Object { $_ * 2 }
}
[pscustomobject]@{
Count = @($result).Count
Milliseconds = [math]::Round($timing.TotalMilliseconds,2)
}3. Stopwatch helps instrument multiple phases
A .NET Stopwatch is useful when you need several checkpoints inside one workflow rather than timing one outer script block.
$sw=[Diagnostics.Stopwatch]::StartNew()
Start-Sleep -Milliseconds 80
$phase1=$sw.ElapsedMilliseconds
Start-Sleep -Milliseconds 120
$phase2=$sw.ElapsedMilliseconds-$phase1
$sw.Stop()
[pscustomobject]@{Phase1Ms=$phase1;Phase2Ms=$phase2;TotalMs=$sw.ElapsedMilliseconds}4. Streaming can reduce time-to-first-result and memory
A pipeline can pass objects onward as they are produced. Collecting an entire upstream result into an array before processing may increase peak memory and delay downstream work. But some operations—sorting, grouping, measuring the whole set—necessarily buffer. Optimize around the semantic requirement, not a slogan.
# Streaming shape: each object is transformed as it arrives.
1..5 | ForEach-Object {
[pscustomobject]@{Input=$_;Square=$_*$_}
} | Where-Object Square -gt 5
# Buffering is required when global ordering is the requirement.
1..5 | ForEach-Object { Get-Random -Maximum 100 } | Sort-Object5. The old += advice changed in PowerShell 7.5
Older PowerShell performance guidance warned that repeated array += recreated/copies arrays and could scale badly. Microsoft’s current performance documentation notes that PowerShell 7.5 optimized array addition, so that specific repeated-reallocation cost no longer applies in the same way on the 7.6 course baseline. It still matters for older hosts, and a typed generic List can remain clearer when you intentionally build a mutable collection.
# Measure on the host you actually support.
$plusEq = Measure-Command {
$a=@()
foreach($i in 1..20000){ $a += $i }
}
$listTime = Measure-Command {
$list=[Collections.Generic.List[int]]::new()
foreach($i in 1..20000){ $list.Add($i) }
}
[pscustomobject]@{PowerShell=$PSVersionTable.PSVersion.ToString();PlusEqMs=$plusEq.TotalMilliseconds;ListMs=$listTime.TotalMilliseconds}6. Formatting is presentation work and can distort benchmarks
Format-Table and Format-List create formatting instructions for the host. They should be at the end of an interactive pipeline. Benchmark the data operation separately from rendering thousands of rows to a terminal.
$data = 1..5000 | ForEach-Object { [pscustomobject]@{Id=$_;Value=$_*2} }
$dataTime = Measure-Command { $selected = $data | Where-Object Value -gt 9000 }
# Interactive rendering is a different concern:
# $selected | Format-Table
$dataTime.TotalMilliseconds7. Native process and job startup have fixed overhead
Launching pwsh, Start-Job, containers, SSH sessions, or external CLIs has a fixed setup cost. Repeatedly launching a process for tiny operations can dominate runtime. Batch work into one invocation when the external tool/API supports it, while preserving clear failure boundaries.
$direct = Measure-Command { $null = 1..8 | ForEach-Object { $_*2 } }
$jobs = Measure-Command {
foreach($n in 1..8){
$j=Start-Job -ArgumentList $n -ScriptBlock { param($x) $x*2 }
$null=$j | Wait-Job | Receive-Job
$j | Remove-Job
}
}
[pscustomobject]@{DirectMs=$direct.TotalMilliseconds;RepeatedProcessJobMs=$jobs.TotalMilliseconds}8. Filter at the source when the source can do it
If a database, API, filesystem provider, or remote system can reduce the dataset before transferring it, source-side filtering can save network, serialization, parsing, and memory. Do not fetch a million records merely to discard 99.9% locally when a supported server-side filter exists.
# General pattern — exact parameter depends on the command/API:
# Get-Something -Filter 'Status eq "Active"'
# Invoke-RestMethod -Uri "$base/items?status=active&limit=100"
# Validate that server-side filtering and local filtering have equivalent semantics
# before choosing the faster version.9. Reduce remote round trips, not observability
Each remote/API invocation pays latency, authentication, protocol, and serialization costs. Prefer one request that returns the needed structured fields over hundreds of tiny round trips. But do not compress a workflow so aggressively that you lose per-target errors, retries, or auditability.
10. Speed and memory are different budgets
Parallelism can reduce elapsed time while increasing memory because more work and more results exist simultaneously. Caching can reduce repeated computation while retaining more objects. A good optimization names the resource it improves and the resource it spends.
$proc = Get-Process -Id $PID
[pscustomobject]@{
WorkingSetMB = [math]::Round($proc.WorkingSet64/1MB,1)
ManagedMB = [math]::Round([GC]::GetTotalMemory($false)/1MB,1)
TimestampUtc = [datetime]::UtcNow
}11. Isolation and remoting serialize objects
Start-Job and remoting provide valuable isolation, but serialization consumes CPU/time and can reduce type fidelity. Thread jobs/runspaces avoid that boundary. The faster mechanism is not automatically the safer architecture; choose based on correctness and failure containment first.
$processJobTime = Measure-Command {
$j=Start-Job { 1..2000 | ForEach-Object { [pscustomobject]@{N=$_;Square=$_*$_} } }
$p=$j | Wait-Job | Receive-Job
$j | Remove-Job
}
$threadJobTime = if(Get-Command Start-ThreadJob -ErrorAction SilentlyContinue){
Measure-Command {
$j=Start-ThreadJob { 1..2000 | ForEach-Object { [pscustomobject]@{N=$_;Square=$_*$_} } }
$t=$j | Wait-Job | Receive-Job
$j | Remove-Job
}
}
[pscustomobject]@{StartJobMs=$processJobTime.TotalMilliseconds;ThreadJobMs=$threadJobTime.TotalMilliseconds}12. One run is evidence, not a benchmark study
Warm-up/JIT, filesystem cache, network variability, antivirus scanning, background load, and garbage collection can affect timings. Repeat measurements, discard obviously invalid runs, compare equivalent outputs, and record the environment.
$samples = 1..5 | ForEach-Object {
$t=Measure-Command { $null = 1..100000 | ForEach-Object { $_ + 1 } }
[pscustomobject]@{Run=$_;Milliseconds=$t.TotalMilliseconds}
}
$samples
$samples | Measure-Object Milliseconds -Average -Minimum -Maximum13. Lab — profile, refactor, and decide whether optimization is worthwhile
Compare serial and parallel versions of an intentionally waiting workload. Validate that both produce the same logical result, calculate the speedup, then decide whether the complexity is justified.
$input=1..12
$serialTime=Measure-Command {
$serial=$input | ForEach-Object { Start-Sleep -Milliseconds 100; [pscustomobject]@{Id=$_;Value=$_*2} }
}
$parallelTime=Measure-Command {
$parallel=$input | ForEach-Object -Parallel { Start-Sleep -Milliseconds 100; [pscustomobject]@{Id=$_;Value=$_*2} } -ThrottleLimit 4
}
$serialCheck=($serial | Sort-Object Id | ConvertTo-Json -Compress)
$parallelCheck=($parallel | Sort-Object Id | ConvertTo-Json -Compress)
[pscustomobject]@{
Equivalent = $serialCheck -eq $parallelCheck
SerialMs = [math]::Round($serialTime.TotalMilliseconds,1)
ParallelMs = [math]::Round($parallelTime.TotalMilliseconds,1)
Speedup = if($parallelTime.TotalMilliseconds){[math]::Round($serialTime.TotalMilliseconds/$parallelTime.TotalMilliseconds,2)}else{$null}
}
# Decide: is the speedup material enough to justify parallel failure/cancellation complexity?14. Performance mistakes to avoid
| Mistake | Why it misleads | Better pattern |
|---|---|---|
| Optimize before measuring | You may fix the wrong bottleneck. | Baseline first. |
| Repeat old += folklore on 7.6 | PowerShell 7.5 changed array-addition performance. | Version-check and benchmark current runtime. |
| Benchmark terminal formatting with data logic | Console rendering dominates. | Measure data and presentation separately. |
| Parallelize tiny work | Concurrency setup dominates. | Use serial unless measured benefit is material. |
| Measure only elapsed time | Memory/downstream pressure may worsen. | Track the relevant resource budgets too. |
15. Verification checklist
- You can use Measure-Command and Stopwatch for different timing needs.
- You distinguish streaming from operations that require buffering.
- You know the PowerShell 7.5 array-addition optimization changes old += advice.
- You account for native/process/remoting startup and serialization costs.
- You filter at the source where semantics and tooling support it.
- You validate equivalent output before calling an implementation faster.
16. Knowledge check
Question 1. What must exist before an optimization claim?
Question 2. Why is old array += advice version-sensitive now?
Question 3. Why can process jobs be slower for small tasks?
Question 4. What does source-side filtering save?
Question 5. Is a faster parallel implementation automatically better?
17. Chapter summary and next bridge
Chapter 17 built concurrency in layers: process jobs for isolation, thread jobs and Parallel for lighter bounded work, runspace pools for advanced hosting, production controls for cancellation/partial failure, and measurement before optimization. The central lesson is that concurrency is a correctness and resource-management problem before it is a speed technique.
Chapter 18 applies the same discipline to quality: Pester tests, mocks, static analysis, code coverage, CI quality gates, and designing automation so important behavior can be tested without touching production systems.
18. Authoritative references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.