Considerações sobre o desempenho de script do PowerShell

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

Observação

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

Suprimir saída

Há várias maneiras de se evitar a gravação de objetos no pipeline.

  • Atribuição ou redirecionamento de arquivo para $null
  • Como converter para [void]
  • Pipe para Out-Null

As velocidades de atribuição para $null, conversão para[void]e redirecionamento de arquivo 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 um computador Windows 11 com o PowerShell 7.3.4. Os resultados são mostrados 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

A adição de matriz é ineficiente porque as matrizes têm um tamanho fixo. Cada adição à matriz cria uma 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 ser importante. O desempenho pode sofrer para grandes coleções.

Há duas alternativas. Se você realmente não precisa 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 aumenta exponencialmente com o tamanho da coleção e as adições numéricas. Esse código compara a atribuição explícita 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 a 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 um computador Windows 11 com o 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ê trabalha com coleções grandes, a adição de matriz é 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 no 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 tipo de lista. Você pode enumerar a coleção inspecionando 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 uma [ArrayList] para armazenar os resultados gravados no pipeline dentro da expressão de matriz. Justo antes de atribuir a $results, o PowerShell converte [ArrayList] em [Object[]].

Adição de cadeia de caracteres

As cadeias de caracteres são imutáveis. Cada adição à cadeia de caracteres, na verdade, cria uma cadeia de caracteres grande o suficiente para conter todos os elementos dos operandos esquerdo e direito e copia os elementos dos dois operandos para a nova cadeia de caracteres. Para cadeias de caracteres pequenas, essa sobrecarga pode não ser importante. Para cadeias de caracteres grandes, isso pode afetar o desempenho e o consumo de memória.

Há pelo menos duas alternativas:

  • O -join operador concatena cadeia 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.

Processar arquivos grandes

A maneira idiomática de processar um arquivo no PowerShell pode se assemelhar a:

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

Isso pode ser muito mais lento do que usar APIs .NET diretamente. Por exemplo, você pode usar a 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()
    }
}

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

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

Pesquisar entradas por propriedade em coleções grandes

É comum precisar usar uma propriedade compartilhada para identificar o mesmo registro em coleções diferentes, como usar um nome para recuperar uma ID de uma lista e um email de outra. É um processo lento iterar na primeira lista para localizar o registro correspondente na segunda coleção. Especificamente, a filtragem repetida da segunda coleção tem uma grande sobrecarga.

Dadas duas coleções, uma com ID e Nome e outra com Nome 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, Nome e Email pode ser assim:

$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 precisa filtrar todos os 5.000 itens na coleção $Accounts uma vez para cada item da coleção $Employee. Isso pode levar minutos, mesmo nessa pesquisa de valor único.

Em vez disso, você pode criar uma Tabela Hash que usa a propriedade Nome compartilhada como chave e a conta correspondente como valor.

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

A procura de chaves em uma tabela de hash é muito mais rápida do que a filtragem de 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. Embora o filtro de loop leve 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 gravar texto formatado no console do host, em vez de gravar objetos no pipeline Sucesso.

Write-Host pode ser uma ordem de magnitude mais lenta que [Console]::WriteLine() para hosts específicos comopwsh.exe, powershell.exe, ou powershell_ise.exe. Entretanto, não há garantia de que [Console]::WriteLine() funcione em todos os hosts. Além disso, a saída por escrito usando [Console]::WriteLine() não é escrita em transcrições iniciadas por Start-Transcript.

compilação JIT

O PowerShell compila o código de script para o código de bytes que é interpretado. A partir do PowerShell 3, para o código que é executado repetidamente em um loop, o PowerShell pode melhorar o desempenho ao realizar a compilação JIT (just-in-time) do código para código nativo.

Loops que têm menos de 300 instruções estão qualificados para compilação JIT. Loops maiores que isso são muito dispendiosos para serem compilados. Quando o loop for executado 16 vezes, o script será compilado em JIT em segundo plano. Quando a compilação JIT for concluída, a execução será transferida para o código compilado.

Evitar chamadas repetidas a 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 execução prolongada, 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 básico de loop é a linha de base para desempenho. O segundo exemplo encapsula o gerador de números aleatórios em uma função que é chamada em um loop rígido. 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 de 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

Evitar encapsular pipelines de cmdlet

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

cmdlet1 | cmdlet2 | cmdlet3

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

Considere o exemplo a seguir. O arquivo Input.csv contém 2100 linhas. O comando Export-Csv é encapsulado dentro do pipeline ForEach-Object. O cmdlet Export-Csv é invocado para cada iteração do loop 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 exemplo a seguir, o comando Export-Csv foi movido para fora do pipeline 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 Acrescentar, que não é necessário para a implementação posterior.

Usar OrderedDictionary para criar objetos dinamicamente

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

Suponha que você tenha a seguinte resposta de 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 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 membro aumenta.

Aqui está uma comparação de desempenho de três técnicas para criar objetos com cinco 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