はじめに
シェルスクリプトのパイプライン(パイプ)を使いこなす鍵は、大きな処理(時間がかかっているコマンド)を複数のコマンドに分割してパイプでつなげ、逆に小さな処理(時間がかかっていないコマンド)を多数パイプでつなぐのをやめて、一つまたは少数のコマンドにまとめ、パイプラインのコマンドの数を 5 つぐらいまでに抑えることです。
CPU コア数を超えて何個もコマンドをパイプでつなぐとパフォーマンスは低下します。多数のコマンドをパイプでつなぐのはアンチパターンであり、効率よく CPU を使い切るためにはボトルネックを調べつつ慎重にパイプを使わなければいけません。CPU が並列で動作していたとしても処理速度が上がっておらず CPU 使用率が単に高いだけだと本末転倒です。
この記事はシェルスクリプトのパイプライン(パイプ)についての基礎的な考え方や使い方の話をしています。おそらく実際にシェルスクリプトを書いている人にとってはもう少し具体的な説明が欲しいと思うはずです。またこの記事に書いてあることは本当だろうか?と疑問になるはずです。安心してください。詳細な解説記事は近いうちに公開しますしました↓。この記事はその前座に過ぎません。難しく考えずに気軽に読んでください。
2021-12-16 追記
パイプライン並列化をうまく活かそう!
パイプラインを使う理由の一つは並列化です。パイプライン処理はよく工場のライン生産方式(ベルトコンベア式)に例えられます。例えば自動車を組み立てる場合、一箇所で全てを組み立てるのではなくベルトコンベアで運びながら、フレーム、エンジン、シート、ガラス、タイヤ、ドアなどを、それぞれの担当者が部品を取り付けていきます。1 台の自動車を組み立てる時間が短くなるわけではありませんが、何人もの人がそれぞれの担当箇所で同時並行で組み立てていくため短い間隔で自動車が組み上がっていきます。
シェルスクリプトのパイプラインもこれと同じです。一連の処理を複数のコマンドに分けそれぞれの CPU コアで同時に実行することで CPU の総使用時間は変わりませんが実時間が短くなります。
大きな処理(ボトルネック)を見つけ出し分割する
それぞれの部品を組み立る時間は同じではありません。フレームの組み立てには 30 分かかるが、タイヤの取り付けには 10 分しかかからないかもしれません。この時、タイヤの取り付けが終わったからと言ってベルトコンベアを進めるわけには行きません。フレームの組み立てが終わっていないからです。ベルトコンベアのスピードは一番遅い部分(ボトルネック)で決まり、ボトルネックにかかる時間より短い間隔で生産できることはありません。そのため改善するにはボトルネックを見つけ出し、組み立て速度を速くするか、組み立て内容を分割します。分割すると別の人が同時並行で組み立てることができるようになります。
シェルスクリプトのパイプラインもこれと同じです。一番時間がかかっているボトルネックとなっているコマンドを速くするか、複数のコマンドに分け別の使用されていない CPU コアを使って並列に実行することで実時間を短くすることができます。
小さな処理をさらに分割してもパフォーマンスは上がらない
タイヤの取り付けには 10 分しかかからないとします。これを前後左右のタイヤ、4 つの工程に分けて別の人が担当できるようにした所で自動車が出来上がる間隔は短くなりません。タイヤの取り付け時間は短くなりますが待ち時間が増えるだけです。またベルトコンベアの長さを長くしなければいけなくなって無駄に移動距離が増えるという問題がでてきます。
シェルスクリプトのパイプラインもこれと同じです。ボトルネックになっていない小さなコマンドを更に分割した所で実時間は短くなりません。逆に無駄なパイプ間通信とコマンドの処理で CPU 時間が増えるだけです。小さな処理を行うコマンドを多数パイプでつなげるよりも一つのコマンドにまとめたほうが CPU 使用時間を減らすことが出来ます。tr
コマンドは sed
コマンドに置き換えることが出来ます。複数の sed
コマンドは一つの sed
コマンドに、複数の grep
コマンドは一つの grep
コマンドに、まとめることが出来ます。そして awk
コマンドを使えば、tr
、sed
、grep
コマンドを一つにまとめることが出来ます。小さな処理は一つのコマンドにまとめましょう。小さなコマンドをたくさんパイプでつないでもメリットはありません。
小さく分割しても CPU が余っていなければ速くならない
自動車の組み立てでいくら多数の工程に分けたとしても、それだけの人がいなければ、同じ人があちこちに移動して作業することになり大変なだけです。工場ならば雇う人を増やすことはできるでしょうが、搭載可能な CPU のコアの数はそれほど多くはありません。CPU コアの数よりも多くのコマンドをつないでもほとんど効果はありません。パイプ間通信と無駄になるフィルタ処理で逆に遅くなるだけです。
パイプでつなぐコマンドの数は目安であってルールではない
パイプでつなぐコマンドの数をやたら増やしてもパフォーマンス上のメリットはありません。特に小さな処理だけしか行わないコマンドを多数つなぐとコードの可読性は下がり、CPU 使用時間は増え、無駄な処理で実時間まで増えてしまいます。
ボトルネックとなる大きな処理を分割し、小さな処理はまとめるのがシェルスクリプトのパイプを使いこなす鍵です。パイプでつなぐコマンドの数の 5 つというのは、これらの方針に従えば 5 つぐらいにおさまるだろうし、それぐらいまでしかパイプライン並列化でパフォーマンスを上げることは難しいという話です。適切な理由があればこれより多く分けても構いません。
パイプライン並列化の限界を知ろう!
シェルスクリプトのパイプライン並列化というのは、うまく使えば CPU コアを有効活用できます。ただしこの「うまく使えば」というのがすごく難しいです。まずパイプラインでつなぐそれぞれのコマンドの処理時間はほぼ等しくなければいけません。等しくない場合 CPU を使い切ることは出来ません。またボトルネックを分割するのは、まさに言うは易く行うは難しです。そう都合よく分けられたりしません。またコマンドというのは実装によって速度が全然違います。GNU 版 と BSD 版、gawk と mawk、実装によって速度が違うものを使って処理速度を等しくするなんて不可能です。
パイプライン処理を工場の**ライン生産方式(ベルトコンベア式)に例えたのは、説明をわかりやすくするための他にもう一つ理由があります。それは工場の生産方式には、ライン生産方式の他にセル生産方式(一人屋台生産方式とも言われる)**というものがあるからです。工場でも効率を上げる生産方式は一つではありません。
簡単に説明するとライン生産方式というのは、一つのラインを複数の人がそれぞれが担当する部品を取り付けることで一つの製品を作るものですが、それに対してセル生産方式というのは一人(または少人数)で一つの製品を組み立てることです。セル生産方式は一人で組み立てるから時間がかかるのではないかと思うかもしれませんが、何人もの人がそれぞれで組み立てるので並列で生産することができます。さらにライン生産方式ではボトルネックに注意する必要がありましたが、セル生産方式では各自のペースで作業ができるのでその必要がありません。
ライン生産方式がパイプライン並列化だとすると、セル生産方式は xargs -P
や GNU Parallel を使ったデータ並列化です。データ並列化は並列度を容易に変更することができるため、パイプライン並列化よりも柔軟で効率的に並列処理を行うことが出来ます。そしてデータ並列化が行える場合には、実はパイプライン並列化にはそれほど大きなメリットはありません。
多数のコマンドをパイプでつなぐと性能は低下し可読性とメンテナンス性は悪くなりテストもデバッグもしづらくなる
10 個や 20 個といった多数のコマンドをパイプでつなぐということは、元のデータを 10 回も 20 回も変化させるということです。当然変換処理で性能は落ちますし、そんな何十回もデータを変化させるようなコードは書いた人以外が読んでも分かりません。頭の中でデータの変化をシミュレートしてどうなるかを想像するなんて不可能です。多くのコメントでコードの動きを説明しなけれいけなくなったらそれは警告のサインです。tee
コマンドなどを使って途中のデータをファイルなどに出力して見る事はできますが、それが意図したとおりのデータなのかはデータを見ただけでは判断できません。
使い捨てのコードであれば別にいくらパイプでコマンドをつないでも構いませんが、長い期間使用され続け、書いた人と別の人が修正するかもしれないようなシェルスクリプトでは可読性とメンテナンス性とテスタビリティが重要です。メンテナンス性が悪いシェルスクリプトというのは、多数のコマンドをパイプで延長し続けるように無計画に作られた、いきあたりばったりで構造のないコードです。そういった方法で作られたコードは無駄が多く性能も低くバグも多くなります。
さいごに
パイプライン並列化の効果があるのは、つなぐコマンドの数は CPU のコア数程度までです。またそれぞれのコマンドの処理速度はなるべく同じにしなければいけません。そんな条件下で効率よく CPU を使い切るのは不可能なので結局はデータ並列化を使うことになります。
小さな処理しかしないコマンドをパイプで多数つないでもメリットはなくあるのはデメリットだけです。コードは読みづらくなりメンテナンス性も低くテストもしづらくなります。特に何十個もの大量のコマンドをパイプでつなぐのは完全なアンチパターンです。
シェルスクリプトのパイプを使いこなす鍵は、実はパイプを使いすぎないことです。