Partager via


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.

Remarque

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
  • Conversion en [void]
  • Redirection vers Out-Null

Les vitesses d’affectation à $null, de conversion en [void] et de redirection de fichiers vers $null sont presque identiques. Toutefois, le fait d’appeler Out-Null dans une grande boucle peut être beaucoup plus lent, notamment 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 sur un ordinateur Windows 11 dans PowerShell 7.3.4. 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 réellement besoin d’un tableau, envisagez plutôt d’utiliser 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 à l’aide de l’ajout de tableau et de l’utilisation de 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 sur un ordinateur Windows 11 dans 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

Lorsque vous travaillez avec de grandes collections, l’ajout de tableaux est considérablement plus lent que l’ajout à une List<T>.

Lorsque vous utilisez un objet [List<T>], vous devez créer la liste avec un type spécifique, comme [String] ou [Int]. Lorsque vous ajoutez des objets d’un type différent à la liste, ils sont convertis 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

Lorsque 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 les 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 sur un ordinateur Windows 11 dans PowerShell 7.4.2. La sortie indique 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

Cela peut s’avérer dix fois plus lent que l’utilisation directe 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 encapsule StreamReader, 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.

Plutôt que d’utiliser cette méthode, vous pouvez créer une Table de hachage qui utilise la propriété Nom partagée 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 prudence

La commande Write-Host ne doit être utilisée que lorsque vous devez écrire du texte mis en forme dans la console hôte, plutôt que d’écrire des objets dans le pipeline Réussite.

Write-Host peut être un ordre de grandeur 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 serrée longue, vous pouvez 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 n’est appelée qu’une seule fois, mais le code génère toujours 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. 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.

Prenons 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 non wrappé 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.

Création d'objet

La création d’objets à l’aide de l’applet de commande New-Object peut être lente. Le code suivant compare les performances de la création d’objets à l’aide de l’applet de commande New-Object à l’accélérateur de type [pscustomobject].

Measure-Command {
    $test = 'PSCustomObject'
    for ($i = 0; $i -lt 100000; $i++) {
        $resultObject = [PSCustomObject]@{
            Name = 'Name'
            Path = 'FullName'
        }
    }
} | Select-Object @{n='Test';e={$test}},TotalSeconds

Measure-Command {
    $test = 'New-Object'
    for ($i = 0; $i -lt 100000; $i++) {
        $resultObject = New-Object -TypeName PSObject -Property @{
            Name = 'Name'
            Path = 'FullName'
        }
    }
} | Select-Object @{n='Test';e={$test}},TotalSeconds
Test           TotalSeconds
----           ------------
PSCustomObject         0.48
New-Object             3.37

PowerShell 5.0 a ajouté la méthode statique new() pour tous les types .NET. Le code suivant compare les performances de la création d’objets à l’aide de l’applet de commande New-Object à la méthode new().

Measure-Command {
    $test = 'new() method'
    for ($i = 0; $i -lt 100000; $i++) {
        $sb = [System.Text.StringBuilder]::new(1000)
    }
} | Select-Object @{n='Test';e={$test}},TotalSeconds

Measure-Command {
    $test = 'New-Object'
    for ($i = 0; $i -lt 100000; $i++) {
        $sb = New-Object -TypeName System.Text.StringBuilder -ArgumentList 1000
    }
} | Select-Object @{n='Test';e={$test}},TotalSeconds
Test         TotalSeconds
----         ------------
new() method         0.59
New-Object           3.17

Utiliser OrderedDictionary pour créer dynamiquement des objets

Il existe des situations où nous devrons peut-être créer dynamiquement des objets en fonction d’une entrée, la méthode peut-être la plus couramment utilisée pour créer un nouveau PSObject, puis ajouter de nouvelles propriétés à l’aide de l’applet de commande Add-Member. Le coût des performances pour les petites collections à l’aide de cette technique peut être négligeable, mais il peut devenir très visible pour les grandes collections. Dans ce cas, l’approche recommandée consiste à utiliser un [OrderedDictionary], puis à le convertir en PSObject à l’aide de l’accélérateur de type [pscustomobject]. Pour plus d’informations, consultez la section Création de dictionnaires ordonnés de about_Hash_Tables.

Supposons que vous disposez de la réponse d’API suivante stockée dans la variable $json.

{
  "tables": [
    {
      "name": "PrimaryResult",
      "columns": [
        { "name": "Type", "type": "string" },
        { "name": "TenantId", "type": "string" },
        { "name": "count_", "type": "long" }
      ],
      "rows": [
        [ "Usage", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
        [ "Usage", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
        [ "BillingFact", "63613592-b6f7-4c3d-a390-22ba13102111", "1" ],
        [ "BillingFact", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "1" ],
        [ "Operation", "63613592-b6f7-4c3d-a390-22ba13102111", "7" ],
        [ "Operation", "d436f322-a9f4-4aad-9a7d-271fbf66001c", "5" ]
      ]
    }
  ]
}

Supposons maintenant que vous souhaitiez exporter ces données vers un fichier CSV. Tout d’abord, vous devez créer de nouveaux objets et ajouter les propriétés et les valeurs à l’aide de l’applet de commande Add-Member.

$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
    $obj = [psobject]::new()
    $index = 0

    foreach ($column in $columns) {
        $obj | Add-Member -MemberType NoteProperty -Name $column.name -Value $row[$index++]
    }

    $obj
}

À l’aide d’un OrderedDictionary, le code peut être traduit en :

$data = $json | ConvertFrom-Json
$columns = $data.tables.columns
$result = foreach ($row in $data.tables.rows) {
    $obj = [ordered]@{}
    $index = 0

    foreach ($column in $columns) {
        $obj[$column.name] = $row[$index++]
    }

    [pscustomobject] $obj
}

Dans les deux cas, la sortie $result serait identique :

Type        TenantId                             count_
----        --------                             ------
Usage       63613592-b6f7-4c3d-a390-22ba13102111 1
Usage       d436f322-a9f4-4aad-9a7d-271fbf66001c 1
BillingFact 63613592-b6f7-4c3d-a390-22ba13102111 1
BillingFact d436f322-a9f4-4aad-9a7d-271fbf66001c 1
Operation   63613592-b6f7-4c3d-a390-22ba13102111 7
Operation   d436f322-a9f4-4aad-9a7d-271fbf66001c 5

Cette dernière approche devient exponentiellement plus efficace à mesure que le nombre d’objets et de propriétés membres augmente.

Voici une comparaison des performances de trois techniques de création d’objets avec 5 propriétés :

$tests = @{
    '[ordered] into [pscustomobject] cast' = {
        param([int] $iterations, [string[]] $props)

        foreach ($i in 1..$iterations) {
            $obj = [ordered]@{}
            foreach ($prop in $props) {
                $obj[$prop] = $i
            }
            [pscustomobject] $obj
        }
    }
    'Add-Member'                           = {
        param([int] $iterations, [string[]] $props)

        foreach ($i in 1..$iterations) {
            $obj = [psobject]::new()
            foreach ($prop in $props) {
                $obj | Add-Member -MemberType NoteProperty -Name $prop -Value $i
            }
            $obj
        }
    }
    'PSObject.Properties.Add'              = {
        param([int] $iterations, [string[]] $props)

        # this is how, behind the scenes, `Add-Member` attaches
        # new properties to our PSObject.
        # Worth having it here for performance comparison

        foreach ($i in 1..$iterations) {
            $obj = [psobject]::new()
            foreach ($prop in $props) {
                $obj.PSObject.Properties.Add(
                    [psnoteproperty]::new($prop, $i))
            }
            $obj
        }
    }
}

$properties = 'Prop1', 'Prop2', 'Prop3', 'Prop4', 'Prop5'

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

        [pscustomobject]@{
            Iterations        = $_
            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'
        }
    }
}

Et voici les résultats :

Iterations Test                                 TotalMilliseconds RelativeSpeed
---------- ----                                 ----------------- -------------
      1024 [ordered] into [pscustomobject] cast             22.00 1x
      1024 PSObject.Properties.Add                         153.17 6.96x
      1024 Add-Member                                      261.96 11.91x
     10240 [ordered] into [pscustomobject] cast             65.24 1x
     10240 PSObject.Properties.Add                        1293.07 19.82x
     10240 Add-Member                                     2203.03 33.77x
    102400 [ordered] into [pscustomobject] cast            639.83 1x
    102400 PSObject.Properties.Add                       13914.67 21.75x
    102400 Add-Member                                    23496.08 36.72x