次の方法で共有


PowerShell スクリプトのパフォーマンスに関する考慮事項

PowerShell スクリプトは、.NET を直接利用してパイプラインを回避する方が、慣例的な PowerShell より高速になる傾向があります。 慣例的な PowerShell では、コマンドレットと PowerShell 関数を使用し、多くの場合パイプラインを活用し、.NET を使用するのは必要なときだけです。

Note

ここで説明する手法の多くは、慣例的な 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

配列のサイズは固定であるため、配列の加算は非効率的です。 配列に追加するたびに、左右両方のオペランドのすべての要素を保持するのに十分な大きさの新しい配列が作成されます。 両方のオペランドの要素は新しい配列にコピーされます。 小さいコレクションなら、このオーバーヘッドは問題にならない可能性があります。 大規模なコレクションでは、パフォーマンスが低下するおそれがあります。

代わりの方法が 2 つあります。 実際には配列が必要ではない場合は、代わりに型指定されたジェネリック リスト ([List<T>]) の使用を検討してください。

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

配列の加算を使う場合のパフォーマンスへの影響は、コレクションのサイズと加算の数と共に指数関数的に大きくなります。 このコードは、配列への値の明示的な代入に、配列の加算を使う場合と、[List<T>] オブジェクトの Add(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
)

この例では、配列式内のパイプラインに書き込まれる結果を保持するための [ArrayList] を PowerShell で作成します。 $results に割り当てる直前に、PowerShell で [ArrayList][Object[]] に変換します。

文字列の追加

文字列は変更不可です。 文字列に追加するたびに、実際には、左右両方のオペランドのすべての要素を保持するのに十分な大きさの新しい文字列が作成された後、両方のオペランドの要素が新しい文字列にコピーされます。 小さい文字列であれば、このオーバーヘッドは問題にならないかもしれません。 大きな文字列の場合、これはパフォーマンスとメモリの消費量に影響を与えるおそれがあります。

少なくとも 2 つの代替手段があります。

  • -join 演算子は、文字列を連結します
  • .NET の [StringBuilder] クラスは変更可能な文字列を提供します

次の例では、文字列を構築するこれら 3 つの方法のパフォーマンスを比較します。

$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 10 コンピューター上の 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

これは、.NET API を直接使用するより、桁違いに遅くなる可能性があります。 たとえば、.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()
    }
}

また、StreamReader をラップする [System.IO.File]ReadLines メソッドを使用して、読み取りプロセスを簡略化することもできます。

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

大きなコレクションでのプロパティによるエントリの検索

異なるコレクション内の同じレコードを識別するため、共通するプロパティの使用が必要になることがよくあります (名前を使ってあるリストから ID を取得し、別のリストからメールを取得する場合など)。 最初のリストを反復処理して、2 番目のコレクション内の一致するレコードを見つけるのでは時間がかかります。 特に、2 番目のコレクションのフィルター処理を繰り返すと、オーバーヘッドが大きくなります。

2 つのコレクションがあるとします。1 つは IDName、もう 1 つは NameEmail です。

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

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

これらのコレクションを調整して、IDNameEmail プロパティを含むオブジェクトのリストを返す一般的な方法は、次のようになります。

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

ただし、その実装では、$Employee コレクション内の項目ごとに 1 回、$Accounts コレクション内の 5000 項目すべてをフィルター処理する必要があります。 この 1 つの値の参照でさえ、何分もかかる場合があります。

代わりに、共通の 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
    }
}

これははるかに高速です。 ループ フィルターは完了するまでに数分かかりましたが、ハッシュ参照に要する時間は 1 秒未満です。

書き込みホストを慎重に使用する

Write-Host コマンドは、Success パイプラインにオブジェクトを書き込む場合ではなく、ホスト コンソールに書式設定されたテキストを書き込む必要がある場合のみで使用してください。

Write-Host は、pwsh.exepowershell.exepowershell_ise.exe のような特定のホストに対しては、[Console]::WriteLine() より桁違いに遅くなることがあります。 ただし、[Console]::WriteLine() がすべてのホストで動作することは保証されません。 また、[Console]::WriteLine() を使って書き込まれた出力は、Start-Transcript によって開始されたトランスクリプトには書き込まれません。

JIT コンパイル

スクリプト コードは PowerShell によって解釈されてバイト コードにコンパイルされます。 PowerShell 3 以降では、ループで繰り返し実行されるコードの場合、PowerShell でコードをネイティブ コードに Just-In-Time (JIT) コンパイルすることで、パフォーマンスを向上させることができます。

命令数が 300 未満のループは、JIT コンパイルに適しています。 それより大きいループは、コンパイルにかかるコストが高すぎます。 ループが 16 回実行されると、スクリプトはバックグラウンドで JIT コンパイルされます。 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'
        }
    }
}

Basic for-loop の例は、パフォーマンスのベースラインです。 2 番目の例では、緊密なループ内で呼び出される関数に、乱数ジェネレーターがラップされています。 3 番目の例では、ループが関数の内部に移動されています。 関数は 1 回呼び出されるだけですが、それでもコードにより同じ量の乱数が生成されます。 各例の実行時間の違いに注目してください。

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

新しいパイプラインの初期化はコストが高い可能性があるため、コマンドレット パイプラインを別の既存のパイプラインにラップしないようにする必要があります。

各データ メンバー フィールドが JSON オブジェクトにマップされ、フィールド名がオブジェクトの "key" 部分にマップされ、"value" 部分がオブジェクトの値の部分に再帰的にマップされます。 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 は 1 回だけ呼び出されますが、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 倍高速です。 また、最初の実装には Append パラメーターが必要であることがわかります。これは、後の実装では必要ありません。

OrderedDictionary を使用して新しいオブジェクトを動的に作成する

一部の入力に基づいてオブジェクトを動的に作成する必要がある場合があります。おそらく最もよく使用される方法は、新しい PSObject を作成した後、Add-Member コマンドレットを使用して新しいプロパティを追加する方法です。 この手法を使用する小規模なコレクションではパフォーマンス コストはごくわずかですが、大規模なコレクションでは非常に顕著になる場合があります。 その場合に推奨されるアプローチは、[OrderedDictionary] を使用し、[pscustomobject] 型アクセラレータを使用してそれを PSObject に変換することです。 詳細については、「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 つのプロパティを持つオブジェクトを作成するための 3 つの手法のパフォーマンス比較を次に示します。

$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