支払いログを要約するスクリプトは、1年間問題なく動作していたが、ファイルが大きすぎるために失敗した。Pythonのイテレータとジェネレータに関するチュートリアルでは、データを一度にすべてメモリに読み込まず、1レコードずつ読み取る処理パイプラインの構築方法を紹介している。
この事例では、スクリプトはファイルを開き、解析済みレコードのリストを構築し、エラーをフィルタリングしてから、テーブルを表示していた。ログローテーションの変更後、1週間分のトラフィックが9GBの単一ファイルにまとめられた。夜間処理は、要約を出力する前にMemoryErrorで停止した。著者によると、解析ロジックに問題はなく、すべてのレコードを同時に保持することを強いるリストが原因だった。
その仕組みは単純な契約に基づいている。リスト、文字列、開いたファイルなどのイテラブルは、イテレータ、つまり要素ごとに進み、決して戻らないカーソルを提供できる。forループはこれらのステップを自動的に呼び出し、シーケンスの終了はStopIteration例外によって通知されるが、これはエラーではない。使い切ったイテレータはリセットされない:2回目の読み取りで「何も返さない」関数は、必ずしも空ではなく、単に既に消費済みである可能性がある。
チュートリアルは、すでにイテレータであるファイルオブジェクトから始めることを推奨している。f.readlines()でファイルを読み取るとすべての行のリストが作成されるが、for line in fでは一度にバッファリングされたブロックしか読み取らない。次に、yieldキーワードを使用するジェネレータ関数により、解析を名前付きのステップに分割できる。yield from文は複数のローテーションファイルを単一のストリームとして連結し、括弧で囲まれたジェネレータ式は、小さな中間変換に役立つ。著者は落とし穴を指摘している:ジェネレータ式の最も左側の部分は即座に評価される。
信頼する前に測定せよ
チュートリアルはまた、リストが密かに復活するポイントについても強調している。list()、sorted()の呼び出し、レコードをリクエストIDでグループ化する辞書、DataFrameの構築は、ストリーム全体をメモリに再導入する。ジェネレータはリストよりも高速というわけでもない:その利点は、メモリ使用量が限定され、ステップを再構成できる点にある。バッチ処理については、Python 3.12ではitertools.batched()が提案されており、以前のバージョンではisliceを使った同等のレシピが示されている。
実際の動作を確認するために、著者はPythonによって割り当てられたメモリを現在値とピーク値で測定するtracemallocモジュールを推奨している。約17MBの合成ログ20万行に対して、「リスト」版はノートパソコンで198MBのピークに達したのに対し、ストリーム版は0.4MBだった。著者は、これは単一実行の結果であり、ベンチマークではないと注記している。また、tracemallocはC拡張の割り当てやシステムキャッシュを認識しないことも思い出させている。
ストリーミングの限界
一部の処理はストリームでは実行できない。グローバルなソート、ファイル全体での正確な重複排除、すべてのレイテンシ値に基づくパーセンタイル計算は、すべてのレコードを知る必要がある。この場合、チュートリアルはサンプルに基づく近似アルゴリズム、またはディスク上でソートする外部ツールについて言及している。
チュートリアルは最後に、このアプローチは純粋なPythonのままであると述べている。Pandas、Spark、Dask、非同期ストリームは関連する問題に対応するが、メモリには収まらないがディスクには収まるログファイル、エクスポート、データダンプには必要ない。
