Share via


PowerShell 腳本效能考慮

直接運用 .NET 的 PowerShell 腳本,並避免管線的速度往往比慣用的 PowerShell 快。 慣用 PowerShell 使用 Cmdlet 和 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'
        }
    }
}

這些測試是在PowerShell 7.3.4的 Windows 11 電腦上執行。 結果如下所示:

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

這些測試是在PowerShell 7.3.4的 Windows 11 電腦上執行。

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] 建立 ,以保存寫入數位運算式內管線的結果。 在指派給 $results之前,PowerShell 會將 [ArrayList][Object[]]轉換成 。

字串新增

字串是不可變的。 每個新增字串實際上都會建立足以容納左右操作數內容的新字串,然後將這兩個操作數的元素複製到新的字串中。 對於小型字串,此額外負荷可能無關緊要。 對於大型字串,這可能會影響效能和記憶體耗用量。

至少有兩個替代方案:

  • 運算串-join連字串串
  • .NET [StringBuilder] 類別提供可變字串

下列範例會比較這三種方法建置字串的效能。

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

這些測試是在PowerShell 7.4.2的 Windows 11 電腦上執行。 輸出顯示 -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()
    }
}

您也可以使用 ReadLines 的 方法來 [System.IO.File]包裝 StreamReader,以簡化讀取程式:

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

依大型集合中的 屬性查閱專案

通常需要使用共用屬性來識別不同集合中的相同記錄,例如使用名稱從一個清單擷取標識符,並從另一個清單中擷取電子郵件。 逐一查看第一個清單,以尋找第二個集合中的相符記錄速度很慢。 特別是,第二個集合的重複篩選具有很大的額外負荷。

假設有兩個集合,一個具有標識符和名稱,另一個則具有名稱和電子郵件

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

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

調和這些集合以傳回識別碼名稱和電子郵件屬性的物件清單的一般方式可能如下所示:

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

不過,該實作必須針對集合中的每個項目篩選一次集合中的所有 $Accounts 5000 個專案 $Employee 。 這可能需要幾分鐘的時間,即使是針對這個單一值查閱。

相反地,您可以建立 哈希表,該哈希表 使用共用 名稱 屬性做為索引鍵,並將相符的帳戶當做值。

$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可以是比、 或 powershell_ise.exepwsh.exepowershell.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 範例是效能的基底線。 第二個範例會將隨機數產生器包裝在緊密迴圈中呼叫的函式中。 第三個範例會在函式內移動迴圈。 函式只會呼叫一次,但程式代碼仍會產生相同數量的隨機數。 請注意每個範例的執行時間差異。

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

避免包裝 Cmdlet 管線

大部分的 Cmdlet 都是針對管線實作,這是循序語法和程式。 例如:

cmdlet1 | cmdlet2 | cmdlet3

初始化新的管線可能很昂貴,因此您應該避免將 Cmdlet 管線包裝到另一個現有的管線。

請思考一下下列範例。 檔案 Input.csv 包含 2100 行。 Export-Csv命令會包裝在管線內ForEach-Object。 會 Export-Csv 針對迴圈的每個反覆專案 ForEach-Object 叫用 Cmdlet。

$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 倍。 此外,請注意,第一個實作需要 Append 參數,這在稍後的實作並非必要。

使用 OrderedDictionary 動態建立新的物件

在某些情況下,我們可能需要根據某些輸入動態建立物件,這或許最常用來建立新的 PSObject ,然後使用 Cmdlet 新增屬性 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。 首先,您需要建立新的物件,並使用 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
}

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