はじめに: レコメンデーションシステムの隠れたCPU消費

Netflixのホーム画面に表示されるパーソナライズされたレコメンデーション行。実はかなり重い計算の上で動作しています。特に「Serendipity Scoring」という機能がCPUの7.5%を消費していました。この機能は「このコンテンツが視聴履歴とどれだけ異なるか」を測定するだけのロジックです。

本記事は、単純なバッチ最適化のアイデアから始まり、メモリレイアウトの再設計、BLAS導入の失敗、そして最終的にJDK Vector APIでSIMDを活用するまでの実践的な最適化の軌跡です。国内のSI/プラットフォーム環境でも同様のCPUボトルネックに直面している方に特に参考になるでしょう。

参考: 本記事はNetflix Tech Blog原文を実務視点で再構成したものです。

Netflix server rack with high CPU utilization from recommendation scoring IT Technology Image

問題: M×N個の内積演算が生んだホットスポット

Serendipity Scoringの元のコードは直感的ですが非効率でした。

// 各候補動画について視聴履歴全体を走査しコサイン類似度を計算
for (Video candidate : candidates) {
    Vector c = embedding(candidate); // D次元の埋め込み
    double maxSim = -1.0;
    for (Video h : history) {
        Vector v = embedding(h);
        double sim = cosine(c, v);  // dot(c, v) / (||c|| * ||v||)
        maxSim = Math.max(maxSim, sim);
    }
    double serendipity = 1.0 - maxSim;
    emitFeature(candidate, serendipity);
}

このコードはM(候補数)×N(視聴履歴数)個の個別内積演算を実行します。一つ一つは軽量でも、RankerサービスのスケールではCPUプロファイルの上位に明確に現れるホットスポットでした。プロファイリングの結果、**この単一フィーチャーが全ノードCPUの7.5%**を消費していることが判明しました。

興味深いトラフィックパターン

分析すると面白い事実が明らかになりました:

  • リクエストの98%は単一動画処理でしたが、
  • 残り2%の大容量バッチリクエストが処理する全動画ボリュームは50:50に達していました。

つまり、バッチ最適化が中央値(median)リクエストには役立たなくても、システム全体の効率には大きな影響を与え得る構造でした。

Data flow diagram of vector embedding similarity computation for recommendation Programming Illustration

最適化の旅: 4段階にわたる改善

Step 1: バッチ処理 - ネストループを行列乗算へ

最初のアイデアは「小さな内積を多数」から「一つの行列積」への変換でした。

// 候補埋め込みをM×D行列A、履歴埋め込みをN×D行列Bとして構成
// 正規化後、C = A × B^T を一度に計算

double[][] A = new double[M][D];
double[][] B = new double[N][D];
// ... 埋め込みを格納
normalizeRows(A);
normalizeRows(B);
double[][] C = matmul(A, B);  // C[i][j] = cosine(candidates[i], history[j])

for (int i = 0; i < M; i++) {
    double maxSim = max(C[i][0..N-1]);
    emitFeature(candidates[i], 1.0 - maxSim);
}

数学的には完璧でしたが、実際のカナリアテストではむしろ5%のパフォーマンス低下を経験しました。理由は?

Step 2: バッチが逆効果になった理由

  • double[][]行列を毎バッチ新規割り当て → GCプレッシャー増加
  • double[][]はメモリが非連続(non-contiguous) → キャッシュミス増加
  • 純Javaの行列乗算はSIMDを活用できない

教訓: アルゴリズム改善だけでは不十分。メモリレイアウトと割り当て戦略が伴わなければならない。

Step 3: Flat Buffer + ThreadLocalでGCを排除

// double[][] の代わりに1次元flat配列を使用(行優先)
class BufferHolder {
    double[] candidatesFlat = new double[0];
    double[] historyFlat = new double[0];
    
    double[] getCandidatesFlat(int required) {
        if (candidatesFlat.length < required) {
            candidatesFlat = new double[required];
        }
        return candidatesFlat;
    }
    // ... historyも同様
}

// ThreadLocalでスレッドごとに再利用
private static final ThreadLocal<BufferHolder> threadBuffers =
    ThreadLocal.withInitial(BufferHolder::new);

変更点:

  • double[M][N]double[M*N] 1次元配列: 連続メモリ、ポインタチェイシング排除
  • ThreadLocalでバッファ再利用: リクエストごとの割り当てゼロ、GC負荷軽減
  • バッファは必要なときだけ拡大し、縮小はしない

Step 4: BLAS失敗談 - 理論と現実のギャップ

BLAS(Basic Linear Algebra Subprograms)は線形代数演算の標準ライブラリです。マイクロベンチマークでは素晴らしかったものの、実際の統合後は期待ほどの性能向上は見られませんでした。

  • netlib-javaのデフォルトパスはF2J(Fortran-to-Java) BLASを使用 → ネイティブではない
  • ネイティブBLASを付けてもJNI遷移コストが発生
  • Javaの**行優先(row-major)レイアウトとBLASの列優先(column-major)**の不一致 → 変換/コピーオーバーヘッド
  • TensorFlow埋め込み処理と並行実行時に追加割り当てがパイプライン全体に悪影響

結論: 純JavaでSIMDを活用する方法が必要だった。

Step 5: JDK Vector API - 純Java SIMDの救世主

JDK Vector APIはインキュベート機能で、JITがホストCPUのSIMD命令(SSE/AVX2/AVX-512)に自動マッピングするポータブルなデータ並列演算APIです。核心は純Javaである点 — JNIなし、ネイティブ依存なし。

// Vector APIを使用した内積演算(AVX2で4 doubles、AVX-512で8 doubles)
for (int i = 0; i < M; i++) {
    for (int j = 0; j < N; j++) {
        DoubleVector acc = DoubleVector.zero(SPECIES);
        int k = 0;
        // SPECIES.length()ずつ一度に処理
        for (; k + SPECIES.length() <= D; k += SPECIES.length()) {
            DoubleVector a = DoubleVector.fromArray(SPECIES, candidatesFlat, i*D + k);
            DoubleVector b = DoubleVector.fromArray(SPECIES, historyFlat, j*D + k);
            acc = a.fma(b, acc);  // fused multiply-add
        }
        double dot = acc.reduceLanes(VectorOperators.ADD);
        // 残りのtail処理
        similaritiesFlat[i*N + j] = dot;
    }
}

Fallback設計: Vector APIはまだインキュベート中のため、--add-modules=jdk.incubator.vectorフラグがない場合は自動的に最適化されたスカラーパス(ループアンロール+Luceneパターン)にフォールバックします。サービスの安定性を損なわずに最大性能を選択できる構造です。

Cloud infrastructure diagram showing cluster footprint reduction after optimization Dev Environment Setup

結果: CPU 7.5% → 1%、そして実務への教訓

実プロダクションのカナリアテスト結果:

  • CPU使用率7%削減
  • 平均レイテンシ12%削減
  • CPU/RPS(リクエストあたりCPU)10%改善 — 同一トラフィックを10%少ないCPUで処理
  • Serendipity ScoringフィーチャーのCPU占有率: 7.5% → 1%

日本開発エコシステムにおける適用コンテキスト

国内環境でこの事例が与える教訓は明確です:

  1. Java 17+導入時にVector APIを検討: 特にレコメンデーションシステム、検索エンジン、金融リスク計算など大量の数値演算があるサービスで効果的です。
  2. GC最適化はアルゴリズムと同様に重要: double[][]の代わりにflat buffer + ThreadLocalパターンはどこでも適用可能な基本スキルです。
  3. ベンチマークと実プロダクションは異なる: BLASがマイクロベンチマークでは輝いたものの、実際のJNI遷移コストとメモリレイアウトの不一致で効果が出ませんでした。

この技術の限界および注意点

  • Vector APIはまだインキュベート: JDK 21でも--add-modulesフラグが必要で、プロダクション適用にはfallback設計が必須です。
  • AVX-512対応CPUが必要: 最大性能を発揮するには最新のIntel/AMD CPUが必要です。旧型サーバーでは効果が限定的な可能性があります。
  • すべてのワークロードに適しているわけではない: I/Oバウンドな処理や複雑なオブジェクトグラフを扱うサービスでは効果が薄いです。

次のステップ学習方向

本記事で学んだ内容を実際のプロジェクトに適用してみたい方へ:

  1. JDK Vector API公式ドキュメントとJEP 426, 438, 460を確認してください。
  2. JMH(Java Microbenchmark Harness) で自身のワークロードに合ったベンチマークを作成してみてください。
  3. Project Panama(FFI) との組み合わせも探求すると、より広い最適化の可能性が開けます。

合わせて読みたい記事

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。