LoginSignup
35
30

More than 1 year has passed since last update.

シェルスクリプトはパイプで並列処理すれば速い・・・は神話!

Last updated at Posted at 2021-05-03

この記事は「シェルスクリプト リファクタリング ~遅いシェルスクリプトが供養されてたので蘇生して256倍に高速化させました~」の続編です。該当の記事ではリファクタリングを行うと共にシェルスクリプトの高速化も行いましたが、その結果としてコマンド置換とパイプがコードから消えました。これらは遅いサブシェルを使っているため、コマンド置換やパイプが消えているのも高速化した理由の一つです。

実はパイプを使ったからと言って必ずしも速くなるわけではなく、場合によっては遅くなることすらあります。パイプを使うとシェルスクリプトは速くなると単純に思っていた人にとっては意外な話ではないでしょうか?ということでこの記事ではパイプラインに焦点を当てて解説したいと思います。

パイプ処理に関する関連記事(2021-12-06 追記)

この記事を書いた時点より更に詳しく調査し、その結果を以下の関連記事まとめています。現時点での結論を一言で言うならば「コマンドをパイプでつなげば並列処理が行われるが、正しくやらなければパフォーマンスは低下する」です。正しい知識を持って計測に基づいてパイプでつながなければパフォーマンスを上げるどころか逆に下げてしまうこともあります。パイプライン並列化を使ってパフォーマンスを上げるのは難しく、コマンドを普通にパイプでつないだだけでは効率的な並列処理は行われません。

シェルスクリプト「シェル芸からの脱出」 〜 コマンドをパイプで長くつなぎすぎた「パイプ地獄」のリファクタリング方法
シェルスクリプトで「パイプライン並列化」をガチでやってみた 〜 パイプ+並列処理でCPUの最大効率を引き出す知識
シェルスクリプトのパイプを使いこなす鍵は、パイプでつなぐコマンド数を5つぐらいまでに抑えること!
シェルスクリプトのデータ出力タイミングが遅い? それはパイプ通信に起因するバッファリングが原因かもという話
パイプを使って高速化したシェルスクリプトを並列実行すると逆に遅くなる謎現象について

★ はオススメ

パイプを使うと速くなるとする主張の根拠

パイプを使うと速くなるという主張の根拠は大きく2つあります。

  1. パイプで速い外部コマンドにデータを渡して処理させるから
    (一般的にシェルスクリプトより外部コマンドの方が速い)
  2. パイプの前後がそれぞれのプロセスで個別に動作するので意図しなくても並列処理が行われるから

この 2 つの根拠は間違ってはいません。しかし隠れたボトルネックが存在しており、そのことを意識せずにパイプを使うと速くならないどころが逆に遅くなります。

パイプを使うと遅くなる?

パイプを使ったほうが速いということを示すベンチマークはいくつもあります。(例 1, 2
それなのにパイプを使うと遅くなるとはどういうことでしょうか?その謎を解く鍵はベンチマークで使ってるデータの量です。

一般的にベンチマークというのは計測の誤差を少なくし、かかった時間の差を明確にするために多くのデータを用いて計測します。例えば何万行ものファイルを用意しておいて cut -d, -f2 | tr -dc 'aeiou' | wc -c のように実行するとかです。しかしこのような計測方法ではサブシェルまたは更に遅い遅い外部コマンドの実行は数回しか行われないため、何万行ものデータ処理にかかる時間で見えなくなってしまいます。

逆に言えばデータの行数が少ない場合はサブシェル(または外部コマンド実行)の影響が相対的に大きくなるということです。ループの中で外部コマンドを呼び出すと極端に遅くなるのも一回のループで扱うデータが少ないからです。何万行ものテキストファイルの置換を行うのであれば sedawk を使ったほうが速くなりますが、ほんの数回必要なだけという場合は遅い外部コマンドを使わずにシェルスクリプトで実装したほうが速くなります。

ただしシェルスクリプトの限界はすぐに来ます。おそらく数百行(数キロバイト)程度を超えれば外部コマンドのほうが速いでしょう。しかしながらそんな行数を処理しないという場合も結構あるはずです。外部コマンドで実装するかシェルスクリプトで実装するかは実際のユースケースで決めなければなりませんし、ベンチマークも実際のユースケースに近い状況で計測しなければ意味がありません。

注意 パイプ通信や外部コマンドそのものが遅いわけではありません。パイプを使うことでサブシェルが生成されので遅くなるということです。(サブシェルの生成だけでなく内部で使用するパイプファイルの生成と削除でも時間がかかってる可能性があります。後日詳細を検証して記事を更新するかもしれません。)

サブシェルが遅い理由

大雑把に言えば子プロセスが生成されているからです。例えば以下のようなパイプを使うコードで数をカウントしても、その後の行で数がカウントされてないという状況になったことはないでしょうか?

cnt=0
seq 10 | while read -r line; do cnt=$((cnt+1)); done
echo "$cnt" # => 0 ※ シェルや設定によっては 10 の場合もあります

# 参考 以下のようにするのが正しい
seq 10 | {
  cnt=0
  while read -r line; do cnt=$((cnt+1)); done
  echo "$cnt" # => 10
}

これはパイプの左右がそれぞれサブシェル(≒子プロセス)で実行されており子プロセスでカウントしても親プロセスには反映されないからです。プロセスの生成はとても重い処理であるためそれを行うサブシェルは外部コマンド実行ほどではないにしろ遅くなります。そのため一部のシェル(ksh)では特定の条件が満たされた時にサブシェルで子プロセスを作らない最適化を行っているほどです。

追記

コメントで read コマンドが seq コマンドの出力を 1 バイトずつ読んでいるので、そのせいで遅いのでは?という指摘があったので追記です。以下のコードには read コマンドは含まれていません。

$ time for i in $(seq 10000); do true; true; done

real    0m0.028s
user    0m0.028s
sys     0m0.000s

$ time for i in $(seq 10000); do true | true; done

real    0m5.791s
user    0m7.009s
sys     0m2.918s

この 2 つのコードはパイプの有無を除いて同じコマンドを同じ数だけ実行しています。違いは後者はパイプを使うためサブシェルが生成されるという点です。結果からも明らかなようにパイプを使う(= サブシェルが生成される)だけでパフォーマンスは大きく低下します。

もう一つ明確に「サブシェルを使った」コードでの例です。

$ time for i in $(seq 10000); do true; done

real    0m0.021s
user    0m0.022s
sys     0m0.000s

$ time for i in $(seq 10000); do (true); done

real    0m5.224s
user    0m4.245s
sys     0m1.889s

() はサブシェルを生成します。ここからもサブシェルが大きなパフォーマンス低下を引き起こすことがはっきりわかります。

パイプを使って処理が早く完了しても速くはなってはいない

パイプを使うと多くの場合、処理が早く(実行時間)終わります。しかしそれは速く(実行速度)なったわけではありません。処理が早く終る理由はパイプでつないだコマンドのそれぞれが並列で動作するからです。マルチコアが一般的になった今では特に CPU がなにもせず休んでいる場合が多いのでパイプを使って並列化し効率的に CPU を使用することで早く処理が終わります。しかしそれでも実行速度が上がったわけではありません。(負荷をかけると Turbo Boost 技術などで CPU の周波数が上がったりするので状況次第では速くなったかのような結果がでることがありますので検証は慎重に行う必要があります。)

処理の並列化はパイプを使う以外にもバックグラウンドプロセスで実行することで行うことができます。例えば CPU コアが 8 個あったとしてバックグラウンドプロセスを 8 個実行すれば CPU コアを使い切って処理を行うことができます。この場合にそれぞれのプロセスの中でパイプを使ったとしても、すでに CPU コアは使い切ってるわけで処理が早く終わることはありません。(実際には I/O 待ちなどの空き時間を活用できる可能性があるので多少は早くなると思いますが)

速くなったわけではないとは言え並列化によって早く終わるのでパイプを使うことに問題はないと思うかもしれませんが、パイプは遅いサブシェルを使うということを忘れてはいけません。並列化の恩恵を得られない状況ではパイプを使うと逆にパフォーマンスが低下します。

パイプを使って効率的に並列化するのは案外難しい

パイプを使って CPU を使い切るのは案外難しいです。例えば CPU コアが 8 個あったとして CPU を使い切るにはコマンドを 8 個もパイプで繋がなければいけません。とりあえずパイプで繋いでおけばだいたい並列化でヨシ!という方針ならそれでもいいんですが、パイプでつなげるコマンドの数はロジックによって決まるので簡単に増やせるものでもありませんし、実行するマシンの CPU 数に応じて並列数を変更するのが難しいのは言うまでもありません。

またパイプで繋いだとして休まずに処理するにはそれぞれのコマンドが同じ速度で処理する必要があります。例えば cmd1 | cmd2 | cmd3 の場合 cmd1cmd2cmd3 は同等の速度でデータを処理しなければいけません。もしどれか一つが遅ければ他のコマンドもそれに引っ張られて処理が停止し CPU は待ち時間が発生してしまうことになります。cmd1 の処理が遅ければ cmd2cmd3 はデータが来るまで待たなければいけないというのはすぐに分かると思いますが、逆に cmd3 が遅くても cmd1cmd2 はそれに引きづられて遅くなります。なぜなら標準出力への出力がブロックされるからです。

それは次のようなシェルスクリプトを実行してみるとわかります。おそらく 68 行までは 0.01 秒毎にログが出力されますが、それ以降は 1秒毎にしか出力されないはずです。

# 0.01 秒ごとに 1 行(1024 文字)出力する
output() {
  for i in $(seq 1000); do
    echo "log $i" >&2
    printf "%1023s\n"
    sleep 0.01
  done
}

# 1 秒ごとに 1 行処理する
input() {
  while read line; do
    sleep 1
  done
}

output | input

たとえ 入力側(パイプの後)は CPU をぶん回してデータを処理していたとしても処理速度が遅いとパイプのバッファ(Linux の場合 64 KB)に出力が溜まっていきます。そしてバッファが埋まってしまうと出力側(パイプの前)の出力はブロックされてバッファが空くまで処理が停止するので CPU を使い切れません。各コマンドが並列で動いているというのは事実だとしても場合によっては並列処理を行っているとは言い難い状況になってしまいます。

詳細な検証

2021-05-23 追記 少しデータに乏しかったので具体的な例を追加しました

もう少し具体的に CPU 使用率がどうなるかを実際のデータを使って説明します。次のコマンドを実行した時の各プロセスの CPU 使用率は果たしてどうなるでしょうか?

seq 100000000 | sed "s/1/2/g" | tr -d '0'

これを実行する前にまず覚えておいてほしいのは seq 100000000 単体では一瞬(1 秒未満)で終わるということです。

$ time seq 100000000 > /dev/null

real    0m0.754s
user    0m0.704s
sys     0m0.051s

ではベンチマークです。

$ time seq 100000000 | sed "s/1/2/g" | tr -d '0' > /dev/null

real    0m35.122s
user    0m34.867s
sys     0m14.630s

上記のコマンドを実行中に他の端末から htop コマンドを実行して CPU 使用率等を表示したときの出力です。上部に表示されてるのは各 CPU コアの CPU 使用率です。(表示からわかるように 8 コアの CPU を使用しています。)

  1  [||                                                   3.2%]   5  [                                                     0.0%]
  2  [|                                                    0.7%]   6  [|||||||||||||||||||||||||||||||||||||||||||||||||||100.0%]
  3  [                                                     0.0%]   7  [                                                     0.0%]
  4  [|||                                                  2.8%]   8  [||||||                                               7.9%]
  Mem[|||                                           95.7M/24.2G]   Tasks: 14, 1 thr; 2 running
  Swp[                                                 0K/7.00G]   Load average: 0.60 0.36 0.26
                                                                   Uptime: 7 days, 00:29:58

  PID USER      PRI  NI  VIRT   RES   SHR S CPU% MEM%   TIME+   STIME+   UTIME+  Command
11945 koichi     20   0  7592   784   664 R 99.8  0.0  0:20.30  0:04.33  0:15.97 sed s/1/2/g
11946 koichi     20   0  6532   520   456 S 20.6  0.0  0:04.10  0:01.59  0:02.51 tr -d 0
11944 koichi     20   0  6524   588   524 S 19.3  0.0  0:03.87  0:01.70  0:02.17 seq 100000000
11938 koichi     20   0  9112  4028  3248 R  0.0  0.0  0:00.29  0:00.16  0:00.13 htop

seqsedtr の中で時間がかかるのは sed であろうと予測すると思いますが htop の出力からも分かる通り sed が 100% (正確には 99.8%) でコアの能力を使い切っています。そして他のコマンドは足を引っ張られて CPU 使用率は 20% ちょっとしか使用できていないことがわかります。また当然ですがパイプで繋いだコマンド(つまり 3 個)より多くは CPU コアを使用しません。

sed が遅いのでその後にある tr コマンドが遅くなるのは当然ですが、前にある seq コマンドも遅くなっています。本来は 1 秒未満で終わる処理なのに、それ以上の時間がかかっています。最終的に seqTIME+ (user and system time) は 6.75 秒ぐらいになりました。これは sed の処理が遅くパイプのバッファがあふれ seq がブロックされているためです。

これらのデータからもパイプを使って効率的に並列するのは難しいということがわかります。

また seqUTIME+ (user mode) が 1秒を大きく超えていることも気になります。なぜかと言うと以下のように単にパイプで他のコマンド(負荷が小さいと思われる tail) に実行すると 1 秒で程度で終わるからです。遅い sed にパイプで渡すとパイプに渡さないよりも seq の負荷が増えているようにみえます。その理由は詳しく調べてないのですが パイプを使うことで余計な CPU を使用している という結論になるのではないかと推測しています。

$ time seq 100000000 | tail > /dev/null

real    0m1.056s
user    0m1.382s
sys     0m0.674s

もう一つ面白い例を一つ。パイプを流れるデータの進捗状況を知るための pv (pipe viewer) というコマンドがあります。このコマンドの機能の一つとして、パイプを流れるデータの転送速度を制限する機能があります。ではここで seq コマンドが出力するデータを秒速 1 文字 に制限したらどうなるでしょうか?

seq 100000000 | pv -q -L 1 | sed "s/1/2/g" | tr -d '0'

  1  [                                                     0.0%]   5  [                                                     0.0%]
  2  [                                                     0.0%]   6  [                                                     0.0%]
  3  [                                                     0.0%]   7  [|                                                    0.7%]
  4  [                                                     0.0%]   8  [                                                     0.0%]
  Mem[|||                                           95.7M/24.2G]   Tasks: 15, 1 thr; 1 running
  Swp[                                                 0K/7.00G]   Load average: 0.00 0.01 0.07
                                                                   Uptime: 7 days, 00:52:28

  PID USER      PRI  NI  VIRT   RES   SHR S CPU% MEM%   TIME+   STIME+   UTIME+  Command
12140 koichi     20   0  9112  4008  3232 R  0.0  0.0  0:00.31  0:00.19  0:00.12 htop
12146 koichi     20   0  6672   824   756 S  0.0  0.0  0:00.04  0:00.03  0:00.01 pv -q -L 1
12147 koichi     20   0  7592   772   656 S  0.0  0.0  0:00.00  0:00.00  0:00.00 sed s/1/2/g
12148 koichi     20   0  6532   524   456 S  0.0  0.0  0:00.00  0:00.00  0:00.00 tr -d 0

結果は簡単に予想できると思いますが CPU は全く使用しません。1 文字ずつデータが転送されるのを待っているわけですから当然ですね。1 秒間に 1文字ではあまり意味がありませんが。例えば秒速 1 MBとかにすれば、パイプを使った処理の(例えば圧縮など)の負荷を適度に調整できるので便利かもしれません。この記事を書いてる途中に面白いコマンドを見つけたので紹介です。Debian 等でもパッケージが提供されているので簡単に使えます。とは言え私はちゃんと使ってないので他の記事にリンクします。(「シェルのパイプの速度(進捗バー) は pv で 」「Linuxのパイプの流れの進捗を見守るには」)

補足 バックグランドプロセスを使った並列処理の代替方法について

余談ですが ShellSpec では並列処理に(パイプも使いますが)バックグラウンドプロセスを使ったテクニックを使用します。これは POSIX 準拠のシェルスクリプトの機能だけを使って実装されています。シェルスクリプトだけで CPU コアをほぼ完全に使い切っているのを見れるのは圧巻です。

  1  [||||||||||||||||||||||||||||||||||||||||||||||||||||91.6%]   5  [||||||||||||||||||||||||||||||||||||||||||||||||||||90.4%]
  2  [||||||||||||||||||||||||||||||||||||||||||||||||||||92.9%]   6  [||||||||||||||||||||||||||||||||||||||||||||||||||||90.5%]
  3  [||||||||||||||||||||||||||||||||||||||||||||||||||| 88.3%]   7  [||||||||||||||||||||||||||||||||||||||||||||||||||||89.5%]
  4  [||||||||||||||||||||||||||||||||||||||||||||||||||||90.0%]   8  [||||||||||||||||||||||||||||||||||||||||||||||||||||90.9%]
  Mem[|||                                            136M/24.2G]   Tasks: 69, 1 thr; 5 running
  Swp[                                                 0K/7.00G]   Load average: 2.66 1.37 0.55
                                                                   Uptime: 7 days, 01:08:36

記事でも書いているように単にパイプを使っただけの並列処理では(都合のいい条件になった場合を除いて)CPU を使いきれませんし、使用するコア数を動的に変更することもできません。シェルスクリプトでまともな並列処理を行いたい場合はバックグラウンドプロセスを使って実装することをお勧めします。もちろん xargs -PGNU parallels を使うのが手っ取り早いですが、シェルスクリプトだけ作ると移植性が上がりますし簡単とまでは言いませんが結構普通に作ることが出来ますよ。(関連記事 POSIX準拠シェルスクリプトでマルチコアの能力を活用する並列処理の実装(最大並列数あり、GNU Parallel, xargsなし)

xargsで速くなるのはパイプを使っているからではない

パイプによる並列処理の効果がまったくないわけではありませんが xargs を使って速度が大きく向上するのは並列処理を行っているからではなく、遅い外部コマンド呼び出しの数が大幅に減るのが理由です。「ソフトウェアの高い互換性と長い持続性を目指すPOSIX中心主義プログラミング」のコードを例にします。

■手続き型コーディング(ステップ数が多く処理効率が低い)
i=3
while [ $i -le 10000 ]; do
  file="file${i}.txt"
  rm -f $file
  i=$((i+3))
done

■ストリーミング型コーディング
awk 'BEGIN{for(i=3;i<=10000;i+=3){print i;}}' |
sed 'sed/.*/file&.txt'                        |
xargs rm -f

この両者のシェルスクリプトをUNIX機[CentOS 7.2,Intel Xeon W5580(4コア3.2GHz),メインメモリ48GB,HDD 1.6TB]で実行したところ,前者の所要時間は2.67秒だった一方,後者は0.05秒であった(両者とも5回平均値).

4.1.2項で述べるように,データ処理においても同様にして分岐やループは最小限に抑え,POSIXコマンドをパイプで繋ぎながら積極的にデータ処理を任せる方針をとると,やはり処理速度が改善する.

この記事では結論として上記のように書いていますが処理速度が改善したのは(この記事で言う)ストリーミング型コーディングをしたからでもパイプで繋いだからでもなく xargs によって rm コマンドの呼び出し回数が減ったからです。

手続き型コーディングでパイプを使わず rm の呼び出し回数だけを減らした以下のコードと比べてみましょう。

# ファイル生成
# touch file{1..10000}.txt

■手続き型コーディング(シェルスクリプト版)
list() {
  i=3
  while [ $i -le 10000 ]; do
    echo "file${i}.txt"
    i=$((i+3))
  done
}
rm -f $(list)

■手続き型コーディング(seq 版) ※seq は POSIX コマンドではありません
rm -f $(seq  --format 'file%.0f.txt' 3 3 10000)

■手続き型コーディング(awk 版) ※POSIX コマンドの例として
rm -f $(awk 'BEGIN{for(i=3;i<=10000;i+=3){print "file" i ".txt";}}')

私の環境では以下の結果となりました。

  1. 手続き型コーディング ・・・ 2.241 秒
  2. ストリーミング型コーディング ・・・ 0.048 秒
  3. 手続き型コーディング(シェルスクリプト版) ・・・ 0.093 秒
  4. 手続き型コーディング(seq 版) ・・・ 0.048 秒
  5. 手続き型コーディング(awk 版) ・・・ 0.049 秒

このように分岐ありループありのPOSIX(外部)コマンドをパイプで繋がない「手続き型コーディング(シェルスクリプト版)」であっても外部コマンドの呼び出しを減らすと実行速度は大きく改善します。シェルスクリプト版が seq 版よりも遅いのは、ファイル一覧が 3333 行もあるのでさすがに外部コマンドのほうが速く生成できるからです。ファイル数が少なければ逆転します。

xargs はシステムが許容する最大の引数の個数をまとめてコマンドを呼び出してくれるというメリットがある素晴らしいコマンドですが、複数の引数をまとめて呼び出すということは、引数がある程度たまるまで xargs はコマンドを呼び出さないということです。つまり xargs は並列処理をほとんど行っていないということになります。(並列処理を行うための xargs -P は別の話です。)xargs で大幅に速くなるのは遅い外部コマンドの呼び出し回数が減るからであってパイプを使うことは直接の理由にはなっていません。リンク先の論文が示しているのは外部コマンドの呼び出しがとても遅いという事実です。

パイプを使ってreadするよりもforの方が速い?

あるコマンドの結果をパイプで受け取ってシェルスクリプトで処理するということは、必然的に while + read を使うということを意味します。そして以下の 3 つの処理を比較した場合パイプ(while + read)を使ったほうが遅くなります。

# pipe.sh
seq 100000 | while IFS= read -r line; do
  echo "$line"
done
# for.sh
var=$(seq 100000)
for line in $var; do
  echo "$line"
done
# loop.sh
i=0
while [ "$i" -lt 100000 ] && i=$((i+1)); do
  echo "$i"
done
pipe.sh for.sh loop.sh
dash 443ms 105 ms 265 ms
bash 1074 ms 360 ms 936 ms
ksh 1228 ms 152 ms 382 ms
mksh 695 ms 174 ms 522 ms
yash 1579 ms 382 ms 952 ms
zsh 1326 ms 23 ms 824 ms

もちろんそれぞれシェルスクリプト内部で行ってる処理が違っているわけで 標準入出力経由でパイプで読み取るといういかにも重そうな処理 をしている方が遅くなるのはある意味当然なのですが、単純にパイプを使ってれば速いという発想でいるとこの罠に陥ります。(zsh の for は速すぎやしませんかね?)

補足ですが pipe.shfor.sh の結果から速い for.sh の方が良いかと言うとそうとは限りません。for.sh の場合はデータ(seq)がすべて揃うまで出力が行われませんが pipe.sh であればすぐに出力が開始されます。レスポンスが良いのは pipe.sh の方です。(ですが今回の事例では同じくレスポンスが良い loop.sh の方が pipe.sh より良いという結果になってますね。)

単純にパイプが遅いかというとそういう話ではなく遅いシェルスクリプトで読み取っているからであって、以下のように外部コマンドで読み取ると速いです。

# cat.sh
seq 100000 | cat
# awk.sh
seq 100000 | awk '{print}'
pipe.sh cat.sh awk.sh
dash 443ms 2.5 ms 17.5 ms
bash 1074 ms 3.0 ms 18.1 ms
ksh 1228 ms 3.2 ms 16.1 ms
mksh 695 ms 2.6 ms 17.7 ms
yash 1579 ms 2.8 ms 17.8 ms
zsh 1326 ms 3.5 ms 18.7 ms

どのシェルも同じ速度(誤差のみ)になるのは、シェルスクリプトがやってるのはコマンドを起動するだけで、あとは外部コマンドによって処理されているからです。つまりシェルスクリプトはほとんど何もしていません。なのでこの結果を持って「シェルスクリプト」は速い(遅くない)という結論にするのは、個人的になんか違うなと思っています。(速いのは C言語で作られた外部コマンドであってシェルスクリプトではないから)

なお繰り返し言っていますが、外部コマンドの処理自体は速くても、外部コマンドの呼び出しは遅いので、大量のデータを処理しない場合はシェルスクリプト自体で処理したほうが速いです。

そして話がここで終わってくれるとまだ楽だったのですが、パイプの左側(seq)をシェルスクリプトで実装すると状況が変わってきます。

# func_pipe.sh
func() {
  i=0
  while [ "$i" -lt 100000 ] && i=$((i+1)); do
    echo "$i"
  done
}

func | while IFS= read -r line; do
  echo "$line"
done
# func_for.sh
func() {
  i=0
  while [ "$i" -lt 100000 ] && i=$((i+1)); do
    echo "$i"
  done
}

for line in $(func); do
  echo "$line"
done
pipe.sh func_pipe.sh func_for.sh
dash 443ms 456ms 768 ms
bash 1074 ms 1163 ms 3638 ms
ksh 1228 ms 1991 ms 531 ms
mksh 695 ms 699 ms 2019 ms
yash 1579 ms 1548 ms 3428 ms
zsh 1326 ms 1381 ms 3394 ms

まず seq コマンドを使った場合(pipe.sh)とシェル関数で実装した場合(func_pipe.sh)では速度が変わりません。これはパイプの右側の処理が遅くパイプのバッファが埋まって seq コマンドの出力がいくら速くてもブロックされてしまっているからです。seq 100000 単体の実行であれば 1.2 ms と極めて高速に処理されます。

この計測結果の理由ですが正直な所なぜ最初の例とは逆の結果が出てるのかよくわかりません。シェルスクリプトは一度に大量のメモリを確保する処理が苦手ということでしょうか?パイプを使ってコードを書けば一行ごとにデータを処理するので一度に大量のメモリを確保する必要はありません。一応説明はつくような気もしますがちゃんと調べられていないので結論は出せません。とりあえず思い込みはやめて計測しましょうということですね。

まとめ

パイプを使うと並列実行されるのは事実であり実際うまく使えば処理を早く終わらせることが可能になります。大量のデータの場合は遅いシェルスクリプトではなくパイプ経由で外部コマンドに渡して処理させたほうが速くなります。しかしパイプを使ったコードのほうが必ずしも速いわけではないので何も考えずにパイプを使ってれば良いというものではなく使い所はちゃんと考えなければいけません。ケースバイケースで判断しなければいけないという身も蓋もない結論です。

関連記事 パイプを使って高速化したシェルスクリプトを並列実行すると逆に遅くなる謎現象について

35
30
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
30