Considérations relatives aux performances des scripts PowerShell

Les scripts PowerShell qui exploitent directement .NET en évitant le pipeline ont tendance à être plus rapides que le PowerShell idiomatique. Le PowerShell idiomatique utilise des applets de commande et des fonctions PowerShell, en tirant souvent parti du pipeline et en ayant recours à .NET uniquement quand cela est nécessaire.

Notes

La plupart des techniques décrites ici ne sont pas issues du PowerShell idiomatiques, ce qui peut réduire la lisibilité d’un script PowerShell. Nous conseillons aux auteurs de scripts d’utiliser le PowerShell idiomatique, à moins que les performances ne le permettant pas.

Empêcher la génération de sortie

Il existe de nombreuses façons d’éviter d’écrire des objets dans le pipeline.

  • Affectation ou redirection de fichiers vers $null
  • Cast sur [void]
  • Piping vers Out-Null

Les vitesses d’affectation à $null, de cast sur [void]et de redirection de fichier vers $null sont presque identiques. Toutefois, le fait d’appeler Out-Null dans une grande boucle peut être beaucoup plus lent, en particulier dans 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'
        }
    }
}

Ces tests ont été exécutés dans PowerShell 7.3.4 sur un ordinateur Windows 11. Les résultats sont affichés ci-dessous :

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

Les temps et les vitesses relatives peuvent varier en fonction du matériel, de la version de PowerShell et de la charge de travail actuelle sur le système.

Ajout de tableaux

La génération d’une liste d’éléments se fait souvent à l’aide d’un tableau avec l’opérateur d’addition :

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

L’ajout de tableaux est inefficace, car les tableaux ont une taille fixe. Chaque ajout au tableau crée un autre tableau suffisamment grand pour contenir tous les éléments des opérandes de gauche et de droite. Les éléments des deux opérandes sont copiés dans le nouveau tableau. Pour les petits regroupements, cette surcharge peut être faible. Les performances peuvent être impactées pour les collections volumineuses.

Il existe deux alternatives à cela. Si vous n’avez pas besoin d’un tableau, vous pouvez utiliser à la place une liste générique typée ([List<T>]) :

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

L’impact sur les performances de l’utilisation de l’ajout de tableaux augmente de façon exponentielle selon la taille de la collection et le nombre d’ajouts. Ce code compare explicitement l’affectation de valeurs à un tableau en utilisant l’ajout à un tableau et la méthode Add(T) sur un objet [List<T>]. Il définit l’affectation explicite comme ligne de base pour les performances.

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

Ces tests ont été exécutés dans PowerShell 7.3.4 sur un ordinateur Windows 11.

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

Quand vous utilisez de grandes collections, l’ajout à un tableau est considérablement plus lent que l’ajout à une List<T>.

Quand vous utilisez un objet [List<T>], vous devez créer la liste avec un type spécifique, comme [String] ou [Int]. Quand vous ajoutez des objets d’un type différent à la liste, ils sont castés vers le type spécifié. S’ils ne peuvent pas être castés dans le type spécifié, la méthode lève une exception.

$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

Quand vous avez besoin que la liste soit une collection de différents types d’objets, créez-la avec [Object] comme type de liste. Vous pouvez énumérer la collection et inspecter les types des objets qu’elle contient.

$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

Si vous avez besoin d’un tableau, vous pouvez appeler la méthode ToArray() sur la liste ou laisser PowerShell créer le tableau à votre place :

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

Dans cet exemple, PowerShell crée un [ArrayList] pour stocker les résultats écrits dans le pipeline à l’intérieur de l’expression de tableau. Juste avant l’affectation à $results, PowerShell convertit [ArrayList] en [Object[]].

Ajout de chaîne

Les chaînes sont immuables. Chaque ajout à la chaîne crée en fait une nouvelle chaîne suffisamment grande pour contenir tout le contenu des opérandes de gauche et de droite, puis copie les éléments des deux opérandes dans la nouvelle chaîne. Pour les chaînes de petite taille, cette surcharge peut ne pas avoir d’importance. Pour les chaînes volumineuses, cela peut affecter les performances et la consommation de mémoire.

Il existe au moins deux alternatives :

  • L’opérateur -join concatène des chaînes
  • La classe .NET [StringBuilder] fournit une chaîne mutable

L’exemple suivant compare les performances de ces trois méthodes de création d’une chaîne.

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

Ces tests ont été exécutés dans PowerShell 7.4.2 sur une machine Windows 11. Les résultats montrent que l’opérateur -join est le plus rapide, suivi de la classe [StringBuilder].

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

Les temps et les vitesses relatives peuvent varier en fonction du matériel, de la version de PowerShell et de la charge de travail actuelle sur le système.

Traitement des fichiers volumineux

La méthode idiomatique pour traiter un fichier dans PowerShell peut ressembler à ceci :

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

Ceci peut s’avérer beaucoup plus lent que d’utiliser directement des API .NET. Par exemple, vous pouvez utiliser la classe .NET [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()
    }
}

Vous pouvez également utiliser la méthode ReadLines de [System.IO.File], qui inclut StreamReaderdans un wrapper et simplifie le processus de lecture :

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

Recherche d’entrées par propriété dans des collections volumineuses

Il est courant d’avoir à utiliser une propriété partagée pour identifier le même enregistrement dans différentes collections, comme l’utilisation d’un nom pour récupérer un ID d’une liste et un e-mail d’une autre liste. Faire une itération sur la première liste pour rechercher l’enregistrement correspondant dans la deuxième collection est une opération lente. En particulier, le filtrage répété de la deuxième collection a une surcharge importante.

Examinons deux collections : l’une d’elles a les propriétés ID et Nom, et l’autre a les propriétés Nom et E-mail :

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

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

La méthode habituelle pour rapprocher ces collections afin de retourner une liste d’objets avec les propriétés ID, Nom et E-mail peut ressembler à ceci :

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

Toutefois, cette implémentation doit filtrer les 5 000 éléments de la collection $Accounts, à raison d’une fois pour chaque élément de la collection $Employee. Cette opération peut prendre plusieurs minutes, même pour une recherche d’une seule valeur comme celle-ci.

Au lieu de cela, vous pouvez créer une table de hachage qui utilise la propriété partagée Nom comme clé et le compte correspondant comme valeur.

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

Rechercher des clés dans une table de hachage est beaucoup plus rapide que de filtrer une collection par valeurs de propriété. Au lieu de vérifier chaque élément dans la collection, PowerShell peut simplement vérifier si la clé est définie et utiliser sa valeur.

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

C’est beaucoup plus rapide. Alors que le filtre en boucle a pris plusieurs minutes, la recherche par hachage prend moins d’une seconde.

Utiliser Write-Host avec précaution

La commande Write-Host doit être utilisée seulement quand vous devez écrire du texte mis en forme sur la console de l’hôte, au lieu d’écrire des objets dans le pipeline Success (Réussite).

Write-Host peut être beaucoup plus lent que [Console]::WriteLine() pour des hôtes spécifiques comme pwsh.exe, powershell.exe ou powershell_ise.exe. Toutefois, il n’est pas garanti que [Console]::WriteLine() fonctionne sur tous les hôtes. De plus, la sortie écrite à l’aide de [Console]::WriteLine() n’est pas écrite dans les transcriptions démarrées par Start-Transcript.

JIT (compilation)

PowerShell compile le code de script en bytecode interprété. À partir de PowerShell 3, pour tout code exécuté de façon répétée dans une boucle, PowerShell peut améliorer les performances en effectuant une compilation JIT (juste-à-temps) du code en code natif.

Les boucles qui comportent moins de 300 instructions sont éligibles pour la compilation JIT. Les boucles plus volumineuses sont trop coûteuses à compiler. Lorsque la boucle s’est exécutée 16 fois, le script est compilé juste-à-temps en arrière-plan. Lorsque la compilation JIT est terminée, l’exécution est transférée au code compilé.

Éviter les appels répétés à une fonction

L’appel d’une fonction peut être une opération coûteuse. Si vous appelez une fonction dans une boucle courte d’exécution longue, envisagez de déplacer la boucle à l’intérieur de la fonction.

Penchez-vous sur les exemples suivants :

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

L’exemple Basic for-loop est la ligne de base pour les performances. Le deuxième exemple wrappe le générateur de nombres aléatoires dans une fonction appelée au sein d’une boucle serrée. Le troisième exemple déplace la boucle à l’intérieur de la fonction. La fonction est appelée une seule fois, mais le code génère néanmoins la même quantité de nombres aléatoires. Notez la différence entre les délais d’exécution pour chaque exemple.

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

Éviter de wrapper des pipelines d’applet de commande

La plupart des applets de commande sont implémentées pour le pipeline, qui correspond à une syntaxe et un processus séquentiels. Par exemple :

cmdlet1 | cmdlet2 | cmdlet3

L’initialisation d’un nouveau pipeline peut être coûteuse. Vous devez donc éviter de wrapper un pipeline d’applet de commande dans un autre pipeline existant.

Considérez l'exemple suivant. Le fichier Input.csv contient 2 100 lignes. La commande Export-Csv est wrappée dans le pipeline ForEach-Object. L’applet de commande Export-Csv est appelée pour chaque itération de la boucle 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

Pour l’exemple suivant, la commande Export-Csv a été déplacée en dehors du pipeline ForEach-Object. Dans le cas présent, Export-Csv est appelé une seule fois, mais traite toujours tous les objets passés en dehors de 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

L’exemple sans inclusion dans un wrapper est 372 fois plus rapide. Notez également que la première implémentation nécessite le paramètre Append, qui n’est pas obligatoire pour la prochaine implémentation.