PowerShell Performance Tips

Image Description

Daily PowerShell #19

Scripting Daily PowerShell

November 4, 2021

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.

Understanding Performance Metrics

There are several places we can look to measure the performance of PowerShell.

Measure-Command

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 }

Profile Timing

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

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

PowerShell Profiler

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.

Differences in Version

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

Collection Performance

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 Performance

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"

Function Performance

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

Loop and Pipeline Performance

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

PSCustomObject Performance

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

Sorting Performance

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

String Performance

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

Where() vs Where-Object Performance

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