Qiita初投稿のCayenneRyoです。
なんか間違ったこともしちゃうかもしれませんが、モヒカン族からの襲撃を歓迎します。
さて、先日fukuoka.ex#8(福岡Elixir会):春のElixir入学式に参加しました。
その中のzackyさんのプレゼンを聴いて、なんか似たようなことがあったな、と思ったことを書いてみます。
今時、各言語で、並列実行の方法が準備されてるけど、うまく使わないと実行時間が早くならないよ、と言う話。
例えば、単に5秒スリープするだけの処理を10本実行する場合を考える。
普通に考えれば、ほぼ何もしない処理なので、同時並列に実行されて、5秒ちょっとで終わるのではないか、とかんがえるところだが…
Elixirの並列実行: TaskとFlow/GenStage
Elixir歴二週間未満なので、zackyさんのプレゼンから丸パク。
Elixir: Task
Elixirに標準で入っているTask
を使った並列実行。
:timer.tc
を使うと、マイクロ秒で実行時間を計測できる。
iex(1)> :timer.tc(fn ->
...(1)> 1..10
...(1)> |> Enum.map(fn i ->
...(1)> Task.async(fn -> Process.sleep(5000); i end)
...(1)> end)
...(1)> |> Enum.map(&Task.await &1)
...(1)> end)
{5011513, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}
iex(2)>
すばらしい! 予想通り5秒ちょっとで実行できた!
※なぜか手元の環境では、たまにエラーになることがあるが、理由は不明。その内調べる…
Elixir: Flow/GenStage
何も考えなくてそれなりに巧く並列実行してくれるFlow
。
mix
でプロジェクトを作って、mix.exs
の中のdefp
にFlow
を加えて…
とゴニョって、iex -S mix
を実行。
iex(1)> :timer.tc(fn ->
...(1)> 1..10
...(1)> |> Flow.from_enumerable
...(1)> |> Flow.map(fn i -> Process.sleep(5000); i end)
...(1)> |> Flow.partition
...(1)> |> Enum.to_list
...(1)> end)
{50052699, [3, 4, 2, 6, 8, 10, 1, 5, 7, 9]}
iex(2)>
およ?! これって50秒かかっているのでは?
前述のzackyさんのプレゼンによれば、Flow
は処理を適当な数の塊にしてElixirのプロセスに渡すし、並列度も調節してくれるのだが、今回の様に中身がスリープのみと言うようなケースでは、それが仇になってしまうのだとか。
それを避けるには、Flow.from_enumerable
に適切なオプションを指定すればいいらしい。
iex(2)> :timer.tc(fn ->
...(2)> 1..10
...(2)> |> Flow.from_enumerable(max_demand: 1, stages: 10)
...(2)> |> Flow.map(fn i -> Process.sleep(5000); i end)
...(2)> |> Flow.partition
...(2)> |> Enum.to_list
...(2)> end)
{5003564, [2, 1, 6, 5, 8, 9, 3, 4, 7, 10]}
iex(3)>
素晴らしい! 5秒台!!
この例では、同時に実行する:stages
を10、バッチサイズ:max_demand
を1にして、すべての処理が同時並列で実行されるよう調整している。
と、ここまでがzackyさんのプレゼンのパクリ。
Bashの並列実行
で、同じことをシェルスクリプト・bashでやるとどうなるか?
Bash: 通常のバックグラウンド実行とwait
ご存知の通り、シェルスクリプトは、コマンドの末尾に「&
」を付けるとバックグラウンドで実行してくれる。
バックグラウンドジョブの終了を待つには、wait
コマンドを使う。
$ time (for NUM in $(seq 1 10); do (sleep 5; echo $NUM)& done; wait $(jobs -p))
1
2
4
5
3
7
8
6
9
10
real 0m5.071s
user 0m0.043s
sys 0m0.114s
$
ゐぇい! 5秒台。
ちなみに、Elixirに影響されて、配管しようとしてはいけない。
\$ time (seq 1 10 | while read NUM; do (sleep 5; echo \$NUM)& done; wait $(jobs -p))
とやっても上手く行かない(実装が多い)。
これは、jobs -p
が空を返すから。
パイプ「|
」の後は、サブシェルで実行される。
サブシェルの中で起動されたバックグラウンドジョブは、親ジョブからは見えない。
Bash: GNU Parallelで並列実行
GNU Parallelは、並列実行を巧いことやってくれるツール。
しかし、使い方をよく考えないと、ElixirのFlow
と同じようなことが起こる。
$ time (seq 1 10 | parallel "sleep 5; echo {}")
Academic tradition requires you to cite works you base your article on.
When using programs that use GNU Parallel to process data for publication
please cite:
O. Tange (2011): GNU Parallel - The Command-Line Power Tool,
;login: The USENIX Magazine, February 2011:42-47.
This helps funding further development; AND IT WON'T COST YOU A CENT.
If you pay 10000 EUR you should feel free to use GNU Parallel without citing.
To silence the citation notice: run 'parallel --bibtex'.
1
2
3
4
5
6
7
8
9
10
real 0m25.623s
user 0m0.361s
sys 0m0.551s
$
実際に実行される様子を見てみると判るが、同時には二つづつしか実行されない。
これは、GNU Parallelがコア数などを見て調整した結果。
これを避けるには、--jobs N
オプションを使う。
$ time (seq 1 10 | parallel --jobs 10 "sleep 5; echo {}")
Academic tradition requires you to cite works you base your article on.
When using programs that use GNU Parallel to process data for publication
please cite:
O. Tange (2011): GNU Parallel - The Command-Line Power Tool,
;login: The USENIX Magazine, February 2011:42-47.
This helps funding further development; AND IT WON'T COST YOU A CENT.
If you pay 10000 EUR you should feel free to use GNU Parallel without citing.
To silence the citation notice: run 'parallel --bibtex'.
1
2
3
4
5
6
7
8
9
10
real 0m5.746s
user 0m0.354s
sys 0m0.661s
$
よし、5秒台。
考察
今回の例は、単に5秒間スリープすると言う、極端にCPU資源を消費しない特殊な処理だった。
なので、こんな検証に意味はない…わけでもない。
たとえば、ウェブAPIの応答待ちが処理時間の大半を占めるというようなものだと、似たような状況になる。
ElixirのFlow
やGNU Parallelは、並列処理の実装に便利なツールだが、上の様な処理の場合には、期待通りには動かないことを知っておくべきだろう。
次は、PowerShellの場合についても検証してみたい。