Zagadnienia dotyczące wydajności skryptów programu PowerShell

Skrypty programu PowerShell, które korzystają bezpośrednio z platformy .NET i unikają potoku, wydają się być szybsze niż idiomatyczny program PowerShell. Idiomatyczny program PowerShell używa poleceń cmdlet i funkcji programu PowerShell, często korzystających z potoku i uciekających się do platformy .NET tylko wtedy, gdy jest to konieczne.

Uwaga

Wiele technik opisanych w tym miejscu nie jest idiomatycznych programu PowerShell i może zmniejszyć czytelność skryptu programu PowerShell. Autorzy skryptów powinni używać idiotycznego programu PowerShell, chyba że wydajność będzie dyktowała inaczej.

Pomijanie danych wyjściowych

Istnieje wiele sposobów, aby uniknąć zapisywania obiektów w potoku.

  • Przypisywanie do $null
  • Rzutowanie do [void]
  • Przekierowanie pliku do $null
  • Potok do Out-Null

Szybkość przypisywania do metody , rzutowania do $null[void]metody i przekierowania pliku do $null metody jest prawie identyczna. Jednak wywołanie Out-Null w dużej pętli może być znacznie wolniejsze, zwłaszcza w programie 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'
        }
    }
}

Te testy zostały uruchomione na maszynie z systemem Windows 11 w programie PowerShell 7.3.4. Poniżej przedstawiono wyniki:

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

Czasy i względne szybkości mogą się różnić w zależności od sprzętu, wersji programu PowerShell i bieżącego obciążenia w systemie.

Dodawanie tablicy

Generowanie listy elementów jest często wykonywane przy użyciu tablicy z operatorem dodawania:

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

Dodanie tablicy jest nieefektywne, ponieważ tablice mają stały rozmiar. Każdy dodatek do tablicy tworzy nową tablicę wystarczająco dużą, aby przechowywać wszystkie elementy zarówno lewego, jak i prawego operandu. Elementy obu operandów są kopiowane do nowej tablicy. W przypadku małych kolekcji to obciążenie może nie mieć znaczenia. Wydajność może być spora w przypadku dużych kolekcji.

Istnieje kilka alternatyw. Jeśli tablica nie jest w rzeczywistości wymagana, rozważ użycie wpisanej listy ogólnej (Lista<T>):

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

Wpływ na wydajność użycia dodawania tablicy rośnie wykładniczo wraz z rozmiarem kolekcji i liczbą dodatków. Ten kod porównuje jawne przypisywanie wartości do tablicy przy użyciu dodawania tablicy i używanie Add() metody na liście<T>. Definiuje jawne przypisanie jako punkt odniesienia dla wydajności.

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

        $result = foreach($i in 1..$count) {
            $i
        }
    }
    '.Add(..) 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'
        }
    }
}

Te testy zostały uruchomione na maszynie z systemem Windows 11 w programie PowerShell 7.3.4.

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

Podczas pracy z dużymi kolekcjami dodawanie tablicy jest znacznie wolniejsze niż dodawanie do listy<T>.

W przypadku korzystania z listy<T> należy utworzyć listę z określonym typem, na przykład Ciąg lub Int. Po dodaniu obiektów innego typu do listy są one rzutowane do określonego typu. Jeśli nie można rzutować ich do określonego typu, metoda zgłasza wyjątek.

$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

Jeśli lista musi być kolekcją różnych typów obiektów, utwórz ją z obiektem jako typem listy. Możesz wyliczyć kolekcję, aby sprawdzić typy obiektów w niej.

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

Jeśli potrzebujesz tablicy, możesz wywołać ToArray() metodę na liście lub umożliwić programowi PowerShell utworzenie tablicy dla Ciebie:

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

W tym przykładzie program PowerShell tworzy tablicęList do przechowywania wyników zapisanych w potoku wewnątrz wyrażenia tablicy. Tuż przed przypisaniem do $resultsprogramu program PowerShell konwertuje tablicęlist na obiekt[].

Dodawanie ciągu

Ciągi są niezmienne. Każdy dodatek do ciągu faktycznie tworzy nowy ciąg wystarczająco duży, aby przechowywać zawartość zarówno lewych, jak i prawych operandów, a następnie kopiuje elementy obu operandów do nowego ciągu. W przypadku małych ciągów to obciążenie może nie mieć znaczenia. W przypadku dużych ciągów może to mieć wpływ na wydajność i zużycie pamięci.

Istnieją co najmniej dwie alternatywy:

  • -join Operator łączy ciągi
  • Klasa .NET StringBuilder udostępnia modyfikowalny ciąg

Poniższy przykład porównuje wydajność tych trzech metod tworzenia ciągu.

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

Te testy zostały uruchomione na maszynie z systemem Windows 10 w programie PowerShell 7.3.4. Dane wyjściowe pokazują, że -join operator jest najszybszy, a następnie klasą StringBuilder .

Iterations Test                   TotalMilliseconds RelativeSpeed
---------- ----                   ----------------- -------------
     10240 Join operator                       7.08 1x
     10240 StringBuilder                      54.10 7.64x
     10240 Addition Assignment +=            724.16 102.28x
     51200 Join operator                      41.76 1x
     51200 StringBuilder                     318.06 7.62x
     51200 Addition Assignment +=          17693.06 423.68x
    102400 Join operator                     106.98 1x
    102400 StringBuilder                     543.84 5.08x
    102400 Addition Assignment +=          90693.13 847.76x

Czasy i względne szybkości mogą się różnić w zależności od sprzętu, wersji programu PowerShell i bieżącego obciążenia w systemie.

Przetwarzanie dużych plików

Idiomatyczny sposób przetwarzania pliku w programie PowerShell może wyglądać następująco:

Get-Content $path | Where-Object { $_.Length -gt 10 }

Może to być kolejność wielkości wolniej niż bezpośrednie używanie interfejsów API platformy .NET:

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

Szukanie wpisów według właściwości w dużych kolekcjach

Często należy używać właściwości udostępnionej do identyfikowania tego samego rekordu w różnych kolekcjach, na przykład przy użyciu nazwy w celu pobrania identyfikatora z jednej listy i wiadomości e-mail z innej. Iterowanie na pierwszej liście w celu znalezienia pasującego rekordu w drugiej kolekcji jest powolne. W szczególności powtarzające się filtrowanie drugiej kolekcji ma duże obciążenie.

Biorąc pod uwagę dwie kolekcje, jedną z identyfikatorem i nazwą, drugą z nazwą i adresem e-mail:

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

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

Zwykle sposób uzgadniania tych kolekcji w celu zwrócenia listy obiektów z właściwościami IDENTYFIKATOR, Nazwa i Adres e-mail może wyglądać następująco:

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

Jednak ta implementacja musi filtrować wszystkie 5000 elementów w $Accounts kolekcji raz dla każdego elementu w $Employee kolekcji. Może to potrwać kilka minut, nawet w przypadku tego wyszukiwania pojedynczej wartości.

Zamiast tego możesz utworzyć tabelę skrótów, która używa właściwości nazwa współużytkowanej jako klucza i pasującego konta jako wartości.

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

Szukanie kluczy w tabeli skrótów jest znacznie szybsze niż filtrowanie kolekcji według wartości właściwości. Zamiast sprawdzać każdy element w kolekcji, program PowerShell może sprawdzić, czy klucz jest zdefiniowany i używać jego wartości.

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

Jest to znacznie szybsze. Podczas gdy filtr pętli trwał kilka minut, wyszukiwanie skrótu trwa mniej niż sekundę.

Unikaj zapisu hosta

Zazwyczaj uważa się, że słabe rozwiązanie w przypadku zapisywania danych wyjściowych bezpośrednio w konsoli, ale jeśli ma to sens, wiele skryptów używa metody Write-Host.

Jeśli musisz napisać wiele komunikatów w konsoli programu , Write-Host może być o wiele większe niż [Console]::WriteLine() w przypadku określonych hostów, takich jak pwsh.exe, powershell.exelub powershell_ise.exe. Jednak nie ma gwarancji, [Console]::WriteLine() że działa we wszystkich hostach. Ponadto dane wyjściowe zapisywane przy użyciu [Console]::WriteLine() polecenia nie są zapisywane w transkrypcjach rozpoczętych przez program Start-Transcript.

Zamiast używać Write-Hostmetody , rozważ użycie funkcji Write-Output.

Kompilacja trybu JIT

Program PowerShell kompiluje kod skryptu do kodu bajtowego, który jest interpretowany. Począwszy od programu PowerShell 3, w przypadku kodu, który jest wielokrotnie wykonywany w pętli, program PowerShell może zwiększyć wydajność dzięki kompilowaniu kodu just in time (JIT) do kodu natywnego.

Pętle, które mają mniej niż 300 instrukcji, kwalifikują się do kompilacji JIT. Pętle większe niż te są zbyt kosztowne do skompilowania. Gdy pętla została wykonana 16 razy, skrypt jest kompilowany w tle. Po zakończeniu kompilacji JIT wykonywanie jest przenoszone do skompilowanego kodu.

Unikaj powtarzających się wywołań funkcji

Wywoływanie funkcji może być kosztowną operacją. Jeśli wywołujesz funkcję w długotrwałej ciasnej pętli, rozważ przeniesienie pętli wewnątrz funkcji.

Rozważ następujące przykłady:

$ranGen = New-Object System.Random
$RepeatCount = 10000

'Basic for-loop = {0}ms' -f (Measure-Command -Expression {
    for ($i = 0; $i -lt $RepeatCount; $i++) {
        $Null = $ranGen.Next()
    }
}).TotalMilliseconds

'Wrapped in a function = {0}ms' -f (Measure-Command -Expression {
    function Get-RandNum_Core {
        param ($ranGen)
        $ranGen.Next()
    }

    for ($i = 0; $i -lt $RepeatCount; $i++) {
        $Null = Get-RandNum_Core $ranGen
    }
}).TotalMilliseconds

'For-loop in a function = {0}ms' -f (Measure-Command -Expression {
    function Get-RandNum_All {
        param ($ranGen)
        for ($i = 0; $i -lt $RepeatCount; $i++) {
            $Null = $ranGen.Next()
        }
    }

    Get-RandNum_All $ranGen
}).TotalMilliseconds

Przykład podstawowy dla pętli to linia podstawowa wydajności. Drugi przykład opakowuje generator liczb losowych w funkcji, która jest wywoływana w ciasnej pętli. Trzeci przykład przenosi pętlę wewnątrz funkcji. Funkcja jest wywoływana tylko raz, ale kod nadal generuje 10000 liczb losowych. Zwróć uwagę na różnicę czasu wykonywania dla każdego przykładu.

Basic for-loop = 47.8668ms
Wrapped in a function = 820.1396ms
For-loop in a function = 23.3193ms

Unikaj zawijania potoków poleceń cmdlet

Większość poleceń cmdlet jest implementowanych dla potoku, co jest sekwencyjną składnią i procesem. Na przykład:

cmdlet1 | cmdlet2 | cmdlet3

Inicjowanie nowego potoku może być kosztowne, dlatego należy unikać zawijania potoku poleceń cmdlet do innego istniejącego potoku.

Rozważmy następujący przykład. Plik Input.csv zawiera 2100 wierszy. Polecenie Export-Csv jest opakowane wewnątrz potoku ForEach-Object . Polecenie Export-Csv cmdlet jest wywoływane dla każdej iteracji ForEach-Object pętli.

'Wrapped = {0:N2} ms' -f (Measure-Command -Expression {
    Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 1 } -Process {
        [PSCustomObject]@{
            Id = $Id
            Name = $_.opened_by
        } | Export-Csv .\Output1.csv -Append
    }
}).TotalMilliseconds

Wrapped = 15,968.78 ms

W następnym przykładzie Export-Csv polecenie zostało przeniesione poza ForEach-Object potok. W tym przypadku Export-Csv wywoływana jest tylko raz, ale nadal przetwarza wszystkie obiekty z pustą wartością ForEach-Object.

'Unwrapped = {0:N2} ms' -f (Measure-Command -Expression {
      Import-Csv .\Input.csv | ForEach-Object -Begin { $Id = 2 } -Process {
          [PSCustomObject]@{
              Id = $Id
              Name = $_.opened_by
          }
      } | Export-Csv .\Output2.csv
  }).TotalMilliseconds

Unwrapped = 42.92 ms

Przykład rozpasany jest 372 razy szybszy. Należy również zauważyć, że pierwsza implementacja wymaga parametru Append , który nie jest wymagany do późniejszej implementacji.