I/Oはもはやボトルネックではない
原文は Ben Hoyt により に公開されました。 このブログを購読する
プログラマーの面接では、テキストファイル内の単語の出現回数を数えるシンプルなプログラムを書いてもらうことがよくある。これはさまざまなスキルを試せる良い問題で、いくつか追加の質問をすることで驚くほど深く掘り下げることができる。
よくする追加質問の一つが、「あなたのプログラムでパフォーマンスのボトルネックはどこですか?」というものだ。たいていの人は「入力ファイルからの読み込み」といった答えを返す。
実は、この記事を書こうと思ったきっかけは、Gopher Slackで誰かの発言に返信したことだった。その人はこう言っていた。「行全体を分割する処理などで余計な作業がたくさん発生していることにも気づいていますが、通常はそれらすべてがI/Oよりもはるかに高速なので気にしません」
別に彼を責めているわけではない — 私もパフォーマンスを分析する前は同じように考えていた。みんなそう教わってきたのだから当然だろう?「I/Oは遅い」と。
もうそうではない!10年や20年前ならディスクI/Oは遅かったかもしれないが、2022年現在、ディスクからファイルをシーケンシャルに読み込むのは非常に高速だ。
どれくらい速いのか?こちらの方法で開発用ノートPCの読み書き速度を計測してみた。ただしcount=4096として、4GBの読み書きを行っている。以下が、Samsung PM9A1 NVMeドライブを搭載しUbuntu 22.04で動作する2022年製Dell XPS 13 Plusでの結果だ:
| I/Oタイプ | 速度 (GB/s) |
|---|---|
| 読み込み(キャッシュなし) | 1.7 |
| 読み込み(キャッシュあり) | 10.8 |
| 書き込み(同期時間込み) | 1.2 |
| 書き込み(同期時間なし) | 1.6 |
システムコールはもちろん比較的遅いが、シーケンシャルに読み書きする場合はバッファサイズに応じて4KBごと、あるいは64KBごとに1回システムコールすれば済む。もちろん、ネットワーク経由のI/Oは今でも遅く、特にローカルでないネットワークでは顕著だ。
では、上記のような単語の出現回数を数えるプログラムで、ボトルネックは何なのか?それは入力の処理や解析と、それに伴うメモリ割り当てだ。入力を単語に分割し、小文字に変換し、ハッシュテーブルで出現回数を数える部分である。
PythonとGoで書いたcount-wordsプログラムを改造し、処理の各段階 — 入力の読み込み、処理(遅い部分)、出現回数順のソート、出力 — にかかった時間を記録するようにした。413MBのテキストファイルで実行しており、入力としては十分な量だ(キング・ジェームズ聖書のテキストを100個連結したもの)。
以下は、3回実行したうちの最速の結果を秒単位で示したものだ:
| ステージ | Python | Go(シンプル版) | Go(最適化版) |
|---|---|---|---|
| 読み込み | 0.384 | 0.499 | 0.154 |
| 処理 | 7.980 | 3.492 | 2.249 |
| ソート | 0.005 | 0.002 | 0.002 |
| 出力 | 0.010 | 0.009 | 0.010 |
| 合計 | 8.386 | 4.000 | 2.414 |
ここではソートと出力にかかる時間は無視できるほど小さい。入力が100個のコピーであるため、ユニークな単語の数が比較的少ないからだ。余談だが、これは面接でのまた別の面白い追加質問になる。候補者の中には、ソートがO(N log N)で入力処理がO(N)だからソートがボトルネックになると言う人もいる。しかし、ここでは2つの異なるNを扱っていることを忘れがちだ。ファイル内の単語の総数と、ユニークな単語の数である。
Python版の中核は、数行のコードに集約される:
content = sys.stdin.read()
counts = collections.Counter(content.lower().split())
most_common = counts.most_common()
for word, count in most_common:
print(word, count)Pythonでは1行ずつ読むことも簡単にできるが、少し遅くなるため、ここではファイル全体をメモリに読み込んで一気に処理している。
シンプルなGo版も同じアプローチを取っているが、Goの標準ライブラリにはcollections.Counterに相当するものがないため、「最頻出」順のソートは自分で実装する必要がある。
最適化したGo版は大幅に高速だが、かなり複雑にもなっている。小文字への変換や単語境界での分割をその場で行うことで、ほとんどのメモリ割り当てを避けている。これはCPUバウンドなコードを最適化する際の良い経験則だ。すなわち、メモリ割り当てを減らすことだ。プロファイリングの方法については、私のcount-words最適化の記事を参照してほしい。
最適化したPython版は示していない。Pythonをこれ以上最適化するのが難しいからだ!(8.4秒から7.5秒まで短縮するのがやっとだった)。今の速度が出ているのは、核となる処理がCのコードで実行されているからだ — だからこそ「Pythonは遅い」ことがそれほど問題にならないことが多いのだ。
見ての通り、シンプルなGo版ではディスクI/Oは実行時間全体のわずか14%しか占めていない。最適化版では読み込みと処理の両方を高速化しており、ディスクI/Oは全体のわずか7%になっている。
結論は?「ビッグデータ」を処理する場合でも、ディスクI/Oはおそらくボトルネックではない。少し計測してみれば、ボトルネックが解析処理やメモリ割り当てにあることがわかるだろう。
記事をランダムに読む
コメント
ログインしてコメントする