第4話. TANREN — 進化したスケジューラを vLLM に差し込んだら、重負荷でテール応答が半減した

本記事は TANREN 連載の第4話です。プロジェクト全体像は こちら、第3話(vLLM の prefix キャッシュ改善)は こちら。今回は同じ実機・同じ方法で、より心臓部に近い部分——リクエストをどの順に処理するか——を進化させます。

結論を先に

本物の vLLM(Llama-3.1-8B・NVIDIA GB10)に、実世界のリクエスト到着列(BurstGPT トレース)を再生して A/B 測定した結果です。進化したスケジューリング方策は、vLLM 既定の FCFS(先着順)に対して次の成績でした。

図① 圧力依存の改善構造 — 混んでいるほど効き、空いていれば何も壊さない

x 軸=負荷の強さ(FCFS の mean TTFT・対数)、y 軸=改善率。負荷がなければ ±0%(悪影響ゼロ)、重負荷では −35〜49%。16 構成で mean TTFT 15 勝(符号検定 p=2.6×10⁻⁴)・p99 13 勝(p=0.011)。

最も重い負荷点(疎トレース × 280 倍圧縮)では、応答時間が半減しました。分布で見ると、既定の FCFS では応答が 20 秒付近で滞留し 50 秒超まで裾を引くのに対し、進化方策はほぼすべてのリクエストを 27 秒以内に返しています。

図③ 最重負荷点の TTFT 分布 — mean 32.0s→16.3s / p99 51.6s→26.9s

最重負荷点(疎トレース × 280 倍圧縮・2 反復 1,600 リクエストずつ)の TTFT 累積分布。mean 32.0s → 16.3s / p99 51.6s → 26.9s の半減(点線=各方策の p99)。

何をやったのか

vLLM は届いたリクエストを基本的に FCFS(先着順)で処理します。混雑すると、長いプロンプトの後ろに短いリクエストが詰まり、待ち行列全体が引きずられます。この「どの順に処理し、あふれたら何を後回しにするか」を、第2話・第3話と同じ方法で進化させました。

  1. 検証器(サービングのシミュレータ)と進化のループで方策を育てる。
  2. シミュレータ上で、進化に一切使っていない別期間のトレースによる最終判定を通す(15/16 勝・p=2.6×10⁻⁴)。
  3. 判定を通った方策だけを、本物の vLLM に 1 ファイルのパッチで組み込み、実機で A/B 測定する。

出来上がった方策は空行を除いて 117 行の Python で、動作の仕組みは読める形をしています。負荷の状態を自動検出し、SJF(短いジョブ優先)にどこまで踏み込むかを適応的に変える——待ち行列が積み上がっているときは短いジョブを優先して詰まりを解消し、空いているときは既定の FCFS と同じ動きを保って何も壊さない、という構造です。

実機テストの全 16 構成

4 つのトレース窓 × 2 段階の負荷 × 2 反復 = 16 ペアの実測です(各ペアで、両方式に完全同一の到着列を再生)。散布図では、対角線より下が「進化方策の勝ち」です。

図② 16ペアの mean TTFT 散布 — 対角線より下=勝ち(15/16)

窓 / 圧縮率 / 反復mean TTFT: FCFS → 進化p99 TTFT: FCFS → 進化
疎 / ×175 / r116.8s → 13.6s(−18.8%27.2s → 23.3s(−14.5%
疎 / ×175 / r217.0s → 13.5s(−20.4%27.6s → 23.1s(−16.2%
疎 / ×280 / r132.0s → 16.2s(−49.3%51.5s → 26.9s(−47.9%
疎 / ×280 / r232.0s → 16.3s(−49.1%51.6s → 27.0s(−47.6%
窓B / ×3 / r10.48s → 0.47s(−1.0%1.20s → 1.14s(−5.0%
窓B / ×3 / r20.49s → 0.48s(−2.1%1.24s → 1.11s(−9.9%
窓B / ×5 / r111.5s → 7.4s(−35.6%29.7s → 22.9s(−22.9%
窓B / ×5 / r211.4s → 7.4s(−35.1%29.4s → 22.8s(−22.5%
窓C / ×75 / r18.9s → 8.3s(−6.1%22.8s → 22.4s(−1.5%
窓C / ×75 / r28.8s → 8.0s(−8.9%22.7s → 21.7s(−4.2%
窓C / ×120 / r120.3s → 11.6s(−42.7%43.2s → 30.5s(−29.3%
窓C / ×120 / r220.6s → 13.2s(−35.9%43.4s → 32.7s(−24.6%
窓A / ×14 / r10.27s → 0.27s(−0.6%0.44s → 0.43s(−0.9%
窓A / ×14 / r20.27s → 0.27s(+0.1%0.45s → 0.45s(+1.6%
窓A / ×22 / r10.79s → 0.78s(−0.3%1.97s → 1.97s(+0.1%
窓A / ×22 / r20.79s → 0.78s(−0.5%1.91s → 1.98s(+3.7%
実機テスト(16 ペア・8B・GB10)結果
mean TTFT15/16 勝・p=2.6×10⁻⁴
p99 TTFT13/16 勝・p=0.011
負荷なしの構成差 ±0%(負けはすべて負荷なし構成での +0.1〜3.7% の微差)= 悪影響ゼロ
重負荷の構成mean / p99 とも −35〜49%
方策のエラー0

負けた箇所がすべて「そもそも待ち行列が発生しない、負荷なし構成での ±数%」である点が、この方策の性格をよく表しています。空いているときは既定と同じに振る舞い、混んだときだけ動きを変える——設計どおりの挙動が実機でも確認できました。

古典との順位表 — 「テール制約違反ゼロで首位」の意味

スケジューリングには古典的な強い手法があります。SJF(短いジョブ優先)系は平均をよく縮めますが、長いジョブを後回しにし続けるためテールを犠牲にする——つまり特定のユーザーが際限なく待たされる——という副作用があります。シミュレータ上の最終判定(進化に使っていないトレース・16 構成合計)で、単一方策としての合計成績を比較しました。

図④ 単一方策順位表 — テール制約違反ゼロで首位は進化方策のみ

方策合計スコア(FCFS=0 基準)テール制約違反
進化方策+1.350
aged-SJF(減衰 0.003)+0.530
aged-SJF(減衰 0.001)+0.240
FCFS(vLLM 既定)0.000
aged-SJF(減衰 0.01〜0.1)−1.1〜−19.61〜7
LAS−41.113
SJF(プロンプト長)−41.414

積極的な SJF 系は一部の構成で大きく稼ぐ一方、テール制約違反を積み重ねて総合ではマイナスに沈みます。進化方策は違反ゼロのまま合計首位(2 位の約 2.5 倍)でした。「平均もテールも壊さずに、詰まりだけを解消する」という狭い通り道を、進化が見つけた形です。

いちばん書きたいのは「6 回失敗してから勝った」こと

この方策は一度で生まれたわけではありません。最初のラン(run1)は訓練窓で圧勝したのに、未知データでは 0 勝 6 敗の全敗でした。そこから「原因分析 → 対策」を 6 周し、各周の失敗がすべて次の設計に反映されています。

  1. 崖のようなペナルティは、際どい個体を育てる。 制約違反ぎりぎりを攻める個体が有利になってしまう → 罰は判定基準より手前から、連続的な勾配で課すように変更。
  2. 絶対秒で書かれた定数は、訓練窓の特性に焼き付く。 → しきい値は相対量・観測量ベースに置き換え。
  3. 検証ゲートを新設。 選抜候補の最高成績が基準に届かないランは「届かず」と正直に記録し、次の段階に進めない。
  4. 訓練窓の多様化は「数」ではなく「勝者の分布」で設計する。 どの負荷状態でどの古典が勝つかがばらけるように窓を選ぶ。
  5. 「構成ごとに、結果を見てから最強の古典を選ぶ」比較は、実際には配備できない基準だった。 実運用に置けるのは単一の方策なので、勝敗は「単一方策の 16 構成合計」で測り直す——この再定義が突破口になりました。
  6. 罰の勾配を倍にして再進化 → 最終判定 15/16 勝。

シミュレータで勝っても実機で崩れるのが普通です(第3話で実際に経験しました)。今回は第3話の教訓——シミュレータの挙動を実機に合わせる・効果が出る動作点を探す・計測を汚す要因(prefix キャッシュ)を遮断する——を先に済ませてから実機に向かったため、シミュレータの予測どおりの結果が実機でも再現されました。

何の役に立つのか

  • LLM サービングの TTFT テール(p99)は、ユーザー体験と SLA の中核です。「混雑時だけ効いて、平常時には悪影響ゼロ」という性質は、そのまま導入判断の材料になります。
  • 方策は空行を除いて 117 行の Python で、なぜその順に処理するのかを読める・監査できる・直せる形をしています。
  • この仕組み(検証器 × 進化 × 診断)は特定分野に依存しません。キャッシュ追い出し・prefix キャッシュに続き、スケジューラで 3 例目になりました。

正直な注記

  • 比較対象は vLLM 既定の FCFS です。構成ごとに結果を見てからチューニングした最強古典と比べると同等域(6 勝 2 分 8 敗・p=0.79)で、「すべての古典に勝った」とは言えません。正確には「テール制約違反ゼロの単一方策として順位表の首位」です。
  • 到着列は BurstGPT の実トレースですが、時間圧縮(speedup)で負荷を作る独自プロトコルです。
  • 実機計測は、スケジューリングの効果だけを分離するため、prefix キャッシュを無効化し、リクエスト固有ヘッダを統制した上で行いました。
  • モデルは 8B・単一 GPU(GB10)です。より大きな構成に同じ結果が出るかは未検証です。
  • コスト(run ログの実測): 進化 run は失敗を含めて全 6 本・計 310 世代・評価した候補コードは約 3,775 本。変異器は gemini-2.5-flash-lite です。金額は請求ベースの実測をしていないため記載しません。

プロトコル(事実チェック用)

  • 実機: NVIDIA GB10 / vLLM / Llama-3.1-8B。4 トレース窓 × 2 負荷 × 2 反復 = 16 ペア、各ペアで両方式に完全同一の到着列を再生。
  • シミュレータの最終判定: 進化に使っていない BurstGPT 別期間トレース・16 構成合計・単一方策での比較。
  • 第1話・第2話のチャンピオンコードと検証器は GitHub で公開中です。

連載の次回はゲーム編に戻ります。素朴な追跡方策の 6.5 倍のスコアを出し、「トンネル戦法」を自力発見した Breakout 編です。プロジェクト全体像に戻る。

この記事をシェア

関連記事