Considerazioni sulle prestazioni di scripting di PowerShell

Gli script di PowerShell che sfruttano direttamente .NET ed evitano che la pipeline sia più veloce di PowerShell idiotica. PowerShell idiomatico usa cmdlet e funzioni di PowerShell, spesso sfruttando la pipeline e usando .NET solo quando necessario.

Nota

Molte delle tecniche descritte di seguito non sono idiotiche di PowerShell e possono ridurre la leggibilità di uno script di PowerShell. Gli autori di script sono invitati a usare PowerShell idiomatico, a meno che le prestazioni non determinino diversamente.

Eliminazione dell'output

Esistono molti modi per evitare di scrivere oggetti nella pipeline.

  • Assegnazione a $null
  • Cast a [void]
  • Reindirizzamento di file a $null
  • Pipe a Out-Null

La velocità di assegnazione a $null, il cast a [void]e il reindirizzamento dei file a $null sono quasi identici. Tuttavia, la chiamata Out-Null in un ciclo di grandi dimensioni può essere notevolmente più lenta, soprattutto 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'
        }
    }
}

Questi test sono stati eseguiti in un computer Windows 11 in PowerShell 7.3.4. Di seguito sono riportati i risultati:

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

I tempi e le velocità relative possono variare a seconda dell'hardware, della versione di PowerShell e del carico di lavoro corrente nel sistema.

Aggiunta di matrici

La generazione di un elenco di elementi viene spesso eseguita usando una matrice con l'operatore di addizione:

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

L'aggiunta di matrici non è efficiente perché le matrici hanno una dimensione fissa. Ogni aggiunta alla matrice crea una nuova matrice sufficientemente grande da contenere tutti gli elementi degli operandi sinistro e destro. Gli elementi di entrambi gli operandi vengono copiati nella nuova matrice. Per le raccolte di piccole dimensioni, questo sovraccarico potrebbe non essere importante. Le prestazioni possono risentire delle raccolte di grandi dimensioni.

Ci sono un paio di alternative. Se in realtà non è necessaria una matrice, prendere in considerazione l'uso di un elenco generico tipizzato (elenco<T>):

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

L'impatto sulle prestazioni dell'uso dell'aggiunta della matrice aumenta in modo esponenziale con le dimensioni della raccolta e le aggiunte di numeri. Questo codice confronta in modo esplicito l'assegnazione di valori a una matrice con l'aggiunta di matrici e l'uso del Add() metodo in un elenco<T>. Definisce l'assegnazione esplicita come baseline per le prestazioni.

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

Questi test sono stati eseguiti in un computer Windows 11 in 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

Quando si lavora con raccolte di grandi dimensioni, l'aggiunta di matrici è notevolmente più lenta rispetto all'aggiunta a un elenco<T>.

Quando si usa un elenco<T>, è necessario creare l'elenco con un tipo specifico, ad esempio String o Int. Quando si aggiungono oggetti di un tipo diverso all'elenco, viene eseguito il cast al tipo specificato. Se non è possibile eseguire il cast al tipo specificato, il metodo genera un'eccezione.

$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

Quando è necessario che l'elenco sia una raccolta di diversi tipi di oggetti, crearlo con Object come tipo di elenco. È possibile enumerare l'insieme per esaminare i tipi degli oggetti in esso contenuti.

$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

Se è necessaria una matrice, è possibile chiamare il ToArray() metodo nell'elenco oppure consentire a PowerShell di creare automaticamente la matrice:

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

In questo esempio PowerShell crea un oggetto ArrayList per contenere i risultati scritti nella pipeline all'interno dell'espressione di matrice. Poco prima dell'assegnazione a , PowerShell converte ArrayList in un oggetto[].$results

Addizione di stringhe

Le stringhe non sono modificabili. Ogni aggiunta alla stringa crea effettivamente una nuova stringa sufficientemente grande da contenere il contenuto degli operandi sinistro e destro, quindi copia gli elementi di entrambi gli operandi nella nuova stringa. Per le stringhe di piccole dimensioni, questo sovraccarico potrebbe non essere importante. Per stringhe di grandi dimensioni, questo può influire sull'utilizzo di prestazioni e memoria.

Esistono almeno due alternative:

  • L'operatore -join concatena le stringhe
  • La classe StringBuilder .NET fornisce una stringa modificabile

Nell'esempio seguente vengono confrontate le prestazioni di questi tre metodi di compilazione di una stringa.

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

Questi test sono stati eseguiti in un computer Windows 10 in PowerShell 7.3.4. L'output mostra che l'operatore -join è il più veloce, seguito dalla classe 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

I tempi e le velocità relative possono variare a seconda dell'hardware, della versione di PowerShell e del carico di lavoro corrente nel sistema.

Elaborazione di file di grandi dimensioni

Il modo idiotico per elaborare un file in PowerShell potrebbe essere simile al seguente:

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

Questo può essere un ordine di grandezza più lento rispetto all'uso diretto delle API .NET:

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

Ricerca di voci per proprietà in raccolte di grandi dimensioni

È comune usare una proprietà condivisa per identificare lo stesso record in raccolte diverse, ad esempio usando un nome per recuperare un ID da un elenco e un messaggio di posta elettronica da un altro. L'iterazione del primo elenco per trovare il record corrispondente nella seconda raccolta è lento. In particolare, il filtro ripetuto della seconda raccolta presenta un sovraccarico elevato.

Date due raccolte, una con ID e Name, l'altra con Name e Email:

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

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

Il modo consueto per riconciliare queste raccolte per restituire un elenco di oggetti con le proprietà ID, Name e Email potrebbe essere simile al seguente:

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

Tuttavia, tale implementazione deve filtrare tutti i 5000 elementi nella $Accounts raccolta una volta per ogni elemento della $Employee raccolta. Questa operazione può richiedere minuti, anche per questa ricerca a valore singolo.

È invece possibile creare una tabella hash che usa la proprietà Shared Name come chiave e l'account corrispondente come valore.

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

La ricerca di chiavi in una tabella hash è molto più veloce rispetto al filtro di una raccolta in base ai valori delle proprietà. Anziché controllare ogni elemento nella raccolta, PowerShell può controllare se la chiave è definita e usarne il valore.

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

Questo è molto più veloce. Durante il completamento del filtro di ciclo sono necessari minuti, la ricerca hash richiede meno di un secondo.

Evitare write-host

In genere è considerato poco pratico scrivere l'output direttamente nella console, ma quando ha senso, molti script usano Write-Host.

Se è necessario scrivere molti messaggi nella console, Write-Host può essere un ordine di grandezza più lento rispetto [Console]::WriteLine() a per host specifici, ad pwsh.exeesempio , powershell.exeo powershell_ise.exe. Tuttavia, [Console]::WriteLine() non è garantito il funzionamento in tutti gli host. Inoltre, l'output scritto con [Console]::WriteLine() non viene scritto nelle trascrizioni avviate da Start-Transcript.

Invece di usare Write-Host, è consigliabile usare Write-Output.

Compilazione JIT

PowerShell compila il codice script in bytecode interpretato. A partire da PowerShell 3, per il codice eseguito ripetutamente in un ciclo, PowerShell può migliorare le prestazioni compilando il codice in codice nativo.

I cicli con meno di 300 istruzioni sono idonei per la compilazione JIT. Cicli più grandi di quelli troppo costosi per la compilazione. Quando il ciclo è stato eseguito 16 volte, lo script viene compilato in background. Al termine della compilazione JIT, l'esecuzione viene trasferita al codice compilato.

Evitare chiamate ripetute a una funzione

La chiamata a una funzione può essere un'operazione costosa. Se si chiama una funzione in un ciclo stretto a esecuzione prolungata, è consigliabile spostare il ciclo all'interno della funzione.

Vedi gli esempi seguenti:

$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

L'esempio di ciclo for basic è la linea di base per le prestazioni. Il secondo esempio esegue il wrapping del generatore di numeri casuali in una funzione chiamata in un ciclo stretto. Il terzo esempio sposta il ciclo all'interno della funzione. La funzione viene chiamata una sola volta, ma il codice genera ancora 10000 numeri casuali. Si noti la differenza nei tempi di esecuzione per ogni esempio.

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

Evitare di eseguire il wrapping delle pipeline dei cmdlet

La maggior parte dei cmdlet viene implementata per la pipeline, ovvero una sintassi e un processo sequenziali. Ad esempio:

cmdlet1 | cmdlet2 | cmdlet3

L'inizializzazione di una nuova pipeline può risultare costosa, pertanto è consigliabile evitare di eseguire il wrapping di una pipeline di cmdlet in un'altra pipeline esistente.

Si consideri l'esempio seguente. Il Input.csv file contiene 2100 righe. Il Export-Csv comando viene sottoposto a wrapping all'interno della ForEach-Object pipeline. Il Export-Csv cmdlet viene richiamato per ogni iterazione del ForEach-Object ciclo.

'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

Per l'esempio successivo, il Export-Csv comando è stato spostato all'esterno della ForEach-Object pipeline. In questo caso, Export-Csv viene richiamato una sola volta, ma elabora comunque tutti gli oggetti passati da 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

L'esempio non sottoposto a wrapping è 372 volte più veloce. Si noti anche che la prima implementazione richiede il parametro Append , che non è necessario per l'implementazione successiva.