データパイプラインの時間は、以下の3つに費やされます。データの読み取りとデコード、各行に対する処理、そして待機(ストレージ、ロック、またはビジーなスレッドの待機)です。デコードは通常、ライブラリ側でチューニング済みです。各行に対する処理はあなたが書く部分であり、不注意なループは2~3倍のコスト増につながります。
このファイルの大部分は、スキャンのバッチを消費するコードに関するものです。その基盤となるリーダー、ライター、コーデック、および信頼できる数値を取得する方法については、最後の2つのセクションとその参考文献を参照してください。
まず、時間の計測
比較対象となる別の実装(Ray、DuckDB、pyarrow)があることは稀です。代わりに、スキャン自体を基準として使用します。
- スキャン単体の時間を計測する。 同じスキャンを、同じカラム、フィルター、
ScanOptionsで実行し、バッチを破棄します。これがデコードのみのベースラインです。この時間より速いパイプラインはありえません。 - 処理単体の時間を計測する。 バッチごとの呼び出し(バッチを消費する
fold、addなど)をperf_counter_ns()でラップし、時間を合計します。 - 比較する。 1スレッドの場合、合計時間がデコードのみの時間の約1.5倍以内であれば、処理は軽量です。それを超える場合、処理がボトルネックであり、最適化の価値があります。多くのスレッドの場合、処理が1スレッドで実行されている一方で、デコードが多くのスレッドで実行されていないかも確認します。
測定例:7700万件のトリップに対するグループ化が1スレッドで4.6秒かかり、そのうち行単位の処理が2.1秒(45%)でした。8スレッドでは2.6秒かかり、同じ処理が2.1秒(82%)を占めました。これは、1つのスレッドがすべてのバッチを消費したためです。references/profiling.md に手順と落とし穴があります。
行ループのルール
各ルールには、distaシャッフルベンチマーク(7700万行、ローカルテーブル、1スレッド)で節約できた時間を記載します。コードは references/hot-loops.md にあります。
| ルール | 測定効果 |
|---|---|
各カラムの生ポインタをバッチごとに一度取得し、その後はポインタをインデックスする。ループ内で List や Tuple[List, List] をインデックスしない。 | 行単位の処理全体が1.40秒→0.78秒 |
| 1つのキーが蓄積するすべての要素を、並列配列ではなく、1つのスロット(構造体配列)に並べて保持する。これにより、1回の更新で1つのキャッシュラインにアクセスする。 | 次のルールと合わせて、2.09秒→1.40秒 |
分岐なしでフィルタリングする:すべての行を計算し、kept インデックスに書き込み、Int(keep) だけ kept を進める。その後、保持された行を別のタイトなループで追加する。 | 上記ルールと同時に測定 |
キーが小さな整数の場合(ゾーン×ゾーン×時間で170万通り)、Dict の代わりに密な配列をキーでインデックスする。 | 単独では未測定。両方のバージョンで密な配列を使用。 |
| 最適化中に浮動小数点加算の順序を変更しない。そうすれば、答えがビット単位で古いものと一致する必要があり、無料の正確性チェックとなる。 | 上記のすべてのステップでビット単位の一致を確認。 |
パイプライン全体のルール
| ルール | 測定効果 |
|---|---|
| 1つのスレッドが、多くのスレッドでデコードされたものを消費しないようにする。各ワーカーに独自のアキュムレータを持たせ、最後にマージする(distaのマップタスクと同様)。 | 16スレッドで、1プロセスのグループ化がAWS上で202秒から126秒に短縮。 |
デコードスレッドが1つであっても、複数の読み取りを並行して実行する:iceberg.mojo の ScanOptions.files_at_once(および overlap_io)。 | S3からの読み取りで、1スレッドの場合に6.6~6.9倍の高速化。 |
| 共有Cライブラリはプロセスごとに一度ロードし、ハンドルを保持する。決して呼び出しごとにロードしない。 | zstdを呼び出しごとにオープンした場合、スキャン時間の52%が dyld に費やされた。修正後、2687ms→262ms。 |
| バイト単位で繰り返されるパース結果(マニフェスト内のAvroスキーマなど)はキャッシュする。 | マニフェスト計画の固定コストが3.6倍削減(113.7μs→31.9μs)。 |
| レコードごとにアロケートしない。再利用可能なスロットにデコードする。 | Avroリーダーは、カーソル移動前にレコード1件あたり約60回アロケートしていた。 |
| 上限を認識する:数コアを超えるとスキャンはメモリ帯域幅に制限され、それ以上スレッドを増やしても効果はない。代わりに、読み取るバイト数を減らす。 | Icebergスキャンは1コアで35.8ms、4コアで12.1ms(約3倍)。 |
references/case-studies.md にこれらの各事例と、問題の兆候が説明されています。
リーダー、ライター、コーデック
デコード自体が遅い部分である場合、ルールのほとんどは、後で破棄される作業を行わないことに関するものです。詳細と数値は references/readers-and-codecs.md にあります。
- 各高速パスのゲート条件が、そこに到達する呼び出し元によって満たされることを確認し、計装によってそれが実際に実行されていることを証明する。 どのリストカラムも満たせないゲートを持つベクトル化パスにより、1つの9KiBカラムの読み取りが43%の効率に留まりました。
- 後で破棄される可能性が高い計算は行わない。 プレーンエンコーディングを選択するためだけに辞書を構築することは、int64カラム書き込みの67%を占めていました。これを削除することで、バイト単位で同一の出力に対して書き込みが3.3倍高速化しました。
- コピーではなく借用する。 定義レベルを有効性ビットマップに直接デコードするなど、最終レイアウトに直接デコードします。
- データに連続性がある場合は、その連続部分で処理する。 ヌル率1%の場合、約250回の
memmoveで、約25,000回のスロットごとの分岐処理を置き換えられます。 - ステージがすでにメモリ帯域幅に達しているタイミングを認識する。 18.3MBを1.2ms(約30GB/s)で入出力するステージは、スキップする以外に方法はありませんでした。
- スレッドを追加する前に、最も遅い項目を見つける。 カラムチャンクで分割した場合、1つの大きなカラムが常に実行中だったため、読み取りは1.39倍に制限されました。
最適化自体と結果の両方をテストします。意図的に最適化を破壊し、テストが失敗することを確認します(references/testing-optimizations.md)。Mojo固有の落とし穴(最後の使用で値が消滅する MutUntrackedOrigin など)については、references/mojo-hazards.md を参照してください。
信頼できる数値の取得
references/measurement.md に完全なルールがあります。magmalakeで結論を変えたルールは以下の通りです。
- 平均値のみではなく、中央値(p50)とp90を報告する。 1回の遅い最初の呼び出しにより、平均値は30%変動したが、p50はまったく変動しませんでした。
- ベンチマークは事前にビルドし、
mojo runで時間を計測しない。 JITにより、あるベンチマークが1.9倍に膨れ上がりました。 - マシンの状態が一定であることを確認する。 同じベンチマークが、アイドル時は31ms、16のビジースレッドと同時実行時は89msかかりました。
- 参照元のAPIとスレッド数を明記する。
pq.read_tableはparquet-cppの読み取りパスではなくpyarrowのデータセットスキャナーであり、pyarrowのスレッド設定により、あるファイルの読み取り時間が2.9倍変動しました。 - 2つの数値が一致しない場合、違いを説明する前に、それらが同じ方法で生成されたかを確認する。
高速化を主張する前に
- 高速化の前後で結果を比較する。 可能な限り、正確に一致することを確認します(最後のループルールを参照)。間違った高速な答えは、遅くても正しい答えよりも悪いです。
- 両方の数値と設定を報告する。 行数、スレッド数、ローカルディスクかS3か、キャッシュがウォームかどうか。
- 残っている問題を説明する。 シャッフル修正後も、777msのうち325msは依然としてカラムのコピーに費やされており、多くのスレッドの場合、処理は依然として1スレッドで実行されています。