10億行のデータ処理に学ぶ、Rust/Go 低レイヤ最適化術(@yusuktan)【#も読】のトップ画像

10億行のデータ処理に学ぶ、Rust/Go 低レイヤ最適化術(@yusuktan)【#も読】

投稿日時:
maguroのアイコン

Deno Land Inc. / ソフトウェアエンジニア

maguro

Xアカウントリンク
「あの人も読んでる」略して「も読」。さまざまな寄稿者が最近気になった情報や話題をシェアする企画です。他のテックな人たちがどんな情報を追っているのか、ちょっと覗いてみませんか?

みなさんこんにちは。

「あの人も読んでる」、第18回目の投稿です。maguro (X @yusuktan)がお届けします。

今回は、「One Billion Row Challenge(1BRC:10億行のデータ処理チャレンジ)」を取り上げます。

1BRCは、もともとはJavaコミュニティから始まったプログラミングチャレンジで、「10億行のテキストデータ(気象観測所の名前と温度がセミコロン区切りになっている)から、観測所ごとの最小値・最大値・平均値を計算する」という課題です。具体的には、以下のようなデータが入力として与えられます。

Hamburg;12.0
Bulawayo;8.9
Palembang;38.8
Hamburg;34.2
St. John's;15.2
Cracow;12.6

期待される出力の例は以下のようなフォーマットです。

Hamburg;12.0;23.1;34.2
Bulawayo;8.9;22.1;35.2
Palembang;38.8;39.9;41.0

シンプルな課題ですが、ファイルサイズはおよそ10GB超。素朴に書くと数分以上かかる処理を、いかに数秒〜十数秒まで縮めるかが見どころで、最適化の腕の見せ所がたくさん詰まったお題です。

今回は、このチャレンジにRustで挑むJon Gjengsetさんのライブコーディング動画と、Go言語での最適化ブログ記事をThePrimeagenさんがリアクションする動画を紹介します。同じ問題を別言語で解いていくのを見比べることで、「言語に依存しない最適化の原理」と「言語固有の落とし穴」がきれいに浮かび上がってきます。

Rust で 1BRC に挑む(10時間半のライブコーディング)

まず1本目は、Jon Gjengsetさんが1BRCにRustで挑んだ、約10時間半 [^1] にわたるライブコーディングのアーカイブです。外部クレートの利用は禁止(標準ライブラリのみ)というルールの中で、perfによるプロファイリングと最適化をひたすら繰り返していく、まさに職人芸と忍耐強さの動画になっています。

ちなみに1BRCの公式のルールが Rules and limits にまとめられています。要点をかいつまんでまとめると、以下のようになっています。

  • 外部ライブラリの利用は禁止。lodash、numpy、Boostなど一切不可で、各言語の標準ライブラリのみで実装すること
  • 実装は単一のソースファイルとして提出すること。ライブラリを丸ごとコピペしてくる「裏技」も禁止
  • 計算はアプリケーションの実行時に行うこと。ビルド時にあらかじめデータを処理しておくのは不可
  • 入力値の範囲:
    • 観測所名:非nullなUTF-8文字列、最小1文字・最大100バイト(1バイト文字なら100文字、2バイト文字なら50文字、といった具合)
    • 気温:非nullなdouble値、-99.999.9 の範囲(両端含む)で、常に小数点以下1桁
  • ユニークな観測所名は最大10,000個
  • 特定のデータセットの性質に依存した実装は不可。上記制約を満たす任意の観測所名・任意の分布で動作すること

シングルスレッドでの最適化

最初は素朴に、std::fs::File + BufReader + .lines() で1行ずつ読み、HashMap に集計していくナイーブな実装からスタート。シングルスレッドで実行時間はおよそ 90〜100秒 です。ここから、perf でプロファイルを取りながら順番にボトルネックを潰していきます。

  • 文字列割り当ての削減:観測所名のパースで毎回 String をつくると重いので、&[u8] のまま処理し、UTF-8検証もスキップ(from_utf8_unchecked)。
  • mmap の導入libc クレート [^2]経由で mmap システムコールを直接呼び出してファイル全体をメモリにマップ。madvis システムコール[^3] でシーケンシャル読み込みであることをカーネルに伝え、I/Oを高速化。これだけで32秒まで短縮
  • 数値のカスタムパース:標準の f64 パースが意外と重かったため、温度が常に小数点以下1桁である性質を利用して、整数 (i16) として読み込むカスタムパーサーに置き換える。さらに、マイナス記号の判定で発生する分岐予測ミス(Branch Misprediction)がCPUパイプラインをストールさせていることを perf で見抜き、if を取り除いたブランチレス実装に書き換える
  • 区切り文字検索の高速化:改行や ; を1バイトずつ探すのは遅いので、まずはカリカリに最適化されているlibcのmemchr を導入。さらに nightly の std::simd で64バイトを一度に検索する版にも挑戦

ここまでくると、perf の出力は実行時間の半分以上が HashMap のルックアップとハッシュ計算になります。Rustの標準ライブラリがデフォルトで利用する SipHash は暗号学的安全性を確保するために遅いので、より単純で高速な独自ハッシュ(FxHash風のXORと乗算の組み合わせ)を自作します。が、単純化しすぎるとハッシュ衝突が増えて逆に遅くなり、観測所名をヒープ上ではなくインライン化する ArrayVec 風の構造体をつくってTLBミスを減らす……といった泥臭い最適化が延々と続きます。

マルチスレッド化で1.08秒へ

シングルスレッドが限界に達したところで並行処理を導入します。std::thread::available_parallelism でコア数を取得し、mmap した領域をコア数分に分割。

各スレッドは独立した HashMap で集計し、最後に mpsc::channel 経由でメインスレッドにマージ結果を送ります。これにより、シングルスレッドでは20秒強だった処理が一気に 1.08秒 まで短縮されました。

Javaの最速には及ばなかった理由

ここまでの最適化を施しても、Javaのトップ実装の記録である0.8秒(Gjengsetさんの環境で測定した数値)には届きませんでした。

この記事のつづきを読もう
新規登録/ログインしたらできること
  • すべての記事を制限なく閲覧可能
  • 限定イベントに参加できます
  • GitHub連携でスキルを可視化
ログイン
アカウントをお持ちでない方はこちらから新規登録