Partilhar via


Considerações sobre o desempenho de scripts do PowerShell

Os scripts do PowerShell que aproveitam o .NET diretamente e evitam o pipeline tendem a ser mais rápidos do que o PowerShell idiomático. O PowerShell idiomático usa cmdlets e funções do PowerShell, muitas vezes aproveitando o pipeline e recorrendo ao .NET apenas quando necessário.

Observação

Muitas das técnicas descritas aqui não são idiomáticas do PowerShell e podem reduzir a legibilidade de um script do PowerShell. Os autores de scripts são aconselhados a usar o PowerShell idiomático, a menos que o desempenho determine o contrário.

Supressão de saída

Há muitas maneiras de evitar escrever objetos no pipeline.

  • Atribuição ou redirecionamento de arquivo para $null
  • Casting para [void]
  • Tubo para Out-Null

As velocidades de atribuição a $null, transmissão para [void]e redirecionamento de arquivos para $null são quase idênticas. No entanto, chamar Out-Null em um loop grande pode ser significativamente mais lento, especialmente no 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'
        }
    }
}

Esses testes foram executados em uma máquina Windows 11 no PowerShell 7.3.4. Os resultados são apresentados abaixo:

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

Os tempos e as velocidades relativas podem variar dependendo do hardware, da versão do PowerShell e da carga de trabalho atual no sistema.

Adição de matriz

A geração de uma lista de itens geralmente é feita usando uma matriz com o operador de adição:

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

Observação

No PowerShell 7.5, a adição de matriz foi otimizada e não cria mais uma nova matriz para cada operação. As considerações de desempenho descritas aqui ainda se aplicam às versões do PowerShell anteriores à 7.5. Para obter mais informações, consulte O que há de novo no PowerShell 7.5.

A adição de matrizes é ineficiente porque elas têm um tamanho fixo. Cada adição à matriz cria uma nova matriz grande o suficiente para conter todos os elementos dos operandos esquerdo e direito. Os elementos de ambos os operandos são copiados para a nova matriz. Para pequenas coleções, essa sobrecarga pode não importar. O desempenho pode ser prejudicado por grandes coleções.

Existem algumas alternativas. Se você realmente não precisar de uma matriz, considere usar uma lista genérica digitada ([List<T>]):

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

O impacto no desempenho do uso da adição de matriz cresce exponencialmente com o tamanho da coleção e as adições de número. Esse código compara explicitamente a atribuição de valores a uma matriz com o uso da adição de matriz e o uso do método Add(T) em um objeto [List<T>]. Ele define atribuição explícita como a linha de base para o desempenho.

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

Esses testes foram executados em uma máquina Windows 11 no 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

Quando você está trabalhando com coleções grandes, a adição de array é dramaticamente mais lenta do que a adição a um List<T>.

Ao usar um objeto [List<T>], você precisa criar a lista com um tipo específico, como [string] ou [int]. Quando você adiciona objetos de um tipo diferente à lista, eles são convertidos para o tipo especificado. Se eles não puderem ser convertidos para o tipo especificado, o método gerará uma exceção.

$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 você precisar que a lista seja uma coleção de diferentes tipos de objetos, crie-a com [Object] como o tipo de lista. Você pode enumerar a coleção, inspecionar os tipos dos objetos nela.

$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

Se você precisar de uma matriz, poderá chamar o método ToArray() na lista ou permitir que o PowerShell crie a matriz para você:

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

Neste exemplo, o PowerShell cria um [ArrayList] para armazenar os resultados gravados no pipeline dentro da expressão de matriz. Pouco antes de atribuir ao $results, o PowerShell converte o [ArrayList] em um [Object[]].

Adição de cadeia de caracteres

As cadeias de caracteres são imutáveis. Cada adição à cadeia de caracteres realmente cria uma nova cadeia de caracteres grande o suficiente para conter o conteúdo dos operandos esquerdo e direito e, em seguida, copia os elementos de ambos os operandos para a nova cadeia de caracteres. Para cordas pequenas, essa sobrecarga pode não importar. Para cadeias de caracteres grandes, isso pode afetar o desempenho e o consumo de memória.

Existem pelo menos duas alternativas:

  • O operador -join concatena cadeias de caracteres
  • A classe .NET [StringBuilder] fornece uma cadeia de caracteres mutável

O exemplo a seguir compara o desempenho desses três métodos de criação de uma cadeia de caracteres.

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

Esses testes foram executados em uma máquina Windows 11 no PowerShell 7.4.2. A saída mostra que o operador -join é o mais rápido, seguido pela 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

Os tempos e as velocidades relativas podem variar dependendo do hardware, da versão do PowerShell e da carga de trabalho atual no sistema.

Processamento de ficheiros grandes

A maneira idiomática de processar um arquivo no PowerShell pode ser algo como:

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

Isso pode ser uma ordem de grandeza mais lenta do que usar APIs do .NET diretamente. Por exemplo, você pode usar o .NET [StreamReader] classe:

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

Você também pode usar o método ReadLines de [System.IO.File], que envolve StreamReader, simplifica o processo de leitura:

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

Procurar entradas por propriedade em grandes coleções

É comum precisar usar uma propriedade compartilhada para identificar o mesmo registro em coleções diferentes, como usar um nome para recuperar um ID de uma lista e um e-mail de outra. A iteração sobre a primeira lista para encontrar o registro correspondente na segunda coleção é lenta. Em particular, a filtragem repetida da segunda coleção tem uma grande sobrecarga.

Dadas duas coleções, uma com um Id e Name, a outra com Name e Email:

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

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

A maneira usual de reconciliar essas coleções para retornar uma lista de objetos com as propriedades Id, Name e Email pode ter esta aparência:

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

No entanto, essa implementação tem que filtrar todos os 5000 itens na coleção $Accounts uma vez para cada item na coleção $Employee. Isso pode levar minutos, mesmo para essa pesquisa de valor único.

Em vez disso, você pode criar um de Tabela de Hash que usa a propriedade Nome do compartilhado como uma chave e a conta correspondente como o valor.

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

Procurar chaves em uma tabela de hash é muito mais rápido do que filtrar uma coleção por valores de propriedade. Em vez de verificar todos os itens da coleção, o PowerShell pode verificar se a chave está definida e usar seu valor.

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

Isso é muito mais rápido. Enquanto o filtro de looping levou minutos para ser concluído, a pesquisa de hash leva menos de um segundo.

Use Write-Host com cuidado

O comando Write-Host só deve ser usado quando você precisar escrever texto formatado no console do host, em vez de escrever objetos no pipeline Success.

Write-Host pode ser uma ordem de grandeza mais lenta do que [Console]::WriteLine() para hosts específicos, como pwsh.exe, powershell.exeou powershell_ise.exe. No entanto, não é garantido que [Console]::WriteLine() funcione em todos os anfitriões. Além disso, a saída escrita usando [Console]::WriteLine() não é gravada em transcrições iniciadas por Start-Transcript.

compilação JIT

O PowerShell compila o código de script para bytecode que é interpretado. A partir do PowerShell 3, para código executado repetidamente em um loop, o PowerShell pode melhorar o desempenho compilando o código em código nativo Just-in-time (JIT).

Loops com menos de 300 instruções são elegíveis para compilação JIT. Loops maiores do que isso são muito caros para compilar. Quando o loop é executado 16 vezes, o script é compilado em JIT em segundo plano. Quando a compilação JIT é concluída, a execução é transferida para o código compilado.

Evite chamadas repetidas para uma função

Chamar uma função pode ser uma operação cara. Se você estiver chamando uma função em um loop apertado de longa duração, considere mover o loop dentro da função.

Considere os seguintes exemplos:

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

O exemplo de for-loop Basic é a linha de base para o desempenho. O segundo exemplo envolve o gerador de números aleatórios em uma função que é chamada em um loop apertado. O terceiro exemplo move o loop dentro da função. A função é chamada apenas uma vez, mas o código ainda gera a mesma quantidade de números aleatórios. Observe a diferença nos tempos de execução para cada exemplo.

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

Evite encapsular pipelines de cmdlet

A maioria dos cmdlets é implementada para o pipeline, que é uma sintaxe e um processo sequenciais. Por exemplo:

cmdlet1 | cmdlet2 | cmdlet3

Inicializar um novo pipeline pode ser caro, portanto, você deve evitar envolver um pipeline de cmdlet em outro pipeline existente.

Considere o exemplo a seguir. O ficheiro Input.csv contém 2100 linhas. O comando Export-Csv é encapsulado dentro do pipeline de ForEach-Object. O cmdlet Export-Csv é invocado para cada iteração do loop de 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

Para o próximo exemplo, o comando Export-Csv foi movido para fora do pipeline de ForEach-Object. Nesse caso, Export-Csv é invocado apenas uma vez, mas ainda processa todos os objetos passados 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

O exemplo desembrulhado é 372 vezes mais rápido. Além disso, observe que a primeira implementação requer o parâmetro Append, que não é necessário para a implementação posterior.

Evitar listagem desnecessária de coleções

Os operadores de comparação do PowerShell têm um recurso de conviência ao comparar coleções. Quando o valor do lado esquerdo na expressão é uma coleção, o operador retorna os elementos da coleção que correspondem ao valor do lado direito da expressão.

Esse recurso fornece uma maneira simples de filtrar uma coleção. Por exemplo:

PS> $Collection = 1..99
PS> ($Collection -like '*1*') -join ' '

1 10 11 12 13 14 15 16 17 18 19 21 31 41 51 61 71 81 91

No entanto, quando você usa uma comparação de coleção em uma instrução condicional que espera apenas um resultado booleano , esse recurso pode resultar em desempenho insatisfatório.

Tomemos como exemplo:

if ($Collection -like '*1*') { 'Found' }

Neste exemplo, o PowerShell compara o valor do lado direito com cada valor na coleção e retorna uma coleção de resultados. Como o resultado não está vazio, o resultado não nulo é avaliado como $true. A condição é verdadeira quando a primeira correspondência é identificada, mas o PowerShell ainda enumera toda a coleção. Essa enumeração pode ter um impacto significativo no desempenho de grandes coleções.

Uma maneira de melhorar o desempenho é usar o método Where() da coleção. O Where() método para de avaliar a coleção depois de encontrar a primeira correspondência.

# Create an array of 1048576 items
$Collection = foreach ($x in 1..1MB) { $x }
(Measure-Command { if ($Collection -like '*1*') { 'Found' } }).TotalMilliseconds
633.3695
(Measure-Command { if ($Collection.Where({ $_ -like '*1*' }, 'first')) { 'Found' } }).TotalMilliseconds
2.607

Para um milhão de itens, usar o Where() método é significativamente mais rápido.

Criação de objetos

A criação de objetos usando o cmdlet New-Object pode ser lenta. O código a seguir compara o desempenho da criação de objetos usando o cmdlet New-Object com o acelerador de tipo [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

O PowerShell 5.0 adicionou o método estático new() para todos os tipos .NET. O código a seguir compara o desempenho da criação de objetos usando o cmdlet New-Object com o método 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

Use OrderedDictionary para criar novos objetos dinamicamente

Há situações em que talvez precisemos criar dinamicamente objetos com base em alguma entrada, a maneira talvez mais comumente usada para criar um novo PSObject e, em seguida, adicionar novas propriedades usando o cmdlet Add-Member. O custo de desempenho para pequenas coleções usando esta técnica pode ser insignificante, no entanto, pode se tornar muito percetível para grandes coleções. Nesse caso, a abordagem recomendada é usar um [OrderedDictionary] e, em seguida, convertê-lo em um PSObject usando o acelerador de tipo [pscustomobject]. Para obter mais informações, consulte a seção Criando dicionários ordenados do about_Hash_Tables.

Suponha que você tenha a seguinte resposta da API armazenada na variável $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" ]
      ]
    }
  ]
}

Agora, suponha que você queira exportar esses dados para um CSV. Primeiro, você precisa criar novos objetos e adicionar as propriedades e os valores usando o cmdlet 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
}

Usando um OrderedDictionary, o código pode ser traduzido para:

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

Em ambos os casos, a saída $result seria a mesma:

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

Esta última abordagem torna-se exponencialmente mais eficiente à medida que o número de objetos e propriedades de membros aumenta.

Aqui está uma comparação de desempenho de três técnicas para criar objetos com 5 propriedades:

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

E estes são os resultados:

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