PowerShell 腳本太慢時該檢查的地方 ── 陣列・管線・比對的訣竅

· · PowerShell, Windows, 效能改善, 自動化, 腳本, 維運改善, 效能調校, 資料處理

「原本幾分鐘就能跑完的彙總腳本,隨著資料增加,變成要花 3 小時」── 這是 PowerShell 自動化已經普及的現場常聽到的話。而且多數情況下,變慢的原因並不在 PowerShell 本身的執行速度,而在寫法

PowerShell 是以易寫性為優先的語言,因此有不少「照直覺寫出來,結果運算量卻惡化」的寫法。最具代表性的就是 $array += $item。看起來很自然,但內部每次都會複製整個陣列,筆數一多就會急遽變慢。是否知道這些常見的陷阱,決定了同樣的處理會花上數小時、還是幾十秒就結束。

本文將依影響大小排序,整理看到腳本變慢時該優先懷疑的地方。同時也會談到正確的測量方式,避免憑感覺改寫程式碼。

適用環境同時包含 Windows PowerShell 5.1 與 PowerShell 7。兩者行為有差異的地方(例如 Get-Content 的預設字元編碼),會在文中逐一說明。文中的範例程式碼,都已在 PowerShell 7.6 下實際執行驗證過。

1. 先講結論

  • 請不要使用 $array += $itemPowerShell 的陣列是固定長度,+= 每次都會建立新陣列並複製所有元素,運算量會與筆數的平方成正比。1
  • 改用 List[T],或是把整個迴圈的輸出直接交給變數接收。後者是很 PowerShell 風格、而且速度快的寫法。
  • 比對・搜尋,雜湊表化最有效。雙重迴圈的線性搜尋(O(n×m)),可以變成鍵值查找(大約 O(n+m))。2
  • 管線很方便,但每個物件都有成本。在處理大量筆數的內層迴圈中,foreach 陳述式往往更快,值得實際測量看看。3
  • 依用途分開使用檔案讀取方式。Get-Content -Raw 一次讀入、用 -ReadCount 分批讀入。有時逐行物件化這件事本身就是瓶頸。4
  • Get-ChildItem 請搭配 -Filter 使用。官方文件也明確指出,因為是由提供者端進行篩選,比取得後再用 Where-Object 捨棄更有效率。5
  • 字串串接請用 -joinStringBuilder$s += "..." 和陣列一樣,會因為同樣的原因而變慢。6
  • Format-* 只能放在畫面顯示的最後。中途插入不僅會破壞後續處理,還會產生無謂的格式化成本。7
  • 先測量,再動手改。Measure-Command 會捨棄輸出,因此不含顯示成本。第一次執行的數值不能用。8
  • 並行化排在最後。請先把演算法修正好,再考慮並行化(「PowerShell 的並行處理」)。

2. 先測量 ── Measure-Command 的正確用法

靠猜測來調校,大多會修到沒效果的地方。第一步該做的是測量。8

先說明一點。本文程式碼範例中出現的 Invoke-KsAggregateConvertTo-KsRecordJoin-KsRecord,都是讓讀者對應到自己實際處理的假想函式名稱。它們不是 PowerShell 的標準 Cmdlet,因此原封不動貼上執行會停在「無法辨識詞彙」的錯誤上。請將它們替換成自己腳本中的函式名稱與實際處理來閱讀。

# 第 1 次會包含模組載入與 JIT,所以捨棄不用
$null = Measure-Command { Invoke-KsAggregate }

# 第 2 次以後測量數次,也順便看看有多少落差
1..3 | ForEach-Object {
    (Measure-Command { Invoke-KsAggregate }).TotalSeconds
}

Measure-Command 有兩個注意點。

  • 指令碼區塊的輸出會被捨棄。不含畫面格式化・描繪的成本。實際體感較慢時,元兇也可能是顯示端
  • 要在相同條件下比較。檔案快取是否已經預熱,會讓包含 I/O 的處理測量值有很大差異

想細分處理中哪一段比較慢時,可以用 Stopwatch

$sw = [System.Diagnostics.Stopwatch]::StartNew()
$rows = Import-Csv $csvPath
Write-Verbose "讀取: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()

$index = $master | Group-Object Code -AsHashTable -AsString
Write-Verbose "建立索引: $($sw.ElapsedMilliseconds) ms"; $sw.Restart()

$result = foreach ($r in $rows) { Join-KsRecord $r $index }
Write-Verbose "比對: $($sw.ElapsedMilliseconds) ms"; $sw.Stop()

像這樣輸出每個區段所花的時間,就能立刻看出「其實讀取就佔了 8 成」之類的事實。之所以使用 Write-Verbose,是為了讓一般執行時不輸出,只在調查時才看到(「PowerShell 的輸出串流與日誌設計」)。

3. 最大的元兇 ── 陣列的 +=

PowerShell 的陣列是固定長度的。無法追加元素,+= 會展開成「建立新陣列並複製所有元素、再把新元素加到最後」的處理。1 也就是說,追加 n 筆的迴圈,整體所做的複製會與 n 的平方成正比。

會增加到什麼程度,可以從寫法直接算出來。追加 n 筆時所發生的元素複製總數是 n(n-1)/2

追加的筆數 += 所產生的元素複製總數 List[T].Add 的追加次數
1,000 筆 約 50 萬次 1,000 次
10,000 筆 約 5,000 萬次 10,000 次
100,000 筆 約 50 億次 100,000 次

這不是實際測量的執行時間,而是由寫法就能唯一決定的處理次數。每筆的實際耗時因環境而異,請用配布 zip 中的效能基準測試(benchmark)在自己的環境上測量秒數。不過,筆數增為 10 倍時次數會增為 100 倍,這種成長方式與環境無關,是一定成立的。

# 【NG】筆數增加就會急遽變慢
$result = @()
foreach ($row in $rows) {
    $result += ConvertTo-KsRecord $row     # 每次都會複製整個陣列
}

# 【OK-1】使用 List[T](追加元素為常數時間)
$result = [System.Collections.Generic.List[object]]::new()
foreach ($row in $rows) {
    $result.Add((ConvertTo-KsRecord $row))
}

# 【OK-2】把整個迴圈的輸出交給變數接收(PowerShell 風格,而且快)
$result = foreach ($row in $rows) {
    ConvertTo-KsRecord $row                # 輸出直接被彙總起來
}

【OK-2】的寫法,熟悉之後會是 PowerShell 中最自然的形式。foreach 陳述式、ForEach-Objectif 等的輸出,都能直接被變數彙總起來。要掌握這個特性,最好搭配理解「輸出串流」一起學習。

同樣的道理也適用於字串。字串是不可變的(immutable),所以 $s += "line" 每次都會建立新的字串。

# 【NG】
$text = ''
foreach ($line in $lines) { $text += "$line`r`n" }

# 【OK-1】-join(最簡潔)
$text = $lines -join "`r`n"

# 【OK-2】StringBuilder(適合有條件分支等較複雜組裝場合)
$sb = [System.Text.StringBuilder]::new()
foreach ($line in $lines) { [void]$sb.AppendLine($line) }
$text = $sb.ToString()

4. 別再用雙重迴圈 ── 比對請用雜湊表

接下來有效的是比對(matching)。「針對訂單資料的每一行,從主檔中查出商品名稱」這種處理,如果照直覺寫出來,會變成這樣。

# 【NG】訂單 1 萬筆 × 主檔 1 萬筆 = 1 億次比較
foreach ($order in $orders) {
    $item = $master | Where-Object { $_.Code -eq $order.Code }   # 每次都全部掃描
    $order | Add-Member NoteProperty ItemName $item.Name
}

把這段改成雜湊表,比較次數會大幅減少。2

# 【OK】只建立一次索引,之後用鍵值查找
$index = $master | Group-Object -Property Code -AsHashTable -AsString

$result = foreach ($order in $orders) {
    $hit = $index[$order.Code]        # 鍵值查找的成本不受筆數影響
    [pscustomobject]@{
        Code     = $order.Code
        Qty      = $order.Qty
        ItemName = if ($hit) { $hit[0].Name } else { $null }   # 也能偵測未登錄的項目
    }
}

這裡也用次數來看一下。雙重迴圈的比較次數是「訂單筆數 × 主檔筆數」,雜湊表則是「建立一次索引」加上「逐筆查找」,因此與總筆數成正比。

訂單筆數 × 主檔筆數 雙重迴圈的比較次數 雜湊表的處理次數
1,000 × 1,000 100 萬次 約 2,000 次
10,000 × 10,000 1 億次 約 2 萬次
100,000 × 100,000 100 億次 約 20 萬次

筆數變成 10 倍時,雙重迴圈是 100 倍,雜湊表則是 10 倍。筆數愈多,差距就愈大,因此資料量預期會增加的處理,愈值得優先修正。

Group-Object -AsHashTable 會把相同鍵值的元素彙整成陣列,所以值會是陣列(前面的 $hit[0])。如果已經知道鍵值是唯一的,也可以自己組出雜湊表。

$index = @{}
foreach ($m in $master) { $index[$m.Code] = $m }    # 前提是鍵值唯一

如果只是要反覆判斷「是否包含」,HashSet 很有效。對陣列使用的 -contains-in 是線性搜尋,判斷次數一多就會顯現差異。

$known = [System.Collections.Generic.HashSet[string]]::new(
    [string[]]$master.Code, [System.StringComparer]::OrdinalIgnoreCase)

$unknown = $orders | Where-Object { -not $known.Contains($_.Code) }

CSV 之間比對的實務模式,在「用 PowerShell 自動化 Excel・CSV 業務處理」中也有處理。

5. 管線與 foreach 陳述式

管線是 PowerShell 的核心功能,但每個物件在 Cmdlet 之間流動都有處理成本。在數十萬筆規模的內層迴圈中,foreach 陳述式往往比較快。3

# 管線: 易讀,而且是逐一處理,不會囤積中途結果
$rows | Where-Object { $_.Status -eq 'OK' } | ForEach-Object { $_.Amount } |
    Measure-Object -Sum

# foreach 陳述式: 每筆的額外負擔較小,大量筆數時較快
$sum = 0
foreach ($r in $rows) { if ($r.Status -eq 'OK') { $sum += $r.Amount } }

關於記憶體,這裡有個容易被誤解的地方,補充說明一下。foreach 陳述式會先計算小括號內的運算式,才進入迴圈,所以像 foreach ($line in (Get-Content $path)) 這樣傳入指令的執行結果時,那個當下就會把全部內容載入記憶體。3 另一方面,如果傳入的是延遲列舉的 IEnumerable,就會一筆一筆列舉。因此即使是巨大檔案,只要這樣寫,就能維持 foreach 陳述式的形式,同時不佔用記憶體。

# 不把全部內容載入記憶體,以 foreach 陳述式處理(延遲列舉)
foreach ($line in [System.IO.File]::ReadLines($path)) {
    if ($line.StartsWith('ERROR')) { $errors++ }
}

不過請注意預設字元編碼不同這一點。只帶 1 個參數的 ReadLines($path) 會以 UTF-8 讀取(若有 BOM 則優先採用)。而 Windows PowerShell 5.1 的 Get-Content,在沒有 BOM 時會以目前的 ANSI 字碼頁(日文環境下為 Shift_JIS)讀取。也就是說,如果直接把 Shift_JIS 的日誌換成這種寫法,日文就會亂碼。要替換時,請使用能明確指定編碼的多載。

# 讀取 Shift_JIS(CP932)日誌時
# PowerShell 6.2 以後已登錄字碼頁提供者,因此
# 可以直接呼叫 GetEncoding(932)(Windows PowerShell 5.1 也相同)
$enc = [System.Text.Encoding]::GetEncoding(932)
foreach ($line in [System.IO.File]::ReadLines($path, $enc)) {
    if ($line.StartsWith('ERROR')) { $errors++ }
}

另外,PowerShell 7 中 Get-Content 的預設值是 UTF-8(不含 BOM),因此只要是在 7 上處理 UTF-8 檔案,即使用只帶 1 個參數的多載,結果也會一致。替換之前,請務必先確認目標檔案的字元編碼與執行環境的版本。

判斷的大致標準如下。

情況 選擇
筆數不多・優先考慮易讀性 管線
傳入指令結果這種巨大輸入(Get-Content 等) 管線,或 [IO.File]::ReadLines + foreach 陳述式
已在記憶體中的陣列,要跑數十萬筆 foreach 陳述式
對陣列做簡單的篩選 .Where({...}) / .ForEach({...}) 方法1

.Where().ForEach() 是 PowerShell 4.0 新增的陣列方法,因為不經過管線,所以較為輕量。1 不過前提是操作對象已經是記憶體中的集合。

6. 檔案與目錄的 I/O

讀取要依目的分開使用。Get-Content 預設會逐行產生物件,在巨大檔案中,這個成本會成為主要瓶頸。4

Get-Content $path                      # 逐行(產生行數份的物件)
Get-Content $path -Raw                 # 整體讀入為一個字串
Get-Content $path -ReadCount 1000      # 以每 1000 行為單位,以陣列傳遞(減少物件產生數)
[System.IO.File]::ReadLines($path)     # .NET。逐一列舉,最輕量(預設 UTF-8)

目錄掃描請使用 -Filter。官方文件明確指出「篩選是由提供者在取得物件時套用,比其他參數更有效率」。5

# 【NG】先取得全部,再捨棄
Get-ChildItem -Path $root -Recurse | Where-Object { $_.Extension -eq '.log' }

# 【OK】在提供者端就篩選
Get-ChildItem -Path $root -Recurse -File -Filter '*.log'

# 數十萬檔案規模,若還是很慢,可以考慮 .NET 的列舉
[System.IO.Directory]::EnumerateFiles($root, '*.log', 'AllDirectories')

寫入方面,在迴圈內每次追加寫入是典型的瓶頸。Add-Content 每次呼叫都會開啟並關閉檔案。

# 【NG】1 萬行 = 1 萬次開啟/關閉
foreach ($r in $result) { Add-Content -Path $out -Value ($r -join ',') }

# 【OK-1】一次寫完
$result | ForEach-Object { $_ -join ',' } | Set-Content -Path $out -Encoding utf8

# 【OK-2】若需要逐次寫出,讓 StreamWriter 保持開啟狀態
$writer = [System.IO.StreamWriter]::new($out, $false, [System.Text.UTF8Encoding]::new($false))
try   { foreach ($r in $result) { $writer.WriteLine($r -join ',') } }
finally { $writer.Dispose() }

透過網路的檔案操作,往返次數本身就會成為主要瓶頸。UNC 路徑特有的注意事項,請參考「網路磁碟機・UNC 路徑的陷阱」。

7. 容易被忽略的小成本

在猶豫該從哪裡下手時,附上效果大小的參考。這不是實測值,而是由「每次成本 × 執行次數」決定的數量級感受,請對照自己腳本的筆數來閱讀。

  • Write-Progress 的更新頻率。每次迴圈都更新進度時,描繪成本有可能超過處理時間本身。可以每 100 筆等間隔更新一次,或在無人執行時用 $ProgressPreference = 'SilentlyContinue' 關閉 ── 效果程度: 大。每次描繪的成本較重,迴圈次數會直接反映出來。迴圈數萬次以上時要最優先檢視
  • 在中途插入 Format-Table / Format-List會被轉換成用於顯示的格式化物件,因此不僅後續處理會壞掉,還是無謂的成本。顯示應該只放在最後7 ── 效果程度: 中。比起速度,「後續處理壞掉」造成的實害更大,即使不是為了速度也值得修正
  • 迴圈內不變的處理。Get-Date 的格式字串產生、正規表示式的編譯、模組的重新載入等搬到迴圈外,有時就能看到效果 ── 效果程度: 中~大。取決於被搬出的那 1 次處理本身的成本,如果混入像模組重新載入這種較重的處理,效果會非常顯著
  • 過度使用 Select-Object -Property因為會產生新的 PSCustomObject,筆數一多就不能忽視。有時保留原始物件到必要階段才轉換會比較快 ── 效果程度: 小~中。幾千筆感覺不出來,超過幾萬筆之後才會逐漸顯現
  • 頻繁發生例外。大量經過 try/catch 的設計(例如每次都對不存在的檔案呼叫 Get-Item、再接住例外)成本很高。應該事先用 Test-Path 分支 ── 效果程度: 大。擲出與捕捉例外遠比一般分支昂貴,筆數 × 例外發生率愈大,影響就愈主導全局

8. 實務定石(判斷表)

症狀 懷疑的地方 對策 詳見
筆數一多就突然變慢 $array += / $string += List[T]、以變數接收迴圈輸出、-join1 第 3 章
兩份資料的比對跑不完 雙重迴圈的線性搜尋 雜湊表 / Group-Object -AsHashTable2 第 4 章
巨大檔案讀取很慢 Get-Content 的逐行物件產生 -Raw / -ReadCount / [File]::ReadLines4 第 6 章(字元編碼注意事項見第 5 章)
資料夾掃描很慢 Where-Object 做後段篩選 Get-ChildItem -Filter,必要時用 EnumerateFiles5 第 6 章
輸出檔案寫入很慢 迴圈內的 Add-Content 一次寫完,或使用 StreamWriter6 第 6 章
CPU 很閒卻遲遲跑不完 網路・I/O 等待 輪到並行化上場 另一篇文章「並行處理
畫面顯示很慢 格式化處理・描繪 Format-* 只放最後,關閉 $ProgressPreference7 第 7 章
不知道哪裡慢 測量不足 Stopwatch 分區間測量,Measure-Command 只看第 2 次以後8 第 2 章

9. 總結

  • 首先要測量。請注意 Measure-Command 會捨棄輸出、不含顯示成本這一點,以及第一次執行的數值不能用。
  • $array += 會使耗時與筆數的平方成正比。優先改成 List[T] 或以變數接收迴圈輸出。
  • 比對的雜湊表化,是投資報酬率最高的改善。發現雙重迴圈時,請思考能否建立索引。
  • 檔案 I/O 依目的選擇讀寫方式。基本做法是善用 -Filter 在提供者端篩選、一次寫完、以及分開使用 StreamWriter
  • 不在中途插入 Format-*、拉長進度更新的間隔、把迴圈內不變的處理搬到外面 ── 這些小小的累積,在筆數多時也很有效。
  • 並行化是最後的手段。請先修正演算法,再套用到等待時間佔主導的處理上。

範例程式碼下載

本文所處理的程式碼,已整理成可直接執行的形式提供下載。內含 3 種效能基準測試(benchmark)與測量輔助工具。

下載範例程式碼(zip)

本文的範例,已在 PowerShell 7.6 上實際執行驗證(Pester 8 項)。執行 zip 內附的 Invoke-SampleTests.ps1,您也能在自己的環境重現相同的驗證。

# 語法解析 + 靜態分析 + Pester 測試
./Invoke-SampleTests.ps1

設定值(路徑、伺服器名稱、租戶 ID 等)僅為範例。請勿直接在正式環境執行,請依貴公司的環境調整後再使用。

相關文章

相關諮詢領域

合同會社小村軟體處理耗時過長的彙總・比對處理效能改善、能承受資料量增加的處理架構重新設計,以及效能劣化原因調查。

參考連結

  1. Microsoft Learn,about_Arrays。關於 PowerShell 陣列是固定大小、+= 運算子的動作是建立新陣列並複製既有元素後再追加元素、反覆追加或刪除元素時適合使用集合型別(List 等),以及 PowerShell 4.0 新增的 .Where().ForEach() 方法的說明。  2 3 4 5

  2. Microsoft Learn,Group-Object。關於可用 -AsHashTable 把分組結果以雜湊表回傳、用 -AsString 可將鍵值視為字串處理,以及回傳的雜湊表其值會是各分組元素所組成之陣列的說明。  2 3

  3. Microsoft Learn,about_Foreach。關於 foreach 陳述式會先計算小括號內的運算式再開始反覆執行,因此傳入指令的執行結果時該結果會保留在記憶體中,而 ForEach-Object Cmdlet 則是從管線逐一接收輸入,以及兩者分工方式的說明。作為延遲列舉的範例,也說明了 File.ReadLines 方法能不讀入整個檔案、逐行列舉(與 ReadAllLines 的差異)。  2 3

  4. Microsoft Learn,Get-Content。關於預設情況下會以換行分隔、逐行回傳物件,用 -Raw 可將整個檔案讀入為單一字串,以及用 -ReadCount 可依指定行數彙整後送入管線的說明。  2 3

  5. Microsoft Learn,Get-ChildItem。關於 -Filter 是由提供者在取得物件時套用,因此比取得後再篩選的其他參數更有效率,以及與 -File、-Recurse 併用的說明。  2 3

  6. Microsoft Learn,StringBuilder 類別。關於 String 因為不可變,每次串接都會產生新的執行個體,相對地 StringBuilder 是在可變的緩衝區上組裝字串的說明。並一併說明 StreamWriter 類別在保持串流開啟狀態下逐次寫入的動作。  2

  7. Microsoft Learn,Format-Table。關於 Format 系列 Cmdlet 會產生用於顯示的格式化物件,因此不適合用於傳給後續指令的管線用途,應該放在管線的最後使用的說明。  2 3

  8. Microsoft Learn,Measure-Command。關於會測量指令碼區塊或指令的執行時間並回傳 TimeSpan、測量對象的輸出本身不包含在結果中的說明。並一併說明用 Stopwatch 類別進行區間測量的方式。  2 3

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

$array += $item 這種寫法,為什麼會很慢?
因為 PowerShell 的陣列是固定長度,無法追加元素。+= 運算子會展開成「建立一個新陣列、複製既有的所有元素、再把新元素加到最後」這樣的處理。每追加 1 筆就會複製全部元素,所以追加 n 筆時,運算量會與 n 的平方成正比。100 筆的話不會察覺,但超過 1 萬筆就會慢到有感,10 萬筆則已經不堪實用。解法是改用 System.Collections.Generic.List[T] 的 Add,或是改成把整個迴圈的輸出直接交給變數接收的寫法。
用 Measure-Command 測量的結果,和實際的執行時間對不上。
這是因為 Measure-Command 只測量指令碼區塊的執行時間,其輸出會被捨棄。實際執行時,還會多出把結果格式化顯示在畫面上的成本(格式化處理),以及主控台的描繪時間。此外,第一次執行會包含模組載入與 JIT 編譯的時間,因此第 1 次的測量值大多不能拿來當參考。請務必遵守兩點:把同樣的處理執行數次,只看第 2 次以後的數值;以及比較的對象要在相同條件下測量。
兩份 CSV 的比對一直跑不完,該修哪裡?
很可能是內層迴圈每次都用 Where-Object 做線性搜尋。如果雙方各有 1 萬筆,比較次數就會來到 1 億次。把其中一方轉成雜湊表(或用 Group-Object -AsHashTable)、改成用鍵值查找的形式,比較次數就能降到與總筆數成正比的程度。這是筆數愈多效果愈明顯、投資報酬率最高的改善。
聽說直接呼叫 .NET 的 API 比用 Cmdlet 快,是不是任何時候都該這樣做?
不,這要看情況分開使用。.NET 的 API(例如 System.IO.File 的 ReadLines、EnumerateFiles)確實較快,但會失去 PowerShell 提供者功能、萬用字元、相對路徑解析等便利性,程式碼也會變得比較難讀。請先測量,只替換掉實際確認為瓶頸的部分。像掃描數十萬個檔案、讀取巨大檔案這種明確有效的場合,才是實務上該限定使用的範圍。
把處理並行化就會變快嗎?
如果是等待時間佔大部分的處理,並行化確實有效,但就順序而言它排在最後。請先做能減少無謂處理的事(縮小取得筆數、把迴圈裡不變的處理搬到外面、把比對改成雜湊化)。演算法本身還是 O(n^2) 的話,就算並行化,也只會依核心數的比例變快。反過來說,如果把每筆都很輕量的處理拿去並行化,也可能因為額外負擔而變得更慢。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽