Prestandaöverväganden för PowerShell-skript

PowerShell-skript som utnyttjar .NET direkt och undviker pipelinen tenderar att vara snabbare än idiomatiska PowerShell. Idiomatic PowerShell använder cmdletar och PowerShell-funktioner, använder ofta pipelinen och använder endast .NET när det behövs.

Kommentar

Många av de tekniker som beskrivs här är inte idiomatiska PowerShell och kan minska läsbarheten för ett PowerShell-skript. Skriptförfattare rekommenderas att använda idiomatisk PowerShell om inte prestandan kräver något annat.

Utelämna utdata

Det finns många sätt att undvika att skriva objekt till pipelinen.

  • Tilldelning eller filomdirigering till $null
  • Gjutning till [void]
  • Rör till Out-Null

Hastigheterna för tilldelning till $null, gjutning till [void]och filomdirigering till $null är nästan identiska. Det kan dock vara betydligt långsammare att anropa Out-Null i en stor loop, särskilt i PowerShell 5.1.

$tests = @{
    'Assign to $null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $null = $arraylist.Add($i)
        }
    }
    'Cast to [void]' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            [void] $arraylist.Add($i)
        }
    }
    'Redirect to $null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $arraylist.Add($i) > $null
        }
    }
    'Pipe to Out-Null' = {
        $arrayList = [System.Collections.ArrayList]::new()
        foreach ($i in 0..$args[0]) {
            $arraylist.Add($i) | Out-Null
        }
    }
}

10kb, 50kb, 100kb | ForEach-Object {
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds

        [pscustomobject]@{
            Iterations        = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

Dessa tester kördes på en Windows 11-dator i PowerShell 7.3.4. Resultaten visas nedan:

Iterations Test              TotalMilliseconds RelativeSpeed
---------- ----              ----------------- -------------
     10240 Assign to $null               36.74 1x
     10240 Redirect to $null             55.84 1.52x
     10240 Cast to [void]                62.96 1.71x
     10240 Pipe to Out-Null              81.65 2.22x
     51200 Assign to $null              193.92 1x
     51200 Cast to [void]               200.77 1.04x
     51200 Redirect to $null            219.69 1.13x
     51200 Pipe to Out-Null             329.62 1.7x
    102400 Redirect to $null            386.08 1x
    102400 Assign to $null              392.13 1.02x
    102400 Cast to [void]               405.24 1.05x
    102400 Pipe to Out-Null             572.94 1.48x

Tiderna och de relativa hastigheterna kan variera beroende på maskinvara, version av PowerShell och den aktuella arbetsbelastningen i systemet.

Matristillägg

Det går ofta att generera en lista över objekt med hjälp av en matris med additionsoperatorn:

$results = @()
$results += Get-Something
$results += Get-SomethingElse
$results

Matristillägget är ineffektivt eftersom matriser har en fast storlek. Varje tillägg till matrisen skapar en ny matris som är tillräckligt stor för att innehålla alla element i både vänster och höger operander. Elementen i båda operanderna kopieras till den nya matrisen. För små samlingar kanske den här kostnaden inte spelar någon roll. Prestanda kan bli lidande för stora samlingar.

Det finns ett par alternativ. Om du faktiskt inte behöver en matris bör du i stället överväga att använda en typad allmän lista ([List<T>]):

$results = [System.Collections.Generic.List[object]]::new()
$results.AddRange((Get-Something))
$results.AddRange((Get-SomethingElse))
$results

Prestandapåverkan av att använda matristillägg växer exponentiellt med samlingens storlek och taltilläggen. Den här koden jämför explicit tilldelning av värden till en matris med att använda matristillägg och använda Add(T) metoden för ett [List<T>] objekt. Den definierar explicit tilldelning som baslinje för prestanda.

$tests = @{
    'PowerShell Explicit Assignment' = {
        param($count)

        $result = foreach($i in 1..$count) {
            $i
        }
    }
    '.Add(T) to List<T>' = {
        param($count)

        $result = [Collections.Generic.List[int]]::new()
        foreach($i in 1..$count) {
            $result.Add($i)
        }
    }
    '+= Operator to Array' = {
        param($count)

        $result = @()
        foreach($i in 1..$count) {
            $result += $i
        }
    }
}

5kb, 10kb, 100kb | ForEach-Object {
    $groupResult = foreach($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value -Count $_ }).TotalMilliseconds

        [pscustomobject]@{
            CollectionSize    = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

Dessa tester kördes på en Windows 11-dator i PowerShell 7.3.4.

CollectionSize Test                           TotalMilliseconds RelativeSpeed
-------------- ----                           ----------------- -------------
          5120 PowerShell Explicit Assignment             26.65 1x
          5120 .Add(T) to List<T>                        110.98 4.16x
          5120 += Operator to Array                      402.91 15.12x
         10240 PowerShell Explicit Assignment              0.49 1x
         10240 .Add(T) to List<T>                        137.67 280.96x
         10240 += Operator to Array                     1678.13 3424.76x
        102400 PowerShell Explicit Assignment             11.18 1x
        102400 .Add(T) to List<T>                       1384.03 123.8x
        102400 += Operator to Array                   201991.06 18067.18x

När du arbetar med stora samlingar är matristillägget betydligt långsammare än att lägga till i en List<T>.

När du använder ett [List<T>] objekt måste du skapa listan med en viss typ, till exempel [String] eller [Int]. När du lägger till objekt av en annan typ i listan omvandlas de till den angivna typen. Om de inte kan omvandlas till den angivna typen genererar metoden ett undantag.

$intList = [System.Collections.Generic.List[int]]::new()
$intList.Add(1)
$intList.Add('2')
$intList.Add(3.0)
$intList.Add('Four')
$intList
MethodException:
Line |
   5 |  $intList.Add('Four')
     |  ~~~~~~~~~~~~~~~~~~~~
     | Cannot convert argument "item", with value: "Four", for "Add" to type
     "System.Int32": "Cannot convert value "Four" to type "System.Int32".
     Error: "The input string 'Four' was not in a correct format.""

1
2
3

När du behöver listan som en samling med olika typer av objekt skapar du den med [Object] som listtyp. Du kan räkna upp samlingen genom att granska typerna av objekten i den.

$objectList = [System.Collections.Generic.List[object]]::new()
$objectList.Add(1)
$objectList.Add('2')
$objectList.Add(3.0)
$objectList | ForEach-Object { "$_ is $($_.GetType())" }
1 is int
2 is string
3 is double

Om du behöver en matris kan du anropa ToArray() metoden i listan eller låta PowerShell skapa matrisen åt dig:

$results = @(
    Get-Something
    Get-SomethingElse
)

I det här exemplet skapar PowerShell en [ArrayList] för att lagra resultaten som skrivits till pipelinen i matrisuttrycket. Precis innan du tilldelar till $resultskonverterar [ArrayList] PowerShell till en [Object[]].

Strängtillägg

Strängar är oföränderliga. Varje tillägg till strängen skapar faktiskt en ny sträng som är tillräckligt stor för att innehålla innehållet i både vänster och höger operander och kopierar sedan elementen i båda operanderna till den nya strängen. För små strängar kanske den här kostnaden inte spelar någon roll. För stora strängar kan detta påverka prestanda och minnesförbrukning.

Det finns minst två alternativ:

  • Operatorn -join sammanfogar strängar
  • .NET-klassen [StringBuilder] innehåller en föränderlig sträng

I följande exempel jämförs prestandan för dessa tre metoder för att skapa en sträng.

$tests = @{
    'StringBuilder' = {
        $sb = [System.Text.StringBuilder]::new()
        foreach ($i in 0..$args[0]) {
            $sb = $sb.AppendLine("Iteration $i")
        }
        $sb.ToString()
    }
    'Join operator' = {
        $string = @(
            foreach ($i in 0..$args[0]) {
                "Iteration $i"
            }
        ) -join "`n"
        $string
    }
    'Addition Assignment +=' = {
        $string = ''
        foreach ($i in 0..$args[0]) {
            $string += "Iteration $i`n"
        }
        $string
    }
}

10kb, 50kb, 100kb | ForEach-Object {
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = (Measure-Command { & $test.Value $_ }).TotalMilliseconds

        [pscustomobject]@{
            Iterations        = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms, 2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

Dessa tester kördes på en Windows 11-dator i PowerShell 7.4.2. Utdata visar att operatorn -join är snabbast följt av [StringBuilder] klassen.

Iterations Test                   TotalMilliseconds RelativeSpeed
---------- ----                   ----------------- -------------
     10240 Join operator                      14.75 1x
     10240 StringBuilder                      62.44 4.23x
     10240 Addition Assignment +=            619.64 42.01x
     51200 Join operator                      43.15 1x
     51200 StringBuilder                     304.32 7.05x
     51200 Addition Assignment +=          14225.13 329.67x
    102400 Join operator                      85.62 1x
    102400 StringBuilder                     499.12 5.83x
    102400 Addition Assignment +=          67640.79 790.01x

Tiderna och de relativa hastigheterna kan variera beroende på maskinvara, version av PowerShell och den aktuella arbetsbelastningen i systemet.

Bearbeta stora filer

Det idiomatiska sättet att bearbeta en fil i PowerShell kan se ut ungefär så här:

Get-Content $path | Where-Object Length -GT 10

Det kan vara en storleksordning som är långsammare än att använda .NET-API:er direkt. Du kan till exempel använda .NET-klassen [StreamReader] :

try {
    $reader = [System.IO.StreamReader]::new($path)
    while (-not $reader.EndOfStream) {
        $line = $reader.ReadLine()
        if ($line.Length -gt 10) {
            $line
        }
    }
}
finally {
    if ($reader) {
        $reader.Dispose()
    }
}

Du kan också använda ReadLines metoden [System.IO.File]för , som omsluter StreamReader, förenklar läsprocessen:

foreach ($line in [System.IO.File]::ReadLines($path)) {
    if ($line.Length -gt 10) {
        $line
    }
}

Söka efter poster efter egenskap i stora samlingar

Det är vanligt att behöva använda en delad egenskap för att identifiera samma post i olika samlingar, som att använda ett namn för att hämta ett ID från en lista och ett e-postmeddelande från en annan. Iterering över den första listan för att hitta matchande post i den andra samlingen är långsam. I synnerhet har den upprepade filtreringen av den andra samlingen stora omkostnader.

Två samlingar, en med ID och Namn, den andra med Namn och E-post:

$Employees = 1..10000 | ForEach-Object {
    [PSCustomObject]@{
        Id   = $_
        Name = "Name$_"
    }
}

$Accounts = 2500..7500 | ForEach-Object {
    [PSCustomObject]@{
        Name  = "Name$_"
        Email = "Name$_@fabrikam.com"
    }
}

Det vanliga sättet att stämma av dessa samlingar för att returnera en lista över objekt med egenskaperna ID, Namn och E-post kan se ut så här:

$Results = $Employees | ForEach-Object -Process {
    $Employee = $_

    $Account = $Accounts | Where-Object -FilterScript {
        $_.Name -eq $Employee.Name
    }

    [pscustomobject]@{
        Id    = $Employee.Id
        Name  = $Employee.Name
        Email = $Account.Email
    }
}

Implementeringen måste dock filtrera alla 5 000 objekt i $Accounts samlingen en gång för varje objekt i $Employee samlingen. Det kan ta minuter, även för den här sökningen med ett värde.

I stället kan du skapa en Hash-tabell som använder egenskapen delat namn som en nyckel och det matchande kontot som värde.

$LookupHash = @{}
foreach ($Account in $Accounts) {
    $LookupHash[$Account.Name] = $Account
}

Det går mycket snabbare att leta upp nycklar i en hash-tabell än att filtrera en samling efter egenskapsvärden. I stället för att kontrollera varje objekt i samlingen kan PowerShell kontrollera om nyckeln har definierats och använda dess värde.

$Results = $Employees | ForEach-Object -Process {
    $Email = $LookupHash[$_.Name].Email
    [pscustomobject]@{
        Id    = $_.Id
        Name  = $_.Name
        Email = $Email
    }
}

Det här går mycket snabbare. Loopningsfiltret tog några minuter att slutföra, men hash-sökningen tar mindre än en sekund.

Använd Write-Host noggrant

Kommandot Write-Host bör endast användas när du behöver skriva formaterad text till värdkonsolen i stället för att skriva objekt till pipelinen Lyckades .

Write-Host kan vara en storleksordning som är långsammare än [Console]::WriteLine() för specifika värdar som pwsh.exe, powershell.exeeller powershell_ise.exe. Det är dock [Console]::WriteLine() inte säkert att det fungerar på alla värdar. Utdata som skrivs med [Console]::WriteLine() skrivs inte heller till avskrifter som startas av Start-Transcript.

JIT-kompilering

PowerShell kompilerar skriptkoden till bytekod som tolkas. Från och med PowerShell 3, för kod som körs upprepade gånger i en loop, kan PowerShell förbättra prestanda genom att JIT (Just-in-time) kompilerar koden till intern kod.

Loopar som har färre än 300 instruktioner är berättigade till JIT-kompilering. Loopar som är större än så är för kostsamma för att kompileras. När loopen har körts 16 gånger är skriptet JIT-kompilerat i bakgrunden. När JIT-kompilering är klar överförs körningen till den kompilerade koden.

Undvik upprepade anrop till en funktion

Att anropa en funktion kan vara en dyr åtgärd. Om du anropar en funktion i en tidskrävande tight loop kan du överväga att flytta loopen inuti funktionen.

Föreställ dig följande exempel:

$tests = @{
    'Simple for-loop'       = {
        param([int] $RepeatCount, [random] $RanGen)

        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $null = $RanGen.Next()
        }
    }
    'Wrapped in a function' = {
        param([int] $RepeatCount, [random] $RanGen)

        function Get-RandomNumberCore {
            param ($rng)

            $rng.Next()
        }

        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $null = Get-RandomNumberCore -rng $RanGen
        }
    }
    'for-loop in a function' = {
        param([int] $RepeatCount, [random] $RanGen)

        function Get-RandomNumberAll {
            param ($rng, $count)

            for ($i = 0; $i -lt $count; $i++) {
                $null = $rng.Next()
            }
        }

        Get-RandomNumberAll -rng $RanGen -count $RepeatCount
    }
}

5kb, 10kb, 100kb | ForEach-Object {
    $rng = [random]::new()
    $groupResult = foreach ($test in $tests.GetEnumerator()) {
        $ms = Measure-Command { & $test.Value -RepeatCount $_ -RanGen $rng }

        [pscustomobject]@{
            CollectionSize    = $_
            Test              = $test.Key
            TotalMilliseconds = [math]::Round($ms.TotalMilliseconds,2)
        }

        [GC]::Collect()
        [GC]::WaitForPendingFinalizers()
    }

    $groupResult = $groupResult | Sort-Object TotalMilliseconds
    $groupResult | Select-Object *, @{
        Name       = 'RelativeSpeed'
        Expression = {
            $relativeSpeed = $_.TotalMilliseconds / $groupResult[0].TotalMilliseconds
            [math]::Round($relativeSpeed, 2).ToString() + 'x'
        }
    }
}

Basic for-loop-exemplet är baslinjen för prestanda. Det andra exemplet omsluter slumptalsgeneratorn i en funktion som anropas i en snäv loop. Det tredje exemplet flyttar loopen inuti funktionen. Funktionen anropas bara en gång, men koden genererar fortfarande samma mängd slumpmässiga tal. Observera skillnaden i körningstider för varje exempel.

CollectionSize Test                   TotalMilliseconds RelativeSpeed
-------------- ----                   ----------------- -------------
          5120 for-loop in a function              9.62 1x
          5120 Simple for-loop                    10.55 1.1x
          5120 Wrapped in a function              62.39 6.49x
         10240 Simple for-loop                    17.79 1x
         10240 for-loop in a function             18.48 1.04x
         10240 Wrapped in a function             127.39 7.16x
        102400 for-loop in a function            179.19 1x
        102400 Simple for-loop                   181.58 1.01x
        102400 Wrapped in a function            1155.57 6.45x

Undvik att omsluta cmdlet-pipelines

De flesta cmdletar implementeras för pipelinen, vilket är en sekventiell syntax och process. Till exempel:

cmdlet1 | cmdlet2 | cmdlet3

Det kan vara dyrt att initiera en ny pipeline. Därför bör du undvika att omsluta en cmdlet-pipeline till en annan befintlig pipeline.

Betänk följande exempel. Filen Input.csv innehåller 2 100 rader. Kommandot Export-Csv omsluts i pipelinen ForEach-Object . Cmdleten Export-Csv anropas för varje iteration av loopen ForEach-Object .

$measure = Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 1 } -Process {
        [PSCustomObject]@{
            Id   = $Id
            Name = $_.opened_by
        } | Export-Csv .\Output1.csv -Append
    }
}

'Wrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Wrapped = 15,968.78 ms

I nästa exempel Export-Csv flyttades kommandot utanför pipelinen ForEach-Object . I det här fallet Export-Csv anropas bara en gång, men bearbetar fortfarande alla objekt som skickas ut från ForEach-Object.

$measure = Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 2 } -Process {
        [PSCustomObject]@{
            Id   = $Id
            Name = $_.opened_by
        }
    } | Export-Csv .\Output2.csv
}

'Unwrapped = {0:N2} ms' -f $measure.TotalMilliseconds
Unwrapped = 42.92 ms

Det oöppnade exemplet är 372 gånger snabbare. Observera också att den första implementeringen kräver parametern Lägg till , vilket inte krävs för den senare implementeringen.