Überlegungen zur Leistung bei der Skripterstellung in PowerShell

PowerShell-Skripts, die .NET direkt nutzen und die Pipeline vermeiden, sind in der Regel schneller als die idiomatische PowerShell-Methode. Bei der idiomatischen PowerShell-Methode werden Cmdlets und PowerShell-Funktionen verwendet, und es wird häufig die Pipeline genutzt. Auf .NET wird nur bei Bedarf zurückgegriffen.

Hinweis

Viele der hier beschriebenen Methoden sind keine idiomatischen PowerShell-Methoden und können die Lesbarkeit eines PowerShell-Skripts beeinträchtigen. Skriptautoren wird empfohlen, die idiomatische PowerShell-Methode zu verwenden, sofern aus Leistungsgründen nicht anders vorgegeben.

Unterdrücken der Ausgabe

Es gibt viele Möglichkeiten, das Schreiben von Objekten in die Pipeline zu vermeiden.

  • Zuweisung oder Dateiumleitung an $null
  • Umwandlung in [void]
  • Pipe an Out-Null

Bei der Zuweisung an $null, der Umwandlung in [void] und der Dateiumleitung an $null sind die Geschwindigkeiten nahezu identisch. Der Aufruf von Out-Null in einer großen Schleife kann jedoch deutlich langsamer sein, insbesondere in 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'
        }
    }
}

Diese Tests wurden auf einem Windows 11-Computer in PowerShell 7.3.4 ausgeführt. Hier sehen Sie die Ergebnisse:

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

Die Zeiten und relativen Geschwindigkeiten können je nach Hardware, PowerShell-Version und aktueller Workload des Systems variieren.

Addition eines Arrays

Das Generieren einer Liste von Elementen erfolgt häufig mithilfe eines Arrays mit dem Additionsoperator:

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

Die Addition eines Arrays ist ineffizient, da Arrays eine feste Größe haben. Bei jeder Ergänzung des Arrays wird jeweils ein neues Array erstellt, das groß genug ist, um alle Elemente des linken und rechten Operanden zu enthalten. Die Elemente beider Operanden werden in das neue Array kopiert. Bei kleinen Sammlungen kann dieser Mehraufwand unerheblich sein. Bei großen Sammlungen kommt es möglicherweise zu einer Beeinträchtigung der Leistung.

Es gibt eine Reihe von Alternativen. Wenn Sie kein Array benötigen, sollten Sie stattdessen eine typisierte generische Liste ([List<T>]) verwenden:

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

Die Leistungsauswirkung bei Addition eines Arrays wächst exponentiell mit der Größe der Sammlung und den Zahlenadditionen. Dieser Code vergleicht das explizite Zuweisen von Werten zu einem Array mit der Anwendung der Arrayaddition und der Anwendung der Add(T)-Methode auf ein [List<T>]-Objekt. Er definiert die explizite Zuweisung als Baseline für die Leistung.

$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'
        }
    }
}

Diese Tests wurden auf einem Windows 11-Computer in PowerShell 7.3.4 ausgeführt.

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

Wenn Sie mit großen Auflistungen arbeiten, ist die Arrayaddition erheblich langsamer als das Hinzufügen zu einem List<T>-Objekt.

Wenn Sie ein [List<T>]-Objekt verwenden, müssen Sie die Liste mit einem bestimmten Typ erstellen, z. B. mit [String] oder [Int]. Wenn Sie der Liste Objekte eines anderen Typs hinzufügen, werden sie in den angegebenen Typ umgewandelt. Wenn sie nicht in den angegebenen Typ umgewandelt werden können, löst die Methode eine Ausnahme aus.

$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

Wenn Sie die Liste als Auflistung verschiedener Objekttypen benötigen, erstellen Sie sie mit [Object] als Listentyp. Sie können die Sammlung auflisten, um die Typen der in ihr enthaltenen Objekte zu untersuchen.

$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

Wenn Sie ein Array benötigen, können Sie die Methode ToArray() auf der Liste aufrufen oder PowerShell das Array für Sie erstellen lassen:

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

In diesem Beispiel erstellt PowerShell ein [ArrayList]-Element, um die im Arrayausdruck in die Pipeline geschriebenen Ergebnisse zu speichern. Kurz vor dem Zuweisen zu $results konvertiert PowerShell das [ArrayList]-Element in ein [Object[]]-Element.

Addition von Zeichenfolgen

Zeichenfolgen sind unveränderlich. Durch jede Addition zur Zeichenfolge wird in Wirklichkeit eine neue Zeichenfolge erstellt, die groß genug ist, um alle Elemente des linken und rechten Operanden aufzunehmen. Anschließend werden die Elemente beider Operanden in die neue Zeichenfolge kopiert. Bei kleinen Zeichenfolgen spielt dieser Mehraufwand möglicherweise keine Rolle. Bei großen Zeichenfolgen kann sich das auf die Leistung und dir Arbeitsspeicherauslastung auswirken.

Es gibt mindestens zwei Alternativen:

  • Der -join-Operator verkettet Zeichenfolgen.
  • Die .NET-Klasse [StringBuilder] stellt eine veränderbare Zeichenfolge bereit.

Im folgenden Beispiel wird die Leistung dieser drei Methoden zum Erstellen einer Zeichenfolge verglichen.

$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'
        }
    }
}

Diese Tests wurden auf einem Windows 11-Computer in PowerShell 7.4.2 ausgeführt. Die Ausgabe zeigt, dass der -join-Operator am schnellsten ist, danach folgt die [StringBuilder]-Klasse.

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

Die Zeiten und relativen Geschwindigkeiten können je nach Hardware, PowerShell-Version und aktueller Workload des Systems variieren.

Verarbeiten großer Dateien

Die idiomatische Methode zum Verarbeiten einer Datei in PowerShell könnte in etwa wie folgt aussehen:

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

Dies kann erheblich langsamer sein als die direkte Verwendung von .NET-APIs. Sie können z. B. die .NET-Klasse [StreamReader] verwenden:

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()
    }
}

Sie können auch die ReadLines-Methode von [System.IO.File] verwenden, die StreamReader umschließt und den Lesevorgang vereinfacht:

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

Suchen nach Einträgen nach Eigenschaft in großen Sammlungen

Es ist üblich, dass Sie eine freigegebene Eigenschaft verwenden müssen, um den gleichen Datensatz in verschiedenen Sammlungen zu identifizieren, z. B. mithilfe eines Namens zum Abrufen einer ID aus einer Liste und einer E-Mail aus einer anderen. Es dauert lange, die erste Liste zu durchlaufen, um den übereinstimmenden Datensatz in der zweiten Sammlung zu finden. Insbesondere die wiederholte Filterung der zweiten Sammlung bedeutet einen großen Mehraufwand.

Nehmen wir an, es gibt zwei Sammlungen, eine mit einer ID und einen Namen, die andere mit einem Namen und einer E-Mail:

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

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

Die übliche Methode zum Abgleichen dieser Sammlungen, um eine Liste von Objekten mit den Eigenschaften ID, Name und Email zurückzugeben, könnte wie folgt aussehen:

$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
    }
}

Diese Implementierung muss jedoch alle 5000 Elemente in der Sammlung $Accounts einmal für jedes Element in der Sammlung $Employee filtern. Dies kann bei der Einzelwertsuche einige Minuten dauern.

Sie können stattdessen eine Hashtabelle erstellen, die die freigegebene Name-Eigenschaft als Schlüssel und das übereinstimmende Konto als Wert verwendet.

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

Das Suchen nach Schlüsseln in einer Hashtabelle ist viel schneller als das Filtern einer Sammlung nach Eigenschaftswerten. Anstatt jedes Element in der Sammlung überprüfen zu müssen, kann PowerShell überprüfen, ob der Schlüssel definiert ist, und seinen Wert verwenden.

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

Das geht viel schneller. Während der Schleifenfilter mehrere Minuten gebraucht hat, dauert die Hashsuche weniger als eine Sekunde.

Sorgfältiges Verwenden von Write-Host

Der Befehl Write-Host sollte nur verwendet werden, wenn Sie formatierten Text an der Hostkonsole schreiben müssen, anstatt Objekte in die Success-Pipeline zu schreiben.

Write-Host kann für bestimmte Hosts wie pwsh.exe, powershell.exe oder powershell_ise.exe erheblich langsamer sein als [Console]::WriteLine(). [Console]::WriteLine() funktioniert jedoch möglicherweise nicht auf allen Hosts. Außerdem werden mit [Console]::WriteLine() geschriebene Ausgaben nicht in Transkripte geschrieben, die von Start-Transcript gestartet werden.

JIT-Kompilierung

PowerShell kompiliert den Skriptcode in Bytecode, der interpretiert wird. Ab PowerShell 3 kann PowerShell für Code, der wiederholt in einer Schleife ausgeführt wird, die Leistung verbessern, indem der Code per JIT-Verfahren (Just-In-Time) in nativen Code kompiliert wird.

Schleifen mit weniger als 300 Anweisungen sind für die JIT-Kompilierung geeignet. Bei größeren Schleifen ist diese Kompilierung zu kostspielig. Wenn die Schleife 16-mal ausgeführt wurde, wird das Skript im Hintergrund per JIT kompiliert. Wenn die JIT-Kompilierung abgeschlossen ist, wird die Ausführung an den kompilierten Code übergeben.

Vermeiden wiederholter Aufrufe einer Funktion

Das Aufrufen einer Funktion kann ein kostspieliger Vorgang sein. Wenn Sie eine Funktion in einer zeitintensiven, engen Schleife aufrufen, sollten Sie erwägen, die Schleife in die Funktion zu verschieben.

Betrachten Sie die folgenden Beispiele:

$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'
        }
    }
}

Das Beispiel Basic for-loop ist die Baseline für die Leistung. Das zweite Beispiel umschließt den Zufallszahlengenerator in einer Funktion, die in einer engen Schleife aufgerufen wird. Im dritten Beispiel wird die Schleife in die Funktion verschoben. Die Funktion wird nur einmal aufgerufen, aber der Code generiert trotzdem dieselbe Anzahl Zufallszahlen. Beachten Sie den Unterschied bei den Ausführungszeiten für jedes Beispiel.

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

Vermeiden des Umschließens von Cmdlet-Pipelines

Die meisten Cmdlets werden für die Pipeline implementiert. Hierbei handelt es sich um eine sequenzielle Syntax und einen entsprechenden Prozess. Beispiel:

cmdlet1 | cmdlet2 | cmdlet3

Das Initialisieren einer neuen Pipeline kann aufwendig sein. Vermeiden Sie es daher, eine Cmdlet-Pipeline in eine andere vorhandene Pipeline einzuschließen.

Betrachten Sie das folgende Beispiel. Die Datei Input.csv enthält 2.100 Zeilen. Der Befehl Export-Csv ist in die Pipeline ForEach-Object eingeschlossen. Das Cmdlet Export-Csv wird für jede Iteration der Schleife vom Typ ForEach-Object aufgerufen.

$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

Im nächsten Beispiel wurde der Befehl Export-Csv außerhalb der Pipeline ForEach-Object platziert. In diesem Fall wird Export-Csv nur einmal aufgerufen. Es werden aber trotzdem alle Objekte verarbeitet, die von ForEach-Object übergeben werden.

$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

Das Beispiel ohne Umschließung ist 372-mal schneller. Beachten Sie außerdem, dass die erste Implementierung den Parameter Append erfordert. In der späteren Implementierung wird er dagegen nicht benötigt.