Поделиться через


Рекомендации по производительности сценариев PowerShell

Скрипты PowerShell, использующие .NET напрямую и избегающие конвейера, как правило, быстрее, чем идиоматическая PowerShell. Idiomatic PowerShell использует командлеты и функции PowerShell, часто используя конвейер и прибегая к .NET только при необходимости.

Заметка

Многие описанные здесь методы не идиоматичны PowerShell и могут снизить удобочитаемость скрипта PowerShell. Авторы скриптов рекомендуется использовать идиоматический PowerShell, если производительность не диктует в противном случае.

Подавление выходных данных

Существует множество способов избежать записи объектов в конвейер.

  • Назначение или перенаправление файлов в $null
  • Приведение к [void]
  • Канал в Out-Null

Скорость назначения $null, приведение к [void]и перенаправление файлов в $null почти идентичны. Однако вызов Out-Null в большом цикле может быть значительно медленнее, особенно в 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'
        }
    }
}

Эти тесты были запущены на компьютере с Windows 11 в PowerShell 7.3.4. Результаты показаны ниже.

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

Время и относительные скорости могут отличаться в зависимости от оборудования, версии PowerShell и текущей рабочей нагрузки в системе.

Добавление массива

Создание списка элементов часто выполняется с помощью массива с оператором сложения:

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

Добавление массива неэффективно, так как массивы имеют фиксированный размер. Каждое дополнение к массиву создает достаточно большой массив для хранения всех элементов как левых, так и правых операндов. Элементы обоих операндов копируются в новый массив. Для небольших коллекций это может не иметь значения. Производительность может страдать от больших коллекций.

Существует несколько альтернатив. Если на самом деле не требуется массив, рекомендуется использовать типизированный универсальный список ([List<T>]):

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

Влияние на производительность добавления массива увеличивается экспоненциально с размером коллекции и числами. Этот код сравнивает явное назначение значений массиву с использованием добавления массива и использования метода Add(T) в объекте [List<T>]. Он определяет явное назначение в качестве базового показателя производительности.

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

Эти тесты были запущены на компьютере с Windows 11 в 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

При работе с большими коллекциями добавление массива значительно медленнее, чем добавление в List<T>.

При использовании объекта [List<T>] необходимо создать список с определенным типом, например [String] или [Int]. При добавлении объектов другого типа в список они приведение к указанному типу. Если они не могут быть приведение к указанному типу, метод вызывает исключение.

$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

Если вам нужно, чтобы список был коллекцией различных типов объектов, создайте его с [Object] в качестве типа списка. Вы можете перечислить коллекцию, чтобы проверить типы объектов в нем.

$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

Если требуется массив, можно вызвать метод ToArray() в списке или позволить PowerShell создать этот массив:

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

В этом примере PowerShell создает [ArrayList] для хранения результатов, записанных в конвейер внутри выражения массива. Перед назначением $resultsPowerShell преобразует [ArrayList] в [Object[]].

Добавление строки

Строки неизменяемы. Каждое дополнение к строке фактически создает новую строку достаточно большой для хранения содержимого левых и правых операндов, а затем копирует элементы обоих операндов в новую строку. Для небольших строк это может не иметь значения. Для больших строк это может повлиять на производительность и потребление памяти.

Существует по крайней мере два варианта:

  • Оператор -join объединяет строки
  • Класс [StringBuilder] .NET предоставляет изменяемую строку

В следующем примере сравнивается производительность этих трех методов построения строки.

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

Эти тесты были запущены на компьютере с Windows 11 в PowerShell 7.4.2. В выходных данных показано, что оператор -join является самым быстрым, за которым следует класс [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

Время и относительные скорости могут отличаться в зависимости от оборудования, версии PowerShell и текущей рабочей нагрузки в системе.

Обработка больших файлов

Идиоматический способ обработки файла в PowerShell может выглядеть примерно так:

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

Это может быть более медленным порядком, чем использование API .NET напрямую. Например, можно использовать класс .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()
    }
}

Вы также можете использовать метод ReadLines[System.IO.File], который упаковывает StreamReader, упрощает процесс чтения:

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

Поиск записей по свойству в больших коллекциях

Обычно необходимо использовать общее свойство для идентификации одной записи в разных коллекциях, например с помощью имени для получения идентификатора из одного списка и электронной почты из другого. Итерации по первому списку, чтобы найти соответствующую запись во второй коллекции, медленно. В частности, повторная фильтрация второй коллекции имеет большие затраты.

Учитывая две коллекции, одна из них с идентификатором и Name, другая с Name и Email:

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

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

Обычный способ примирить эти коллекции для возврата списка объектов с идентификатором , nameи свойствами электронной почты могут выглядеть следующим образом:

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

Однако эта реализация должна фильтровать все 5000 элементов в коллекции $Accounts один раз для каждого элемента в коллекции $Employee. Это может занять несколько минут, даже для поиска с одним значением.

Вместо этого можно сделать хэш-таблицу , которая использует общее свойство Name в качестве ключа и соответствующей учетной записи в качестве значения.

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

Поиск ключей в хэш-таблице гораздо быстрее, чем фильтрация коллекции по значениям свойств. Вместо проверки каждого элемента в коллекции PowerShell может проверить, определен ли ключ и использовать его значение.

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

Это гораздо быстрее. Пока фильтр циклов занимает несколько минут, хэш-поиск занимает менее секунды.

Тщательно используйте Write-Host

Команда Write-Host должна использоваться только в том случае, если необходимо написать форматированный текст в консоль узла, а не записывать объекты в конвейер Success.

Write-Host может быть порядком медленнее, чем [Console]::WriteLine() для определенных узлов, таких как pwsh.exe, powershell.exeили powershell_ise.exe. Однако [Console]::WriteLine() не гарантируется работать во всех узлах. Кроме того, выходные данные, написанные с помощью [Console]::WriteLine(), не записываются в расшифровки, запущенные Start-Transcript.

Компиляция JIT

PowerShell компилирует код скрипта в байт-код, который интерпретируется. Начиная с PowerShell 3, для кода, который многократно выполняется в цикле, PowerShell может повысить производительность, скомпилируя код в машинный код.

Циклы, имеющие менее 300 инструкций, могут быть доступны для JIT-компиляции. Циклы больше, чем это слишком затратно для компиляции. При выполнении цикла 16 раз скрипт компилируется в фоновом режиме. После завершения компиляции JIT выполнение передается в скомпилированный код.

Избегайте повторных вызовов функции

Вызов функции может быть дорогой операцией. Если вы вызываете функцию в длительном жестком цикле, рассмотрите возможность перемещения цикла внутри функции.

Рассмотрим следующие примеры:

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

Пример "Базовый для цикла" является базовой строкой для производительности. Второй пример упаковывает генератор случайных чисел в функцию, которая вызывается в жестком цикле. Третий пример перемещает цикл внутри функции. Функция вызывается только один раз, но код по-прежнему создает то же количество случайных чисел. Обратите внимание на разницу в времени выполнения для каждого примера.

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

Избегайте упаковки конвейеров командлетов

Большинство командлетов реализованы для конвейера, который является последовательным синтаксисом и процессом. Например:

cmdlet1 | cmdlet2 | cmdlet3

Инициализация нового конвейера может быть дорогой, поэтому следует избегать упаковки конвейера командлета в другой существующий конвейер.

Рассмотрим следующий пример. Файл Input.csv содержит 2100 строк. Команда Export-Csv упаковывается в конвейер ForEach-Object. Командлет Export-Csv вызывается для каждой итерации цикла 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

В следующем примере команда Export-Csv была перемещена за пределы конвейера ForEach-Object. В этом случае Export-Csv вызывается только один раз, но по-прежнему обрабатывает все объекты, передаваемые из 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

Пример распакученного кода 372 раза быстрее. Кроме того, обратите внимание, что для первой реализации требуется параметр добавления , который не требуется для последующей реализации.

Создание объекта

Создание объектов с помощью командлета New-Object может быть медленным. Следующий код сравнивает производительность создания объектов с помощью командлета New-Object с акселератором типа [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 добавил статический метод new() для всех типов .NET. Следующий код сравнивает производительность создания объектов с помощью командлета New-Object с методом 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

Использование OrderedDictionary для динамического создания новых объектов

Существуют ситуации, когда может потребоваться динамически создавать объекты на основе некоторых входных данных, возможно, наиболее часто используемый способ создания нового PSObject, а затем добавить новые свойства с помощью командлета Add-Member. Затраты на производительность небольших коллекций, использующих этот метод, могут быть незначительными, однако это может стать очень заметным для больших коллекций. В этом случае рекомендуется использовать [OrderedDictionary], а затем преобразовать его в PSObject с помощью акселератора типов [pscustomobject]. Дополнительные сведения см. в разделе Создание упорядоченных словарей раздела about_Hash_Tables.

Предположим, что у вас есть следующий ответ API, хранящийся в переменной $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" ]
      ]
    }
  ]
}

Предположим, что вы хотите экспортировать эти данные в CSV-файл. Сначала необходимо создать новые объекты и добавить свойства и значения с помощью командлета 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
}

С помощью OrderedDictionaryкод можно преобразовать в:

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

В обоих случаях выходные данные $result будут одинаковыми:

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

Последний подход становится экспоненциально более эффективным по мере увеличения числа объектов и свойств элементов.

Ниже приведено сравнение производительности трех методов создания объектов с 5 свойствами:

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

И это результаты:

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