Compartilhar via


Considerações de 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 PowerShell idiomático. O PowerShell idioma usa cmdlets e funções do PowerShell, muitas vezes aproveitando o pipeline e recorrendo ao .NET somente quando necessário.

Nota

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 script são aconselhados a usar o PowerShell idioma, a menos que o desempenho determine o contrário.

Suprimindo a saída

Há muitas maneiras de evitar a gravação de objetos no pipeline.

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

As velocidades de atribuição a $null, conversão em [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 no 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 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 coleções pequenas, essa sobrecarga pode não importar. O desempenho pode sofrer com grandes coleções.

Há algumas alternativas. Se você realmente não precisar de uma matriz, considere usar uma lista genérica tipada ([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 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 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 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 grandes coleções, a adição de matriz é dramaticamente mais lenta do que adicionar 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 no 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 o 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 manter os resultados gravados no pipeline dentro da expressão de matriz. Pouco antes de atribuir a $results, o PowerShell converte o [ArrayList] em um [Object[]].

Adição de cadeia de caracteres

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

Há pelo menos duas alternativas:

  • O operador -join concatena cadeias de caracteres
  • A classe de [StringBuilder] do .NET 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 um computador 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.

Processando arquivos grandes

A maneira idiomática de processar um arquivo no PowerShell pode ser semelhante a:

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

Essa pode ser uma ordem de magnitude mais lenta do que usar as APIs do .NET diretamente. Por exemplo, você pode usar a classe de [StreamReader] do .NET:

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 encapsula StreamReader, simplifica o processo de leitura:

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

Pesquisando 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 uma ID de uma lista e um email de outra. A iteração na primeira lista para localizar o registro correspondente na segunda coleção é lenta. Em particular, a filtragem repetida da segunda coleção tem uma grande sobrecarga.

Considerando duas coleções, uma com uma ID e Name, 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, Namee 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 precisa filtrar todos os 5.000 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 fazer um de Tabela de Hash que usa a propriedade nome de compartilhada 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 cada item 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 loop levou minutos para ser concluído, a pesquisa de hash leva menos de um segundo.

Use Write-Host cuidadosamente

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 Success.

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

Compilação JIT

O PowerShell compila o código de script para bytecode interpretado. A partir do PowerShell 3, para o código executado repetidamente em um loop, o PowerShell pode melhorar o desempenho compilando o código em código nativo.

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

Evitar 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 execução longa, 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 básico do for-loop é a linha base para desempenho. O segundo exemplo encapsula o gerador de número aleatório 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

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

A inicialização de um novo pipeline pode ser cara, 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 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 Acrescentar, que não é necessário para a implementação posterior.

Criação de objeto

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 de .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

Usar OrderedDictionary para criar dinamicamente novos objetos

Há situações em que talvez seja necessário criar objetos dinamicamente com base em alguma entrada, a maneira talvez mais 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 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 novos objetos e adicionar as propriedades e 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