Ironman Software Forums
Continue the conversion on the Ironman Software forums. Chat with over 1000 users about PowerShell, PowerShell Universal, and PowerShell Pro Tools.
PowerShell is primarily a scripting language that is not entirely compiled so it may suffer from degraded performance in relation to other tools built on .NET. With the PowerShell v3 release, the PowerShell team started to JIT compile portions of PowerShell script to greatly improve the performance. The introduction of .NET 5.0 and PowerShell 7.1 raised the bar even further.
In this post, we’ll look at some tips and tricks to improve PowerShell performance.
There are several places we can look to measure the performance of PowerShell.
Measure-Command can be used to time particular script blocks for their execution time. This can be useful for determine for A\B testing around different types of scripts to produce simple performance results.
You’ll see after running the below command, the Measure-Command
call will return a timing for the included Start-Sleep
command.
Measure-Script { Start-Sleep 10 }
Another useful metric is provided when starting PowerShell. If your profile takes a long time to load, it will be reported as soon as the shell starts. It’s an indication to improve your profile or use the -NoProfile
command in scripts you’d like to run quickly.
Loading personal and system profiles took 5083ms.
The path to your profile is stored in the $Profile
variable.
Performance counters can also play an important role to view the overall .NET runtime performance for a process. You can use the dotnet-counters
global tool to monitor PowerShell process performance for version 7 and later.
You will need the .NET SDK installed to install this tool.
dotnet tool install --global dotnet-counters
# 24656 is the process ID of pwsh.exe
dotnet-counters monitor --process-id 24656
Press p to pause, r to resume, q to quit.
Status: Running
[System.Runtime]
% Time in GC since last GC (%) 0
Allocation Rate (B / 1 sec) 8,168
CPU Usage (%) 0
Exception Count (Count / 1 sec) 0
GC Fragmentation (%) 1.889
GC Heap Size (MB) 14
Gen 0 GC Count (Count / 1 sec) 0
Gen 0 Size (B) 24
Gen 1 GC Count (Count / 1 sec) 0
Gen 1 Size (B) 2,878,424
Gen 2 GC Count (Count / 1 sec) 0
Gen 2 Size (B) 9,641,232
IL Bytes Jitted (B) 486,667
LOH Size (B) 1,106,960
Monitor Lock Contention Count (Count / 1 sec) 0
Number of Active Timers 1
Number of Assemblies Loaded 86
Number of Methods Jitted 5,225
POH (Pinned Object Heap) Size (B) 277,888
ThreadPool Completed Work Item Count (Count / 1 sec) 2
ThreadPool Queue Length 0
ThreadPool Thread Count 3
Working Set (MB) 123
You can also use a profiler, such as the PowerShell Pro Tools Profiler, to instrument your code and view the overall performance of your script line-by-line.
As mentioned, Windows PowerShell v3 is significantly faster than v2 and PowerShell v7 is faster than all previous versions.
Here are some comparisons for some standard looped commands.
This is a basic loop using slow array adding and a pipeline.
Measure-Command {
$Array = @()
1..100000 | ForEach-Object { $Array += $_ }
}
# Windows PowerShell v5.1
#TotalSeconds : 2.1401528
# PowerShell 7.1.4
#TotalSeconds : 1.9452586
This second example uses a much faster array list and foreach
loop and you can see PowerShell 7 is about 30% faster here.
Measure-Command {
$ArrayList = [System.Collections.ArrayList]::new()
foreach($i in (1..1000000))
{
$ArrayList.Add($i) > $null
}
}
# Windows PowerShell v5.1
# TotalSeconds : 1.2822479
# PowerShell 7.1.4
# TotalSeconds : 0.8229365
Collections, like arrays and array lists, make a huge difference when it comes to performance. As you can see in the above example, adding to an array is much slower than using a structure like an array list. In the below example, we loop over 100x as many items in less than half the time using an ArrayList
.
Measure-Command {
$ArrayList = [System.Collections.ArrayList]::new()
foreach($i in (1..1000000))
{
$ArrayList.Add($i) > $null
}
}
# PowerShell 7.1.4
# TotalSeconds : 0.8229365
Measure-Command {
$Array = @()
1..100000 | ForEach-Object { $Array += $_ }
}
# PowerShell 7.1.4
#TotalSeconds : 1.9452586
Filtering is often built directly into the calling cmdlets and is often referred to as filtering left. This means that you want to include filters as left as possible in a pipeline to avoid having to iterate over too much data later on. This is especially true for cmdlets like Get-ADUser
and Get-ChildItem
.
In both of the examples below we are filtering using the built in filter mechanisms. With Get-ADUser
, this executes the filter within Active Directory rather than retrieving all users and filtering within this local PowerShell process. Much faster!
# Good
Get-ADUser -Filter 'Name -eq "Adam"'
Get-ChildItem -Filter "*.txt" -Recurse
# Bad
Get-ADUser -Identity * | Where-Object Name -eq "Adam"
Get-ChildItem -Recurse | Where-Object Extension -eq "txt"
Functions are optimized in PowerShell and often compiled directly to JIT’d code by the PowerShell engine. It may be useful to create a function for performance reasons.
The below example simply loops many times and the functional version is much faster.
function Test-Function {
$i = 10
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
}
Measure-Command {
1..1000 | ForEach-Object { Test-Function }
}
# PowerShell 7.1.4
# TotalSeconds : 0.745318
Measure-Command {
1..1000 | ForEach-Object {
$i = 10
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
foreach($x in ($i..1000))
{
$y = $i
}
}
}
# PowerShell 7.1.4
# TotalSeconds : 5.6235451
The pipeline is great for chaining commands together but can be slower than standard loops in many scenarios.
You can see below that looping through 1 million integers is much faster with a standard loop than with the pipeline and ForEach-Object
.
Measure-Command {
1..1000000 | ForEach-Object {
$i = $_
}
}
# PowerShell 7.1.4
# TotalSeconds : 3.8893147
Measure-Command {
foreach($x in (1..100000))
{
$i = $x
}
}
# PowerShell 7.1.4
# TotalSeconds : 0.1002988
When using PSCustomObject, it is almost twice as fast to use the new()
constructor to create new copies of an object rather than casting to the object directly.
Measure-Command {
1..100000 | ForEach-Object { [PSCustomObject]@{ Test = $_ } > $null }
}
# PowerShell 7.1.4
# TotalSeconds : 0.9698343
Measure-Command {
1..100000 | ForEach-Object { [PSCustomObject]::new(@{ Test = $_ }) > $null }
}
# PowerShell 7.1.4
# TotalSeconds : 0.4251424
The Sort-Object
cmdlet is great for sorting complex objects based on properties but might not always be suitable for sorting large data sets of primitive types. The [Array]::Sort()
method is useful when sorting numbers, characters or strings.
For this example, I’m using the complete genome of E.coli bacteria. It’s about 4.4Mb of text. We are sorting each character in the array.
$EColi = Get-Content .\E.coli -Raw
Measure-Command {
$Array = Sort-Object -InputObject ($EColi.ToCharArray())
}
# PowerShell 7.1.4
# TotalSeconds : 56.6806371
Measure-Command {
$Array = $EColi.ToCharArray()
[Array]::Sort($Array)
}
# PowerShell 7.1.4
# TotalSeconds : 0.1773409
Similar to arrays, strings can provide challenges when using the standard, built-in concatenation and formatting features. To work around this, you can use classes like the StringBuilder
class to improve the performance of string operations.
The StringBuilder
class drastically increases the performance the below loop over 100,000 characters being added and formatted to a string.
Measure-Command {
$String = ""
1..100000 | ForEach-Object {
$String += "{0}" -f $_
}
$String
}
# PowerShell 7.1.4
# TotalSeconds : 19.0313727
Measure-Command {
$StringBuilder = [System.Text.StringBuilder]::new()
1..100000 | ForEach-Object {
$StringBuilder.AppendFormat("{0}", $_) > $null
}
$StringBuilder.ToString()
}
# PowerShell 7.1.4
# TotalSeconds : 0.5531556
The Where()
method was introduced in PowerShell v4 and provides a shorthand way of filtering collections similar to Where-Object
. It has the added benefit of being faster. The big downside of Where-Object
is that it requires the pipeline to function which slows it down.
Measure-Command {
1..1000000| Where-Object {$_ -eq 10}
}
# PowerShell 7.1.4
# TotalSeconds : 5.1956048
Measure-Command {
(1..1000000).Where({$_ -eq 10})
}
# PowerShell 7.1.4
# TotalSeconds : 1.2118224
Find this useful? Please consider sharing this article. Have a question about PowerShell? Contact us and we'll write a post about it.
Continue the conversion on the Ironman Software forums. Chat with over 1000 users about PowerShell, PowerShell Universal, and PowerShell Pro Tools.
Receive once-a-month updates about Ironman Software. You'll learn about our product updates and blogs related to PowerShell.