tech.guitarrapc.cóm

C#, PowerShell, Unity, Cloud, Serverless Technical Update and Features

PowerShellのForeach-Objectは一体何をしているのか

さて、前回の記事でForeach-Objectが残念であることを暴いてしました (

日本語記事: PowerShellでFor / Foreach / Foreach-Object / ScriptBlock / Filterのベンチマーク
English Article: PowerShell For / Foreach / Foreach-Object / ScriptBlock / Filter – Benchmark 2013

さて、ここでいい機会なのでforeachとForeach-Objectがどのような動作をしているのか。 何故パフォーマンスに差が出るのかを考えてみます。 

PowerShelインアクション

Windows PowerShell インアクション (2007年8月3日初版発行)にはこうあります。

Foreach-Objectコマンドレットは、基本的には、匿名フィルタを実行する手段です。 匿名とは、名前を指定したり、あらかじめ定義したりする必要が無いことを意味します。必要なときに使用するだけです。ただし、名前付きフィルタを作成する機能も非常に便利です。
※引用 : P232 - Part I PowerShellの習得 / 7.4.1フィルタと関数

ではここで言うフィルタとは何でしょうか? 前回の記事にもありますが、Filterはこのような構造を持ちます。

filter <名前>{param(<パラメーターリスト>) <文リスト>}

ご存じのFunctionと比べてみましょう。

function <名前>{param(<パラメーターリスト>) <文リスト>}

……はい、FunctionキーワードをFilterキーワードに差し替えただけです。 しかし大きく違います。 filterの利用例を見てみましょう。

filter double {$_ * 2}
1..5 | double

たったこれだけで1..5と渡された内容を順次実行します。 どこかで見たことありますね?そう、Foreach-Objectと同じ利用ができるのです。

1..5 | Foreach-Object {$_ * 2}

ではここでFilterの定義を見てみましょう。

フィルタは関数の一般概念を拡張したものです。パイプラインの関数が一度だけ実行されるのに対し、フィルタはパイプラインから渡されるオブジェクトごとに実行されます。
~中略~
関数とフィルタの構文上の違いはキーワードだけです。大きな違いはすべて意味的なものです。関数は一度実行され、最後まで実行されます。パイプラインで使用されると、関数はストリーミングを中止します。つまり、パイプラインの1つ前の要素が最後まで実行されて初めて、関数の実行が開始されます。また、関数がパイプラインの最初の要素以外のものとして使用された場合に定義される、特殊な変数$inputもあります。これに対し、フィルタは一度実行され、パイプラインの要素ごとに最後まで実行されます。フィルタは、変数$inputの代わりに、現在のパイプラインオブジェクトを保持する特殊な変数$_を持ちます。
※引用: P231 - Part I PowerShellの習得 / 7.4.1フィルタと関数

更に書き換えてみましょう。 ScriptBlockで同様のコードを書きます。

1..5 | &{process{$_ * 2}}

ScriptBlockはFunction同様にbegin{}process{}end{}を持てます。当然param()も持てます。 つまり、上記は、ScriptBlock内部のProcess{}を持ってきたものです。

{
    process{
        <文リスト>
    }
}

さぁ、ここで今一度Foreach-Objectを考えます。 ScriptBlockでこの記述が…

1..5 | &{process{$_ * 2}}

Foreach-Obejctでこうなります。

1..5 | Foreach-Object {$_ * 2}

どうでしょうか。 ここでインアクションはこのように述べています。

Foreach-Objectコマンドレットは、基本的には、複雑なスクリプトブロック構造のショートカットです。
※引用: P255 - Part I PowerShellの習得 / 8.1.3 スクリプトブロックリテラル

ふむ…、もう少し細かく覗いてみると?

前略~ ループがオブジェクトを1つずつ処理することです。通常のforeachループでは、1つの値を処理する前に、値のリスト全体が生成されます。Foreach-Objectパイプラインでは、オブジェクトが1つずつ生成され、コマンドレットに渡されて、処理されます。
※引用 : P196 - Part I PowerShellの習得 / 6.8.1 Foreach-Objectコマンドレット

メモリの利用に関しても指摘があります。

前略~
Foreach-Objectコマンドレットには、特定の時点で使用される領域がforeachループよりもすくないという利点があります。例えば、大きなファイルを処理する場合、foreachループではファイル全体をメモリに読み込んでから処理しなければなりません。Foreach-Objectコマンドレットを使用する場合、ファイルは1行ずつ処理されます。これにより、タスクを実行するために必要なメモリの量が大幅に少なくなります。
※引用 : P196 - Part I PowerShellの習得 / 6.8.1 Foreach-Objectコマンドレット

さて、これらが全ての要因でしょうか?

おそらく他にもありそうです。 加えて、FilterもScriptBlock{Process{}}も、同様にオブジェクトをストリーミングしています。 つまり、オブジェクトを両者ともに1つずる処理しています。 にも関わらず、Foreach-ObjectがScriptBlockなどよりコストがかかっているのはなぜでしょう。

メモリに関しては、これは処理に依存する話なので状況によりにとどめます。

Cmdlet.ProcessRecord

さて、牟田口先生に少し相談するとこのようなヒントが。

つまりこれです。

MSDN - Cmdlet.ProcessRecord Method

ここに記述が

The Windows PowerShell runtime calls this method multiple times for each instance of the cmdlet in the pipeline.

メモリ管理からの視点

さて、海外MVP同志が真っ向からぶつかり合った興味深いブログがあります。

所要ステップではという指摘

Brandon先生から、このような指摘があります。

Why use foreach vs foreach-object.
The reason “foreach(){}” is faster is because it compiles into a single expression tree which gets evaluated in a single function call. While, foreach-object is effectively compiled into three expression trees: For example
get-childitem | foreach-object { $_.Name }
Get the value to pipe. Get-Childitem Call the Foreach-Object. foreach-object One for the ScriptBlock. {$_.Name}

メモリではという指摘

これに対して、Kirk Munro先生がメモリ管理の指摘をしています。

Essential PowerShell: Understanding foreach

例として、Dmitry先生によるメモリ消費での問題例を挙げています。

Optimize PowerShell Performance and Memory Consumption

また、Powershellインアクション著者によるこの一言も上がっています。

according to Bruce Payette, author of PowerShell in Action and development lead for PowerShell, foreach can perform faster than ForEach-Object in some cases. He states, ”in the bulk-read case, however, there are some optimizations that the foreach statement does that allow it to perform significantly faster than the ForEach-Object cmdlet”.

まとめ

最終的には、このようにまとまっています。

1) Foreach-Objectは、Object生成と破棄を順次実行するよ。 2) forach(){}は、初めにオブジェクトの生成と実行をまとめて行tって、破棄は最後に行うよ。 つまり用途によって適材適所という事です。 1) Foreach-Objectは、Very Large Data Searchには向いているとは言えないです。 2) 一方で小さなデータセットには向いてるね!
1) foreach-object is process and “cleanup” as you go 2) foreach(){} is collect and process. Cleanup at the end. We all understand this and it can be put in the “we know this” pile. Now… our difference is in application. I am saying there is not a pragmatic difference in application, and therefore the difference is moot. Why do I say that? 1) foreach-object is not very useful for VLDS by itself, meaning you have to do something with the data you collect or whats the point. 2) This argument is moot in small datasets.

メモリ管理からの視点2

先の纏めをもう少し端的にしめします。

Powershell: Foreach-Object vs. foreach
foreachは、メモリに全Objectを格納するよ。 Foreach-Objectは、各プロセスで必要分のみメモリに格納すると。 ただし、foreachは、全メモリを格納するため、若干の最適化がかかりForaech-Objectよりも早くなるよ。 ここで、パフォーマンスとメモリのトレードオフは考える必要があるね。
First, because in the foreach statement case all the objects are gathered at once, you need to have enough memory to hold all these objects. In the Foreach-Object case, only one object is read at a time so less storage is required. From this, you would think that Foreach-Object should always be preferred. In the bulk-read case, however, there are some optimizations that the foreach statement does that allow it to perform significantly faster than the Foreach-Object cmdlet. The result is a classic speed versus space tradeoff. In practice, though, you rarely need to consider these issues, so use whichever seems most appropriate to the solution at hand.
Forach-Objectについて、パイプラインを介するときに、Objectを生成し、次の要素生成前にforeachに渡すよ。 従来のShellの場合は、各コマンドが別プロセスで実行…つまり、Objectの生成とforeachへ渡す、次の要素生成をほぼ同時に行う。 でもPowerShellは、左辺でパイプラインからのObject生成、それから右辺のforeachに渡す、でforaech分を実行、同時に$foreach変数もループ中宣言(LoopのEnumeratorになります。foreachは、$foreach変数という Loop Enumeraotrで、現在の進捗を把握します。)このLoop Enumeratorを操作することで、skipさせたりも可能になります。
The second difference is that in the Foreach-Object case, the execution of the pipeline element generating the object is interleaved with the execution of the Foreach-Object cmdlet. In other words, the command generates one object at a time and then passes it to foreach for processing before generating the next element. This means that the statement list can affect how subsequent pipeline input objects are generated. Unlike traditional shells where each command is run in a separate process and can therefore actually run at the same time, in PowerShell they’re alternating— the command on the left side runs and produces an object, and then the command on the right side runs. Executing the foreach statement also defines a special variable for the duration of the loop. This is the $foreach variable and it’s bound to the loop enumerator. (The foreach statement keeps track of where it is in the collection through the loop enumerator.) By manipulating the loop enumerator, you can skip forward in the loop.

同様の試み

当然、世界には同じことを考えてる人も多いわけです。 ここでもヒントを得ることができます。 Performance with PowerShell

"Early Filter:" 
" {0} ms"  -f  ((Measure-command {1..100 | foreach { 
Get-WMIObject win32_share -computer  Cookham1 -filter "Description='remote admin'"}}).totalmilliseconds).tostring("f")

"Late filter:" 
" {0} ms"  -f  ((Measure-command {1..100 | foreach { 
Get-WMIObject win32_share -computer  Cookham1  | Where {$_.description -eq 'remote admin'}}}).totalmilliseconds).tostring("f")

結果です。

Early Filter: 
 1948.91 ms 
Late filter: 
 2715.44 ms

オブジェクトを渡した場合のforeachとForeach-Objectの差は、28%程度のforeach優位という結果。 では、Arrayではどうでしょうか。

$items = 1..10000 
Write-Host "ForEach-Object: " 
" {0} ms"  -f ((Measure-Command { $items | ForEach-Object { "Item: $_" } }).totalmilliseconds).tostring("f")  
Write-Host "Foreach: " 
" {0} ms" -f ((Measure-Command {Foreach ($item in $items) { "Item: $item" }}).totalmilliseconds).tostring("f")

結果です。

ForEach-Object: 
  629.73 ms 
Foreach: 
 31.84 ms

ここで、メモリ以外に繰り返しに関して指摘があります。 これこそが、今回のArrayにおける差がついた理由でしょう。

foreachをパイプラインで利用した場合、PowerShellはプロセスの最初の段階でObjectの生成を最適化でき移行の処理に移ります。 一方で、Foreach-Objectは、初めに「繰り返し実行する全オブジェクト」を定めてからパイプラインで渡し、繰り返し処理に入ります。 この繰り返し処理の際に、foreachよりも処理を要し、またコレクションオブジェクトが大きければ大きいほど繰り返し処理でメモリを要し処理速度が悪化することになります。
When you use the foreach operator in a pipeline, PowerShell is able to optimise the creation of objects at one stage of a pipeline and their consumption in the next. Using Foreach-Object, you need to first persist all the objects you wish to iterate across, then perform the iteration. The latter clearly requires a bit more processing and it is likely to require more memory (which can be a bad thing if the collection of objects is large!

まとめ

foreachとForeachではこのような違いと纏めれるかと思います。

foreach

  1. 初めにコレクションをすべてメモリに格納する
  2. その分メモリを要することになり、コレクションサイズによってはメモリが足りなくなることもある
  3. 一方で、PowerShellの処理最適化がかかる事があり、高速化される場合がある
  4. ただし、処理はストリーム出力ではなく揃ってからの出力となる
  5. メモリ解放は、出力時。すなわち全処理の完了時となる
  6. 大量の繰り返しを伴う配列やデータに向くが、ストリーム出力されない分、順次確認には向かない
  7. パイプライン中に利用することはできない

Foreach-Object

  1. 初めにコレクションを定める
  2. パイプラインを介して、コレクションを順次Object生成、foreachに渡す、処理を実行、$foreach変数への格納、出力、メモリ破棄、次のObjectへを繰り返す
  3. 都度のObject処理を順次実行するため、メモリはその処理分でのみ要することになり、コレクションサイズによらずメモリ節約できる
  4. PowerShellの処理最適化はかからない
  5. 処理はストリーム出力であり、順次出力される
  6. メモリ解法は、出力時。すなわち各処理の完了時となる
  7. 大量の繰り返しを伴う配列やデータにはむかない。オブジェクトを持ったデータのパイプには自動変数$_での記述利便性やストリーム処理が向く。

また、私と同様にScriptBlockを使っている例がコメントにありましたね。 またドット化では、ChildScopeに絞れるがSciptBlock単独実行より遅いようです。

& {process{}} #ScriptBlock
. {Process{}} #Dot

参照スコープ

日本でもこのような議論があります。 これは、次回の記事に持ち越しましょう。