スループットとレイテンシの両方を同時に下げるのは、なぜ難しいのか
LLM 推論の現場で、ほぼ全員が下のような場面に遭遇したことがあります。ビジネス側から「最初の1トークンの応答が遅い」と文句を言われる。そこで同時実行数を上げて全体を速くしようとしたところ、最初の1トークンの応答がさらに遅くなった。これはパラメータ調整の腕の問題ではありません。推論システムでは、スループットとレイテンシが本質的にせめぎ合っているからです。GPU が 1 秒間に生み出せるトークンの総量はほぼ保存則であり、それを特定のリクエストへすべて注ぎ込めば、そのリクエスト単体のデコード速度は必ず落ちます。
本当の問題は「スループットを取るべきか否か」ではなく、レイテンシを約束どおり守る前提で、スループットを最大化することにある。スループットはトークン単位のコストを直接左右し、レイテンシはユーザが離脱するかどうかを直接左右するからです。
この 2 つを同時に押さえつけるのは、単一の「銀弹」的な最適化ではなく、互いに噛み合うスケジューリング機構に支えられています。本稿はその順番で分解していきます。まず静的バッチ処理がどこに無駄があるのか、次に Continuous Batching が何を代価と引き換えに何を得たのか、そしてメモリのページング、Prefix Cache、speculative decoding、プリエンプションと公平性、最後に本番環境で効くチューニング順序に収束させます。
どの機構についても利得と境界を同時に述べていきます。どの最適化も、あるワークロードではかならず牙を剥くからです。先に、本稿を貫く 2 つの主要指標を決めておきます。1 リクエストのエンドツーエンドレイテンシは、おおよそ次のように分解できます。総レイテンシ ≈ TTFT + (出力トークン数 − 1) × TPOT
TTFT(Time To First Token、最初トークンまでの遅延)は、主に待ち行列時間と prefill の計算で決まります。TPOT(Time Per Output Token、出力トークン 1 個あたりの間隔)は、ほぼ 1 ステップのデコード時間と比例します。この 2 つを混ざって見てしまうと、アーキテクチャ上のあらゆる判断が誤った結論に到達してしまいます。これは後段で 1 節を割り当てて、詳しく扱う課題です。
静的バッチ処理:なぜ最初から無駄になる
長いリクエストに拘束されたバッチ
静的バッチ処理の流れは非常に素朴です。batch_size 個のリクエストが揃うまで待って、まとめて一度 prefill を実行し、続いて自己回帰的にデコードを進めて、バッチ内でもっとも長いシーケンスが終わるまで続けます。残った短いリクエストは計算済みの結果を返し、残った padding 位置はそのまま捨てます。
問題は最後のステップにあります。1 バッチの中に 30 個の 20 トークン出力と、1 個の 500 トークン出力が混ざっているとします。するとその 30 個は 480 ステップぶん一緒に回ることになり、この 480 ステップの間、彼らは無意味な計算をしています。GPU の算力が空気に消えます。無駄率は (500 − 20) / 500 = 96% に近い。これは「短いリクエストが長いリクエストに引ずられる」の正確な意味です。
それと並ぶ第 2 の無駄が padding です。静的バッチ処理はバッチ内の最長シーケンスに合わせて KV Cache を確保しなければならず、もっとも短いリクエストが領域を占有しながらほとんど使わないため、有効な同時実行数が最長のリクエストに一方的に決められてしまいます。
算力利用率が低いのは表の現象、メモリウォールが根因
推論時の GPU 利用率が 40% 程度しかないと見かけた meisten人は、最初に「挿有でっていない」と考えます。しかし自己回帰デコードにおいては、低い利用率そのものが仕様です。トークンを 1 つ生成するたびに全モデルの重みを HBM から 1 遍読み直す必要があり、マトリクス乗算はその規模でもまだかなりルーフラインに達していない。したがってこれはメモリ律速(memory-bound)であって、計算律速(compute-bound)ではありません。
本当に同時実行数の上限を縛っているのは算力ではなく、メモリです。先にモデルの重みを載せ、その残りを KV Cache と活性化値に充てます。そして KV Cache が 1 バッチに何本のシーケンスを載せられるか、1 本あたりどれくらいの長さを持てるかを決めます。KV Cache の規模はおおよそ次のようになります。KV バイト数 ≈ 2 × 層数 × KV ヘッド数 × ヘッド次元 × シーケンス長 × 要素あたりのバイト数
係数の 2 は K と V の 2 種類ぶんという意味です。60 層、KV ヘッド 48 個、ヘッド次元 128 のモデルを例にすると、FP16 では 1 トークンあたり 2 × 60 × 48 × 128 × 2 ≈ 1.47 MB になります。8K のシーケンスは KV だけで約 12 GB、16K なら 24 GB です。80 GB のカードに 14B の高密度モデルを 1 つ載せても、KV に回せる領域は同時に走れる長いシーケンスを数本しか収まりません。
この式は、以降のほぼすべてのエンジニアリング選択を説明します。ページングは KV の無駄を減らすため、Prefix Cache は確保済みの KV に再利用の価値を与えるため、KV 量子化は式の中の「要素あたりのバイト数」を直接半分にするため、MLA は「KV ヘッド数」の項に手を入れるため。すべての最適化が最終的に向かうのは同じことです。同じ量のメモリにより多くの「使える」KV を詰め込むことです。
過大評価されがちな静的バッチ処理の利点
静的バッチ処理の唯一の利点は、バッチ内の形状が揃っていることです。シーケンス長が均一で、カーネルパラメータが固定され、CUDA Graph が捕まえやすく、Tensor Core の利用率が高くなります。すべてを揃えているからこそ、すべてを無駄にします。連続バッチ処理の中核アイデアは、この澄澄したさを捨てる代わりに、「あらゆるシーケンスが任意の時刻に任意のステップで出入りできる」ことを取りに入れる点です。下の表は無駄の発生源を分解したものです。以降の各最適化は、必ずこの中の 1 つに対応します。
| 無駄の発生源 | 具体的な表れ | 対応する最適化手法 |
|---|---|---|
| バッチ内の長さの不揃い | 短いシーケンスが長いシーケンスに引きずられて空回りする | Continuous Batching |
| padding の占有 | 最長シーケンスに合わせて KV を割り当て、メモリを最悪ケースで予約する | PagedAttention のページング |
| 重複計算 | 同じ prefix が各リクエストで prefill しなおされる | Prefix Cache |
| 1 ステップの粒度が粗すぎる | 長い prefill が一度に数千トークンを計算し、すべての decode をブロックする | Chunked Prefill |
| 直列ステップ数 | 下書きを直列に N ステップ生成し、N 倍のカーネル起動コストがかかる | Speculative Decoding |
| テナント同士の衝突 | 大きな顧客がバッチを埋め尽くし、小さな顧客が全員待ち行列に並ぶ | プリエンプションと公平性スケジューリング |
Continuous Batching:スケジューリングの粒度をトークン級まで下げる
何をしているのか
Continuous Batching(in-flight batching、iteration-level scheduling とも呼ばれます)が行う変更は 1 つだけです。スケジューラが毎ラウンド、誰を走らせるかをあらためて決める。各ステップの開始時に、終わったシーケンスはすぐ抜けていき、待っていた新しいリクエストが空いた場所にすぐ入り、prefill と decode が同じステップの中で混ざり合って進みます。
核心的な利点は、「占有時間」が「シーケンス長」から「それぞれの実際の長さ」に変わったことです。先ほどの 30×20 + 1×500 の例の Continuous Batching では、30 個の短いリクエストは自分の第 20 ステップで終わります。長いリクエストだけが走り続けます。無駄はほぼ消えます。
もちろん厳密に言うと、バッチウィンドウは動的な集合です。running = { s : s 未结束 }。各ステップで running 内のすべてのシーケンスから、トークンを 1 個(decode)あるいはトークンの区間を 1 つ(prefill)取り出してから、集合を更新します。利益と代価は、この「毎ステップ集合を作り直す」から生じます。
実装コストのチェックリスト
「毎ステップで集合を作り直す」という文章をコードに翻訳すると、想像以上に很多东西が要ります。
1. バッチ内の長さが揃わなくなり、まず attention の経路が壊れる。静的バッチ処理では、すべてのシーケンスが同じオフセットから連続メモリを読めます。連続バッチ処理ではシーケンスごとに長さが異なり、KV Cache は物理的に散在します。したがって attention は「間接索引テーブルを 1 枚使って gather する」ことをサポートしなければなりません。これがページング attention が存在する直接の理由です。これはメモリの最適化ではなく、連続バッチ処理を GPU 上で走らせるための前提条件です。両者は同時にやる必要があります。
2. Python スケジューラがボトルネックになる。初期の実装では、各ステップごとに Python のスケジューリングロジックを回し、テンソルのスライス、索引の更新、サンプリングをまとめて実行していました。数百の同時実行数では、この CPU 時間が GPU の計算時間を超えることがあり、しかも GIL のためにマルチスレッドでも救えません。vLLM V1 エンジンはスケジューラを C++ へ降ろし、リクエストの状態と KV ブロック表を同じ側に置き、言語境界をまたぐ転送を減らしました。これが vLLM が高同時実行数を耐えられる主要なエンジニアリング変更の 1 つです。
3. CUDA Graph と動的なバッチ形状が衝突する。CUDA Graph はキャプチャ時の形状が固定であることを要求しますが、連続バッチ処理のバッチサイズはステップごとに変わります。定石となる解決策は、バッチサイズを一組(1、2、4、8、16、32、64、128、256 など)の「バケット」に量子化し、実際のバッチを padding で次のバケットまで埋めてからキャプチャすることです。バケットが細かすぎるとメモリ使用量が増え、粗すぎると padding の無駄が戻ります。これはメモリとレイテンシの両立という、もう 1 箇所のトレードオフです。
4. サンプリングと同期コスト。ステップごとにカーネル起動、同期、サンプリング、開ループでの結果取得という処理が必要ですが、ステップ時間が数ミリ秒の 小さい場合には安くはありません。非同期スケジューリング、サンプラの融合、.cpu() 同期を避けることが、この層での定石的な最適化です。
最小限のスケジューリングループ
次のコードはどの本番エンジンにも実装されていませんが、連続バッチ処理のスケジューリングのセマンティクスを明確に書き表しています。各ラウンドでバッチのメンバーを組み直す必要があり、そして prefill と decode が同じステップに混ざっていることに注目してください。
class Engine:
def __init__(self, kv_manager, step_token_budget=8192):
self.waiting = deque() # 排队中的请求
self.running = [] # 本轮要跑的序列(可能含 prefill 片段)
self.kv = kv_manager
self.budget = step_token_budget
def step(self):
batch = []
# 1) 先给已在跑的序列分配额度,保证它们不会饿死
# decode 每条序列本轮只消耗 1 个 token 的预算
decode_seqs = [s for s in self.running if not s.in_prefill]
prefill_seqs = [s for s in self.running if s.in_prefill]
spent = len(decode_seqs)
# 2) 按预算把长 prefill 切块(chunked prefill)
for s in prefill_seqs:
room = self.budget - spent
if room <= 0:
break
chunk = min(s.remaining_prompt, self.chunk_size, room)
batch.append((s, chunk, is_prefill=True))
spent += chunk
# 3) 有空位就准入新请求,新请求只做第一段 prefill
while self.waiting and spent < self.budget:
s = self.waiting[0]
room = self.budget - spent
chunk = min(len(s.prompt_ids), self.chunk_size, room)
if chunk < self.min_prefill_chunk:
break # 碎片太小,等下一轮
self.waiting.popleft()
s.in_prefill = True
batch.append((s, chunk, is_prefill=True))
spent += chunk
# 4) 跑 forward:一次混合了 prefill 片段和 decode 步的 batch
logits = self.model.forward(batch, self.kv)
# 5) 采样后立刻更新集合:这就是 continuous 的含义
self.running = []
for s in batch:
tok = sample(logits[s])
s.append(tok)
if s.finished:
self.kv.free(s.block_ids) # 立刻释放,可被下个请求复用
elif s.in_prefill and s.prompt_done:
s.in_prefill = False
self.running.append(s)
else:
self.running.append(s)
実際のエンジンはこの骨格に、プリエンプション、優先度、ブロック表の保守、prefix の探索、マルチモーダル入力などを補う必要がありますが、セマンティクスは完全に一致します。第 5 ステップの「すぐに解放、すぐに受け入れ」が、すべての利益の源であると同時に、すべての複雑さの源でもあります。
Chunked Prefill:長い prompt でエンジン全体を止めない
連続バッチ処理が解けていなかった問題
連続バッチ処理は「短いリクエストが長いリクエストに引ずられる」ことは解決しましたが、「長いリクエストが短いリクエストを引ずられる」ことは解決しませんでした。100K トークンの prompt を連続バッチ処理に載せた場合でも、初回 prefill は 1 ステップの中で 100K トークンを計算し終えなければなりません(prefill 段階には自己回帰的な依存がないので、区間全体を並列に計算できます)。この 1 ステップは数百ミリ秒から数秒かかることがあり、その間すべての decode リクエストを進められず、TPOT に明瞭なスパイクが現れます。
結果として、エンジンは全体のスループットがそこそこ良く見えても、各リクエストの出力はきこちない出力になります。TTFT も必ずしも改善するわけではありません。新しいリクエストは,その大きな prefill ステップが終わるのを待ち行列で待たなければならないからです。これが prefill と decode の間のヘッド・オブ・ラインブロッキングです。
チャンク分割と、その大きさの決め方
Chunked Prefill のやり方は、prefill するシーケンスを固定トークン数のチャンクに切り、1 ステップでは 1 チャンクだけ処理し、他のリクエストの decode ステップと混ぜ合わせるというものです。prefill リクエスト自体は複数のステップをまたいで完了し、その KV はチャンク単位に書き込まれ、最後のチャンクで初めてトークンが生み出されます。チャンクサイズのトレードオフは双方向です。
- チャンクが大きすぎる場合:1 ステップの算力がより飽和し、prefill 全体の時間は短くなりますが、1 ステップが長くなり、decode リクエストの TPOT スパイクがより目立ちます。
- チャンクが小さすぎる場合:TPOT は滑らかになりますが、各ステップのバッチが小さくなり、GPU のメモリアクセス効率が落ちます(メモリ律速の場面では、バッチが小さいことは致命的です)。prefill 自体のスループットも落ちます。
そのため典型的な設定は中間値になり、「1 ステップのトークン予算」と「1 つの prefill チャンクのトークン数」を分けて設定します。前者は max_num_batched_tokens のような総予算で、後者はチャンクサイズです。vLLM V1 は chunked prefill を既定で有効にし、連続バッチ処理と統合したスケジューリングにしています。SGLang は chunked prefill size を個別に公開しています。経験上、既定値は多くのワークロードに十分なので、まずは触らないほうがよいです。
飢餓を忘れない
混在バッチの自然な副作用として、decode リクエストは 1 ステップごとにトークン予算を 1 しか消費せず、prefill チャンクは数千消費します。先に arrived 順で素朴に予算を埋めていくと、大きな prefill シーケンスが長時間予算全体を占有し、小さいリクエストが繰り返し待たされます。エンジニアリング上の対処はいくつかあります。decode に最低保障量を与える方法が 1 つです。単一リクエストがバッチ内で占める prefill の割合を制限する方法、あるいは大きな文書リクエストを専用の prefill インスタンスへルーティングする方法もあります。後の PD 分離の節で、この問題に戻ってきます。
メモリ管理:連続割り当てからページングへ
連続割り当てがなぜ必ず無駄になる
ページングの前に、各シーケンスの KV Cache は連続した 1 塊の GPU メモリで、見積もった最大長分だけ先に確保されていました。多くのエンジンは max_model_len に合わせるか、動的に伸ばします。これは 2 つの必然的な無駄を生みます。
- 内部断片化:100K のコンテキストのために確保した領域が、実際には 500 トークンしか生成されず、残りは恒久的に空いたままになります。
- 外部断片化:長いリクエストと短いリクエストが交互に確保と解放を繰り返すと、どのリクエストも満たせない隙間がメモリにできます。
より厄介なのは、メモリの断片化が cudaMalloc を遅くし、同期まで誘発しうることです。これは実行時のジッタの発生源の 1 つです。
PagedAttention の 2 つの原語
PagedAttention は KV Cache を仮想メモリのように管理します。固定サイズのブロックプール(通常は 16 トークンの 1 ページ)と、「論理ブロック番号 → 物理ブロック番号」を記録する block_table です。attention カーネルが KV を読むときは、この表を通じて間接的にアドレス解決します。この設計はちょうど連続バッチ処理の必要条件でもあるので、両者は同じ硬貨の 2 面です。実際に得られるのは 3 つの利得です。
- ほぼゼロの無駄:内部断片化はシーケンス末尾に半ブロック程度が生じるだけです。PagedAttention の論文が示している比較では、メモリの無駄が 20% から 30% 超の水準から 4% 以内へ下がったとされています。
- O(1) の割り当てと解放:割り当てとは free list から 1 つブロック番号を取り出すことで、解放とはそれを戻すことです。断片化率とは無関係で、
cudaMallocを呼ぶ必要もありません。 - ブロック単位の共有:これが prefix cache の物理的な土台です。詳しくは後述します。
ブロックサイズは勘で決められないパラメータ
ブロックが大きすぎると、最後のブロックが数割しか使われず、長いシーケンスでは内部の無駄が戻ります。また block_table 自体も GPU メモリに載せる必要があり、シーケンス 1 本につきブロックの索引が要ります。ブロックが小さすぎると block_table が長くなり、カーネルはブロックを読むたびに間接アドレス解決を 1 回行うため、メモリアクセスの集約が崩れます。prefix cache の有効なヒット単位も細かくなります。16 が現在広く採用されている既定値で、一部の実装はこれを設定可能にしています。
SGLang の別の道:radix tree
SGLang は RadixAttention によって KV Cache を基数木(radix tree)として整理します。ハッシュベースのブロックプールではありません。論理的には同等です。最長共通 prefix に合わせてノードを再利用します。ただし木の構造のおかげで、多輪対話や木探索、並列サンプリングのような「分岐してから合流する」ワークロードでは再利用がより自然に働き、木の経路に沿って正確な LRU の追い出しも直接行えます。
vLLM はブロックハッシュ表 + LRU 追い出しという道を進みます。どちらの道も行き着く先は同じで、「共有される prefix」をデータ構造が直接表現できる物理的事実に変えることにあります。
メモリ台帳は自分で計算する
チューニングの前に、上の KV の式で予算を出しておくと、勘で触るよりずっと確かです。この式の価値は、同時実行数が上がらないときに「メモリが足りない」のか「スケジューラが十分な仕事を喂えていない」のかを即座に切り分けられることにあります。
# 估算单卡能塞下多少并发
# 注意:这是粗算,实际还要给激活值、采样器、CUDA Graph、通信缓冲留空间
model_layers = 61
kv_heads = 48 # MLA 模型要另算,压缩潜变量小得多
head_dim = 128
kv_bytes_per_elem = 2 # fp16=2, fp8=1, int8=1
gpu_total_gb = 80
weights_gb = 14.2 # 14B FP16 权重大约值
reserved_ratio = 0.03 # 分配器与显存碎片余量
kv_bytes_per_token = 2 * model_layers * kv_heads * head_dim * kv_bytes_per_elem
# 8K 上下文单条序列的 KV 占用
kv_gb_per_seq_8k = kv_bytes_per_token * 8192 / (1024 ** 3)
usable_gb = gpu_total_gb * (1 - reserved_ratio) - weights_gb
max_seqs_at_8k = usable_gb // kv_gb_per_seq_8k
print(f"KV/token = {kv_bytes_per_token/1024:.1f} KB")
print(f"单序列 8K KV = {kv_gb_per_seq_8k:.2f} GB")
print(f"8K 下最大并发 = {int(max_seqs_at_8k)}")
print(f"若 KV 换 fp8,并发约 x{int(2 * 0.5)} 级别提升(视实际占用)")
MLA のような注意力を圧縮するモデルが長コンテキストで受欢迎なのは、この式の kv_heads × head_dim の項をたった 1 けただけ削れるためです。KV の占有量は従来の MHA の 10 分の 1 程度まで下がります。
Prefix Cache:スイッチを入れるだけではない、命中率的設計
原理:ブロック単位のハッシュ
prefix cache の判定単位はブロックです。スケジューラは各 KV ブロックについてハッシュを計算し、そのハッシュの入力には、このブロックのトークン列と親ブロックのハッシュが含まれます。こうすると、1 つのブロックのハッシュには、それより前のすべての内容の指紋が暗黙に含まれることになります。探索時はブロックのトークンから順にハッシュを計算し、連鎖的に何個ヒットしたかだけ、その個数の KV を再利用するのです。
親ブロックのハッシュが計算に参加することで、連鎖の正確性が保証され、「途中で内容が差し替えられたのに前後のハッシュが衝突してしまう」ようなことはありません。結果として、ヒット長は自然にブロックの整数倍に切り捨てられます。1 ブロックが 16 トークンで、共有している prefix が 100 トークンなら、実際に再利用できるのは 96 トークンしかありません。これは小さなが実在する機会コストです。多輪対話で積み上がる後半では、prefix 命中率がこの切り捨てのせいでわずかに低下します。
命中率はどこから来るのか
本番環境での命中率の差はきわめて大きく、トラフィックの形次第で変わります。命中率が高くなる典型的な出どころは次の 4 つです。
- 安定した system prompt。数千トークンのシステムプロンプトがすべてのリクエストで繰り返されます。命中率の最大の出どころであり、便益も最も直接的な部分です。数千トークンの prefill がゼロ計算に変わるからです。
- few-shot の例。少数の例で組むテンプレートは、もともと共有された prefix です。
- 多輪対話。第 N 輪の入力は第 N−1 輪までの完全な履歴を含むので、理論的にはその大半を再利用できます。ただし履歴が書き換えられたり要約で圧縮されたりしていないことが前提です。上流で要約を一度でも作ると prefix は切れ、命中率はゼロになります。
- 構造化生成の固定された骨格。ツール呼び出しや関数定義の schema は、prefix としての重複率が非常に高いです。
失效のシナリオ:命中率が上がっても、レイテンシがより悪くなる
失效のシナリオ:命中率が上がっても、レイテンシがより悪くなる
これは本番環境で最も踏まれやすい一方、あまりにも記録が少ない種類の問題です。prefix cache には 4 方向の落とし穴があります。
- prefix に変化量が混ざっている。system prompt のなかにタイムスタンプやリクエスト ID、ユーザー ID、ランダムな nonce を入れるのは、命中率がゼロになる第 1 の原因です。しかもそれを入れた人が、推論サービスを担当しているチームに属していないことがよくあります。
- 命中率は 100% でも、得られるものは限られる。prefix cache が節約するのは prefill だけで、decode は節約しません。
ワークロードが「長い prompt + 短い出力」なら TTFT の便益は巨大ですが、TPOT はほとんど変わりません。逆に「短い prompt + 極端に長い出力」(長いコード生成など)なら、prefix cache が節約した時間は数百ステップの decode に完全に埋もれます。まず負荷の形を判断してから、命中率の設計に投資する価値があるかを決めるべきです。
- キャッシュが同時実行数を圧迫する。再利用されるブロックも同様にメモリを消費します。命中率が高いほど多くのリクエストが同じブロックを共有し、独立したシーケンスの総数がむしろ減る可能性があり、スループットが上がるとは限りません。
- 追い出し戦略が反过来る。LRU の追い出しのもとで、流式出力中の多輪リクエストが途中で他リクエストにブロックを奪われると、再計算か中断のどちらかを迫られます。
正しい做法は、変化量をすべてユーザーメッセージの後ろへ移すことです。prefix を純粋に保つことができれば、命中率は初めて根拠を持ちます。
この一点は直感に反しますが現実です。「命中率」と「スループット」という 2 つの指標を分けて見る必要がある理由がここにあります。
長い対話の場面では、追い出しに関する命中率の指標に注目すべきであり、全局的な数字だけを見ていてはいけません。
観測:少なくともこの 4 つの数字を見る
prefix cache は観測できなければ、チューニングは暗中摸索になります。vLLM と SGLang はどちらも関連指標を公開していて、姿だけは少しずつ異なります。
# vLLM V1 常见前缀/调度相关指标(以实际 /metrics 输出为准)
vllm:prefix_cache_queries_total # 块级查询总次数
vllm:prefix_cache_hits_total # 命中次数 -> 两者相除得块级命中率
vllm:gpu_prefix_cache_hit_rate # 引擎直接上报的命中率
vllm:num_requests_waiting # 排队长度:前缀缓存省时间的主要受益者
vllm:num_requests_running # 在跑并发
vllm:num_preemptions_total # 抢占次数:缓存挤占导致抢占时这里会飙升
vllm:gpu_cache_usage_perc # KV 块池水位
# PromQL 组合出可用的判断
# 命中率趋势
rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])
# 等待队列是否积压(配合命中率一起看)
vllm:num_requests_waiting
# 抢占风暴告警
rate(vllm:num_preemptions_total[1m]) > 0.5
SGLang 側で対応するのは、実行中・待ち行列中のリクエスト数、キャッシュ命中率、生成スループット、待ち行列レイテンシ、そして speculative decoding の受理長です。指標名は各家・各バージョンで変わるので、使う前に /metrics を 1 回取って照合してください。そのままコピペしないこと。
命中率と便益の定性的な関係
| 場面 | 典型的な命中率 | 主な便益点 | リスク |
|---|---|---|---|
| 長く安定した system prompt + 短い出力 | 非常に高い | TTFT が大幅に低下する | ブロック共有の後は独立した同時実行数の上限が総メモリで決まる |
| 多輪対話、履歴を要約しない | 輪が進むほど上がる | 第 N 輪はほぼ prefill が不要になる | 長いセッションは追い出し後に再計算が要るので、十分なブロックプールが必要 |
| few-shot の固定テンプレート | 高い | テンプレート部分は prefill が不要になる | 便益が例示部分の長さの割合で薄まる |
| リクエストごとに独立した長い文書 RAG | 低い | 共通指示の部分だけがヒットする | 命中率が低いときのコストは主にブロック表の保守なので、許容できる |
| 短い prompt + 極端に長い出力 | 該当しない | ほぼない | このため命中率の設計に投資する意味はない |
バッチサイズ:1 本の曲線にある 2 つの曲がり角
バッチサイズは、スループットの式とレイテンシの式の両方に現れる唯一のパラメータです。それだけに節を 1 つ割く価値があります。「バッチが大きいほど速い」という直感は曲線の前半でのみ成立し、後半では曲線は 2 回連続で折れ曲がります。
前半:バッチを大きくすると必ずスループットが上がる理由
前述のとおり、デコード段階はメモリ律速です。トークンを 1 つ生成するたびに、全モデルの重みを HBM から 1 遍読み直す必要があります。バッチの中にシーケンスが 1 本しかないとき、この重み読み取りは 1 個のトークンのためにしか使われず、帯域を純粋に浪費します。バッチ内に N 本のシーケンスがあるとき、同じ 1 遍の重み読み取りが N 個のトークンに奉仕し、トークンあたりの重み読み取りコストが 1/N に薄められます。
これが「バッチを大きくすれば必ずスループットが上がる」ことの全理由です。その数学的な基盤は算力利用率ではなく、重みの再利用にあります。これは、低同時実行数の区間ではスループットがバッチとともにほぼ線形に伸びる一方、GPU の監視ダッシュボードの「利用率」の数字が終始よく見えないことを説明します。利用率が低いことこそが、まだ余裕があることの証拠なのです。
最初の曲がり角:メモリ律速から計算律速へ
バッチ内のトークン数が大きくなり、マトリクス乗算の算術強度がハードウェアの roofline の境界を越えると、1 ステップの所要時間は下がらなくなり、スループットの伸びが停止します。この曲がり角の位置は 2 つの量の比で決まります。モデル重みがメモリに占める比重と、1 ステップで実際に計算に参加するトークン数です。
実践的には 2 つの間接的なシグナルでそれを観測できます。1 ステップの所要時間の低下曲線が平坦化し始めることと、max_num_seqs を上げたのにスループットが変わらなくなることです。この 2 つの現象が現れたら、さらにバッチサイズを増やしても、メモリを待ち行列時間に交換しているだけで、得にはなりません。
2 つ目の曲がり角:TPOT の越境
曲がり角を過ぎたあとは、バッチサイズの増加がそのまま TPOT の増加になります。しかもこの増加には何の補償もありません。スループットはもう上がらないのに、ユーザーは確実に遅くなります。SLO を一度決めると、この曲がり角は硬い境界です。性能の曲がり角より先に到来し、交渉の余地がありません。
したがって正しい進め方はこうです。まず TPOT の P99 から max_num_seqs の上限を逆算し、その値を制約として扱います。その制約のもとでこそスループットを追求します。逆の順序(まずスループットを追い、あとからレイテンシを見る)はほぼ確実に手直しになります。スループットの改善は漸進的でとても良く見える一方、レイテンシの悪化は P50 ではまだ見えないことが多いからです。
バッチサイズは 1 つの数字ではない
さらに、同じエンジンでも「実効バッチサイズ」は毎日変わります。昼のピーク時ではバッチ内に 128 本のシーケンスがある一方、深夜には 3 本しかありません。ピークに合わせて決めたパラメータは、低い時間帯には GPU の大部分の時間を空転させます。もっとも TTFT は大きく悪化しません(そもそも待ち行列が並んでいないからです)。対処の考え方は 2 つあります。1 つは受け入れることです。谷値を最も安価なリソースでカバーし、コストの無駄を昼ではなく深夜に出すようにします。
もう 1 つは弾力性を持たせることです。時間帯や待ち行列の水位に応じて異なるバッチサイズの構成に切り替えますが、この道の実装複雑度は非常に高く、多くのチームは「小さい構成のインスタンスを 1 つ多く立てて受け皿にする」ことを選びます。
見落とされがちな帰結が 1 つあります。谷値は speculative decoding の便益が最も高い時間帯だということです。前述のとおり、メモリ律速の場面では余剰の算力で下書きの追加計算を吸収できます。そして谷値は恰恰、最もメモリ律速に近い状態です。もし speculative decoding が高負荷では伸びず低負荷で伸びるなら、ボトルネックの判断は正しいであり、谷値でのみ有効にするという選択肢を考えられます。
リクエスト形態の階層分け:1 つの構成ではすべての業務を支えられない
多くのチームはサービス開始時に 1 つのモデルインスタンスと 1 つの構成を持ち、すべての業務をそこへ接続します。しばらく走らせると、P99 が非常に説明しにくい形で劣化し始めます。平均値の指標はどれも正常に見えます。原因の多くはいつも同じで、異なる形態のリクエストが同じスケジューラの中で互いに干渉しているのです。
5 つの典型的な形態
「入力トークン数 / 出力トークン数」という比で見ると、本番トラフィックはおおむね数種類に分けられ、最適な構成は著しく異なります。
- 長い入力、短い出力:文書要約、長文理解、RAG の質問応答。ボトルネックは prefill で、レイテンシは TTFT が支配します。prefix cache と chunked prefill の便益が最大で、speculative decoding はほぼ无用(十分に長い decode 段階がないため)です。
- 短い入力、長い出力:長いコード生成、思考の連鎖による推論。ボトルネックは decode で、体感の優劣は TPOT が支配します。prefix cache はほぼ無意味、speculative decoding の便益が最大、chunked prefill の優先度は下げられます。
- 短い入力、短い出力:分類、意図の判別、情報抽出、単発対話。全体のレイテンシが小さく、待ち行列に対して敏感です。バッチサイズは必ず下げなければならず、そうしないと 1 リクエストあたりのレイテンシが許容できなくなります。スループットも重要ではありません。
- 超長コンテキストの多輪:長いセッションのアシスタント。KV の占有量が輪数とともに線形に増え、ブロックプールの圧力が最大になり、プリエンプションを起こしやすい部類です。この種類の SLO は個別に定める必要があり、前の 3 種類と合算してはいけません。
- 構造化出力と並列サンプリング:JSON schema による制約出力が要る、1 つの prompt から n>1 の結果をサンプリングしたい、あるいは beam search を行いたい、という場合です。この種類のバッチ内の形状は非常に不規則で、constrained decoding ではさらに構文制約を 1 遍走らせる必要があり、スケジューリングのオーバーヘッドは自由生成より明らかに大きくなります。
階層ルーティングのやり方と代償
素直な解決策は、形態ごとに異なる仕様のデプロイへ分けることです。短い対話にはバッチが小さくレイテンシ優先のインスタンス、長文の理解には chunked prefill を調整しメモリ優先のインスタンス、長いコード生成には speculative decoding を有効にした大きなバッチのインスタンスを用意します。フロント側はリクエストの特徴に応じてそこへルーティングします。
この解決策の代償は前もって計算しておく必要があります。大きく 3 つあります。第 1 に、メモリ断片化とスケジューリングの複雑さです。複数のインスタンスはそれぞれピーク分のリソースを予約する必要があり、利用率は単独の大プールより本質的に低くなります。第 2 に、負荷の偏りです。形態の判定を誤る可能性があり、トラフィック自体が時間で変動するので、インスタンスごとに負荷が偏ります。第 3 に、運用上の複雑さです。スケールアウト、ヘルスチェック、バージョン管理をすべて複数ぶん行う必要があります。
私の判断はこうです。P99 が確かに特定の形態に引きずられて壊れる場合、そしてその形態の占比が十分に高い場合(通常は 3 割超え)に限って、分割が划算になります。占比の低い形態のために単独のインスタンスを立てると、遊休のコストがすべての便益を打ち消してしまいます。比率の低い形態のために単独のインスタンスを立てると、遊休のコストがすべての便益を打ち消してしまいます。判断に迷うときは、まず単一インスタンスの上で chunked prefill と prefix cache を正しく設定してください。この 2 つの便益はすべての形態でプラスになり、リスクも最も低いです。
構造化出力に特有のスケジューリング負荷
5 番目の形態は特筆する価値があります。多くのチームでは、後期になって初めて露見する問題だからです。guided decoding(JSON schema や grammar でサンプリングを制約する)を用いると、サンプリングの段階は単純な multinomial ではなくなります。直前の候補トークンのうち、エンジンが「文法が許す状態」を維持し、順に不正なトークンを除外していきます。
この過程にはシーケンス単位の状態遷移と追加のカーネルスケジューリングが含まれ、高同時実行数では 1 ステップの所要時間が明らかに伸びます。その結果、structured なリクエストと free-form なリクエストを同時に走らせると、前者がバッチ全体の 1 ステップの所要時間を引き下げてしまいます。現れ方は両方が一緒に遅くなることです。回避方法は、文法の状態機械の遷移をサンプリングカーネルと融合するか、あるいはこの種類のリクエストを専用のインスタンスへルーティングすることです。観察方法は、構造化トラフィックの占比の高い時間帯に TPOT が規則的に上昇するかどうかを見ることです。
もう 1 つ、小さくて実用的な事実があります。constrained decoding が実際に受け付ける幅は、モデルの完全な語彙よりはるかに小さいのが普通です。エンジンが候補の切り詰めを行っていない場合、毎ステップ数十個のトークン上で完全なサンプリング分布の正規化を計算しており、これは純粋に省ける計算です。使っているエンジンが候補の切り詰めを有効にしているかを確認することは、このステップで最も費用対効果の高い検査です。
プロンプトの構造とキャッシュの親和性
ここまでは prefix cache をエンジンの特性として説明してきましたが、その実際の効果はほぼ完全に、プロンプトの組み立て方で決まります。この節ではプロンプト側から説明します。投入対効果が最も高い種類の仕事だからです。エンジンのパラメータを 1 つも触らずに、成果はすべてのチューニングの合計を超える可能性があります。
長さではなく、変化する頻度で階層化する
プロンプトを組み立てるときに最も起こりやすい誤りは、「重要度」や「長さ」で並べてしまうことです。最も重要なシステムの説明を冒頭に置き、業務ルールを後ろに置きます。この並びは読む体験としては最も自然ですが、キャッシュの仕組みとはまさに衝突します。冒頭が連鎖全体のハッシュ prefix を決めるため、冒頭が 1 か所でも変われば、後ろは全部無効になるからです。正しい階層化の原則は、変化する頻度の低い順に並べることです。決して変わらないアイデンティティと規則を最も前に、テナントや製品ラインごとに変わる部分を中央に、リクエストごとに変わるユーザ入力や検索結果、時刻情報は最後に置きます。
この原則は単純に見えますが、実践ではしばしば破られます。業務ルールを 1 条追加した人が本能的にシステムプロンプトの冒頭に挿入し、この 1 回の挿入でキャッシュ全体が壊れることにまったく気づかないからです。ある内容を prefix に置くべきかどうかを判断するには、簡単な検査があります。過去 1 時間の本物のリクエストから prefix 部分を取り出して重複除去し、何通りの組み合わせが存在するかを見るのです。共有されるべき部分に何十通りもの変種があるなら、そのなかに変化量が混ざっていることになります。どのフィールドが原因かを切り分けなければなりません。全体の命中率を見るより有用です。フィールドのレベルに直接到達できるからです。
多輪履歴の保存方法
多輪対話は prefix cache の便益が最も大きい場面であり、同時に最も静かに壊れやすい場面でもあります。鍵となるのは、履歴がそのまま再生されるかどうかです。毎輪で完全な履歴をそのまま戻すなら、理論的にはその輪の追加分以外はすべてヒットします。しかし上流がどこかで書き換えれば、prefix はその書き換え位置より後ろで全面的に無効になります。
書き換えの方法は何種類かあり、名を挙げる価値があります。履歴メッセージを再度シリアライズするときに空白や改行を統一してしまう。要約圧縮が特定の輪数を超えたら自動発動する。安全ポリシーのため履歴にフィルタ説明を挿入する。マルチモーダルで画像を文字説明に変換してから再生する、などです。これらの書き換えがモデルの品質に与える影響は小さいかもしれませんが、キャッシュ命中率に与える影響は壊滅的です。対処の方法は 2 つあります。1 つはエンジン側で prefix マッチングを行うもので、prefix が必ず連続してヒットすると仮定せず、ブロック単位で後ろから最も長く一致する部分を探すという方法です。
こうすれば途中で 1 か所だけ書き換えられていても、後ろの部分を残せます。もう 1 つはアプリケーション側で安定性の制約をかけるもので、履歴は追記だけを許し変更は禁じると決め、そして要約の結果を新しい 1 回の「圧縮注入」として明示的に印付け、命中率の低下を静かな劣化ではなく予測可能なイベントに変えます。前者はより徹底的で、後者はコストが安いという違いがあります。
分岐と並列サンプリング
1 回の生成で複数の候補が必要な場面、つまり複数のサンプリング、束探索、エージェントが同じプロンプトで複数の経路を試すという場合は、キャッシュの再利用状況が直観より複雑になります。実装が「独立したリクエストに複製する」ものなら、それぞれが完全に prefill をやり直し、キャッシュはまったく使われません。実装が「一度 prefill して、複数の分岐が同じ prefix cache を共有する」ものなら、分岐間の共通部分は 1 回しか計算されず、これは巨大な差になります。
判断方法は直接的です。サンプリング数を 2 倍にしたとき、TTFT と prefill の計算量が 2 倍になるのか、ほぼ変わらないのかを見る。2 倍になるなら、分岐が prefix を共有していないということであり、これは最初に直す価値が最も高い箇所です。修正のあと、分岐サンプリングはほぼ「リクエスト量は 2 倍でも計算量はほぼ変わらない」唯一の種類の場面になり、複数候補の業務には桁違いの便益になります。
ツール定義と構造制約の再利用
ツール呼び出しの場面では、ツール名、引数のスキーマ、返り値の形式という部分は高度に重複しています。共有される prefix に置くのが本質的な性質です。多くの実装はツール定義をプロンプトの末尾に注入しますが、ツール一覧は多輪対話の中で増えていくものなのです。新しいツールが後ろに足され続けると、後ろのすべての内容が移動し、キャッシュが次々に無効になります。
より安定した做法は、ツール定義の位置を安定させることです。すでに読み込まれたツールの位置は動かさず、新しいツールは固定位置の後ろへ追記します。構造化出力のスキーマ定義にも同じ問題があります。毎リクエストでスキーマ定義のプロンプト内での位置が違ったり、内容によって順序が変わったりすれば、いずれもキャッシュを無効にします。スキーマ定義を prefix の固定位置に固定しておくのは、コストがほぼゼロの最適化です。
プロンプトをバージョン管理の対象にする
プロンプトはすでにエンジニアリング資産になっています。それでも多くのチームでは、コード、設定画面、各個人のローカルファイルに散らしたままです。これは 2 つの直接的なリスクを生みます。現在本番で動いているのがどのバージョンなのか、誰も説明できないことと、プロンプトを変更したときに、それがプロンプトの問題なのかエンジンの問題なのかを判断できないことです。
実務上の最低要件は 3 条です。プロンプトに明確なバージョン識別子を持たせ、リクエストとともにログへ記録すること。変更はレビューを通し、prefix 構造に影響するかを注記すること。数分以内に直前のバージョンへロールバックできるようにすること。この 3 つを満たせば、prefix 命中率の変化を正しく原因特定できます。命中率が急落したなら、それはプロンプトの改版によるもの(バージョン番号で確認できます)か、そうでないか(後者ならエンジン側の問題)です。この機能がなければ、命中率は誰も説明できない指標になります。そして誰も説明できない中核指標は、異常が起きたときに事実上存在しないのと同じです。
Speculative Decoding:受理率がすべてを決める
数学的に無損失である理由
speculative decoding の論理はこうです。安価な小さなモデル(draft)で、次の γ+1 個のトークンを直列に当てておき、大きなモデル(target)でその γ+1 個の位置を一度の並列フォワードで検証します。検証は順番どおりに行います。i 番目のトークンが draft と一致すれば受理して次を見、不一致になったらそれ以降の推測をすべて捨て、その位置から target 自身の分布で再サンプリングします。
重要な性質は、出力分布が target だけを使った場合と完全に一致するということです。これは近似的な高速化ではなく、無損失の高速化です。draft は候補を出すだけで、最終結果は target の分布が決めます。そのため量化のように追加の品質回帰試験を必要とせず、本番で安心して使えます。
加速比は手で計算できる数です
各位置の受理確率を α とします(ここでは独立と仮定しますが、実際には相関があります)。長さ γ の下書きが受理されるトークン数の期待値は等比数列の和になります。期待受理長 E[γ] = (1 − α^(γ+1)) / (1 − α)いくつかの値を代入するだけで、α の支配的な地位がわかります(γ=3 の場合)。α=0.9 で約 3.44、α=0.7 で約 2.92、α=0.5 で約 1.88、α=0.3 で約 1.42 です。受理率が 0.9 から 0.5 へ落ちると、期待長はおおむね半減します。
一方、1 ステップのコストは target のフォワード 1 回に draft の γ 回の小さなステップを加えたものです。したがって実際の加速比は、期待受理長 ÷(1 + γ × draft の相対コスト)になります。
draft が target に対して安ければ安いほど γ を大きくでき、α が低いほど「draft が十分に安い」ことで補わなければなりません。現実のシステムの分布は「1 つの数」ほどの単純さではありません。温度が高いと α は急激に下がり、長い推論鎖の後では draft と target の分布のずれも α を押下げます。したがって受理長を実測する必要があります。式に当てはめるのではなく。
# 投机解码的收益手算:先决定"值不值得开",再去调参
def expected_accepted(alpha, gamma):
"""长度 gamma 的草稿,期望被接受的 token 数"""
if alpha >= 1.0:
return gamma + 1
return (1 - alpha ** (gamma + 1)) / (1 - alpha)
def speedup(alpha, gamma, draft_cost=0.05, mem_bandwidth_free=0.5):
"""
draft_cost : draft 单步耗时 / target 单步耗时
mem_bandwidth_free:
0.0 -> GPU 已经完全算力饱和,多花的计算无法重叠,加速比被完全吃掉
1.0 -> GPU 处于访存受限状态,draft 的计算几乎免费,加速比接近期望长度
"""
if alpha <= 0.0:
return 0.0
cost = 1.0 + gamma * draft_cost
gain = expected_accepted(alpha, gamma)
# 空闲带宽能吸收一部分 draft 计算成本
return 1.0 + (gain - 1.0) * mem_bandwidth_free / cost
print("gamma alpha 期望长度 访存受限 算力饱和")
for alpha in (0.9, 0.8, 0.7, 0.5):
for gamma in (1, 3, 5):
e = expected_accepted(alpha, gamma)
lo = speedup(alpha, gamma, mem_bandwidth_free=1.0)
hi = speedup(alpha, gamma, mem_bandwidth_free=0.0)
print(f"{gamma:>5} {alpha:>5} {e:>8.2f} {lo:>8.2f}x {hi:>8.2f}x")
この表が示していることは、同じ構成でも、メモリ律速(低同時実行数、小さなバッチ)と計算飽和(高同時実行数、大きなバッチ)の 2 つの状態で加速比が何倍も変わりうるということです。高負荷のとき GPU にはもともと余剰の算力がないため、そこに draft の計算を入れるのは純粋なコストです。この時点で speculative decoding はスループットを悪化させます。「有効にすれば速くなる」という期待は成り立たず、バッチサイズと結合しています。
下書きモデルの選び方
道筋はおおむね 4 つに分かれ、それぞれ適用できる境界があります。
- 独立した小さなモデル(n-gram や prompt lookup の系統):追加のモデルを一切導入せず、テキスト中の繰り返しだけに rely します。prompt や直近の出力に現れたトークンの断片を下書きとして使います。
- 同じ系統の小さなモデル:同じ tokenizer と同じシリーズの小モデルを draft として使います。実装は単純で、α は 2 つのモデルの分布の近さで決まります。品質の悪い近縁モデルをかえって α を下げてしまいます。
- 特徴量や多ヘッドの自己投机(EAGLE や Medusa の系統):完全な追加モデルを読み込まず、主モデルの上に軽量な「次の特徴量予測」ヘッドや複数のデコードヘッドを付け、主モデルの隠れ状態を使って将来のトークンを予測します。α が明確に高く、現在の主流の選び方です。
- MTP による自己投机:基盤モデルが最初から複数トークン予測ヘッド(MTP)を持っている場合、そのヘッドは本質的に最も適した draft です。主モデルと表現を共有し、メモリの増加は極めて小さく、分布の一致が最もよいからです。
メモリの消費はゼロ、重みのコストもゼロです。要約の書き換え、文書问答、コード補完のような重複の多い負荷に向きます。創造的な生成ではほぼ効きません。便益の上限は低いものの、単純で信頼でき、訓練も不要です。
これはアーキテクチャ上の「ただのごちそう」ですが、元から MTP を持つモデルにのみ成り立ちます。
実測すべき 3 つの数
ホワイトボードで speculative decoding を入れるかどうかを決めず、直接測ってください。受理長、有効にする前後の TPOT、有効にする前後の全体スループットです。3 つはどれか 1 つが欠けてもいけません。TPOT だけを見ると、「1 リクエストは速くなったが全体のスループットは落ちた」という罠を見落としてしまいます。典型的な観測方法は次の通りです。
# 投机解码相关观测(参数名以所用版本文档为准)
# vLLM 侧
# --speculative-config '{"method":"ngram","n":5,"prompt_lookup_min":2,"num_speculative_tokens":5}'
# method 也可为 "eagle" / "eagle3" / "medusa",需配套加载 draft 权重
# SGLang 侧
# --speculative-algorithm EAGLE3
# --speculative-draft-model-path /path/to/draft
# --speculative-num-steps 3 # 草稿步数 gamma
# --speculative-eagle-topk 4 # 候选分支宽度
# 上线前必须同时记录的三个数
# 1) 实际接受长度 spec_accept_length 分布(不是均值,要看 P10)
# 2) TPOT P50/P99
# 3) 集群总吞吐 tokens/s
# 判定规则:若总吞吐下降,无论 TPOT 改善多少,都不开启。
見落とされがちな境界がもう 1 つあります。マルチモーダルなリクエストと超長コンテキストでは、draft の入力の組み立てとキャッシュの組織が複雑になり、実際の便益は個別に検証する必要があります。純テキストでの結論をそのまま外挿しないでください。
マルチテナントのスケジューリング:プリエンプション、公平性、QoS
メモリが足りないときは奪うしかない
連続バッチ処理には静的な予約がなく、KV ブロックは動的に確保されます。ブロックプールが足りなくなったとき、エンジンに選べる手は 3 つしかありません。拒否(実装によっては 503 を返すか、待ち行列に積む)、swap(入れ出し)して被害者の KV ブロックをホストメモリへ書き出す、再計算(recompute)して最初からやり直す、の 3 つです。再計算のコストは壊滅的です。20K コンテキストのセッションが奪われれば、再計算とは 20K トークンの prefill を再び払うことを意味します。
そのため主要なエンジンは「最も最近アクセスされていないもの」または「再計算コストが最も高いもの」を基準に被害者を選ぶことを優先し、長コンテキストのリクエストや、すでに大量の content を生成済みのリクエストを可能な限り避けます。swap は安いものの、数 GB 規模の PCIe 転送を 1 回伴い、NVLink のホスト相互接続が使えるなら改善されます。
待ち行列戦略の選び方
先着先順(FCFS)は実装が単純で遅延を予測できますが、直感に反する性質が 1 つあります。メモリが制約される場面では、LIFO(後着先順、つまり後から来たリクエストを優先する)の方がクラスタのスループットを明らかに高く出せることがあるのです。理由は、新しいリクエストのコンテキストは、すでに半分ほど走った古いリクエストより短いのが通常で、占める KV も少なく、単位メモリあたりで支えれる同時実行数がより多くなるからです。vLLM の関連作業では、メモリの壁に直面した場面でこの差が数倍に達すると報告されています。
ただし LIFO は対話形のサービスには受け入れられません。古いリクエストが無期限に飢餓状態になる可能性があるからです。実用的な妥協案はこうです。既定は FCFS で公平性を保ち、メモリの圧迫やプリエンプションの多発を検知したら LIFO 式の短ジョブ優先に切り替える、あるいは「残りの長さ/残りの時間が最も短いものを優先」する方式です。これは純粋に理論上の選択ではなく、圧力が挂かったときのクラスタの実容量を直接左右します。
公平性:真のマルチテナント問題
同じクラスタで複数の業務にサービスする場合、「公平」は 3 層に分けて見る必要があります。
- 受け入れ(准入)の公平性:1 つのテナントがバッチを埋め尽くせるかどうかです。答えは「できる」です。そのテナントのリクエストが十分多ければ、FCFS では後から来た他のテナントがずっと待ち行列に並びます。
- 受け入れ(准入)の公平性:1 つのテナントがバッチを埋め尽くせるかどうかです。答えは「できる」です。そのテナントのリクエストが十分多ければ、FCFS では後から来た他のテナントがずっと待ち行列に並びます。対策はテナントごとのトークンバケットによるレート制限、あるいはスケジューラの中でテナントごとにバッチの枠数を上限を設けることです。
- 待ち行列の公平性:長いリクエストと短いリクエストが混在すると、長い方が長く待ちます。バッチ内の同時実行数が高いほど、待ち時間の分散は大きくなります。対話系の業務では通常
max_num_seqsに上限を設けます。スループットがわずかに低くても、P99 の待ち行列が爆発するよりはよいとします。 - プリエンプションの公平性:最も危険なテナント分離の穴です。テナント A の長いリクエストが何度も奪われると、A が何度も prefill を払うことに等しいです。テナントごとに分けたプリエンプション回数を監視してください。
QoS 上の実用的なやり方は階層化です。SLA の異なる業務に異なる優先度と独立した同時実行数の枠を与え、スケジューラは優先度順に出力し、同じ優先度の中では FCFS とします。受け入れ側のレート制限と組み合わせれば、スケジューラの中で複雑な公平共有アルゴリズムを実装するよりずっと安定し、説明と排查も容易です。
PD 分離:混在バッチという根本原因を取り除く
chunked prefill は「混在バッチ」の枠組みの中でヘッド・オブ・ラインブロッキングを緩和する罢了。PD 分離(prefill/decode disaggregation)は直接引き裂きます。prefill 用のインスタンスと decode 用のインスタンスを異なるデプロイ、異なるバッチ戦略で用意し、それぞれが最適化し、ネットワーク越しに KV を送ります。
利点はとても実際的です。prefill は計算律速なのでバッチが小さいほど速く、decode はメモリ律速なので大きなバッチで重み読み取りを薄める必要があります。混在させると両者が妥協し、どちらも最適に到達できません。分けると両者がそれぞれにパラメータを決められ、異なる仕様のマシン(prefill は計算力の強いものを、decode は帯域の広いものを)を使えます。
代償は KV の転送です。長コンテキストの KV は数百 MB から数 GB に達し、マシンをまたぐ転送は容易に新たなボトルネックになります。RDMA のような直結経路を通す必要があり、KV 転送ライブラリ(NIXL のようなもの)が現れた理由でもあります。PD 分離を入れるべきかの経験的基準はこうです。長い prompt(数千トークン以上)の占比が高く、かつ TPOT の揺らぎが明らかな場合に便益が最大になります。短い prompt・短い出力の負荷では、転送コストがすべての便益を食べてしまいます。
1 つのスケジューリングステップ内の時間構成
これまでの議論はすべて「1 ステップの所要時間」という量の上に立っていますが、実際に 1 ステップの中に何が入っているかを見る人はほとんどいません。ここを理解して初めて、同じモデル・同じ構成でも実装によって性能差が 2 倍以上出るのはなぜか、を説明できます。
1 ステップの 6 つの段階
スケジューラがこのラウンドで何を走らせるかを決めてから、結果がユーザの手元に来るまで、おおよそ 6 つの区間に分かれます。
- 状態の準備:このバッチのシーケンスの現在の長さ、ブロック表、位置番号を、デバイスがそのまま消費できる索引テンソルへまとめます。動的バッチ処理では毎ラウンドのシーケンスの集合が変わるため、索引の再計算が必要になります。
- フォワード計算:attention と feed-forward ネットワークです。実際に数学演算が行われる唯一の部分であり、グラフィックスカード上で大規模に並列化できる唯一の部分でもあります。
- サンプリング:出力分布から 1 個のトークンを取り出します。確率の正規化、乱数生成、制約デコード時の構文フィルタ、そして複数シーケンスの場面でのバッチ処理を含みます。
- 通信と同期:複数カード間のテンソルの同期と、デバイスとホストの間のデータ転送です。
- 状態の更新:新しいトークンをシーケンスに書き戻し、ブロック参照を追加し、終了したシーケンスを解放し、prefix cache のヒットと追い出しを更新します。
- 結果の配信:新しく生成されたトークンをリクエスト元へ送り返します。
見落とされがちな犯人は、ホストとデバイスの間の同期の待ち時間です。グラフィックスカードの計算が終わるのを待つ必要がある呼び出し(結果の読み戻し、状態の取得、分岐の判断)は、どれもカードの計算パイプラインを強制的に空にします。モデルが小さいほど、バッチ内のシーケンスが短いほど、スケジューリングのオーバーヘッドが占める比重は大きくなります。小さなモデルほどエンジンの実装差が拡大されるのも、このためです。
同期点が最も高価な部分
バッチ内のシーケンスが少ないとき、計算自体がパイプラインを埋めていません。そこに同期の割り込みが入ると、カードの実際の利用率は非常に低い水準まで落ちます。これは「バッチ内のシーケンスが多いほどよい」という直感が、ある範囲では成立する理由でもあります。シーケンスが多ければ、カードはスケジューリングのオーバーヘッドを隠すのに十分な仕事を持ちます。少なければ、同期 1 回の固定コストがごく少数のトークンに分けて被され、単位コストが急激に上昇します。バッチサイズの曲線の最初の曲がり角は、本質的にスケジューリングのオーバーヘッドが完全に薄められきる点です。
自分で測れる方法
推測に頼らないこと。多くの推論フレームワークは段階ごとの計測やプロファイリング機能を持っています。典型的なやり方は 1 ステップをいくつかの区間に分けてタイムスタンプを打ち、重点的に見るのが 3 つです。フォワード計算が 1 ステップに占める割合、サンプリングと状態更新の絶対的な所要時間、そして想定外の同期待ちが存在するかです。
フォワードの比率が明らかに低いなら、まずバッチ内のシーケンス数が不足している可能性か、同期点の有無を疑います。サンプリングの比率が異常に高いなら、制約デコードの構文状態機械で候補の切り詰めを行っていないかを確認します。状態更新の比率が高いなら、多くはブロック表の保守や prefix 探索のコストなので、関連する実装がクリティカルパスに置かれていないかを確認します。この方法には副次的な利点もあります。「このエンジンはなぜ遅いのか」という謎が、具体的なコードの位置まで特定できる問題へ変わるからです。チューニングの助言は 1 つ 1 つ、測定可能な段階に対応してします。そうでなければ、それは単に他人の経験値にすぎません。
単一マシンから複数マシンへ:並列化の代償
これまでの議論は「モデルは 1 セットのデバイスに載っている」ことを前提にしていました。現実の本番環境ではモデルが収まらないことが多く、複数カードや複数マシンへ分割する必要があり、並列化自体に通信コストを伴います。
3 つの分割方法
- テンソル並列:重み行列を行または列で切り、各カードが一部分だけを計算し、層の中で 1 回 all-reduce してから下へ進みます。通信が最も頻繁で、規模は 1 層あたり数回、毎回活性化を転送します。拡張できる前提はデバイス間の高速な相互接続があることです。
- パイプライン並列:異なる層を異なるデバイスに割り当て、1 つのデバイスの出力をそのまま次へ渡します。通信量は小さく(層境界の活性化だけ)、しかしパイプの気泡现象があります。全デバイスが同じ種の計算をするため、効率は最も遅いカードに支配されます。
- パイプライン並列:異なる層を異なるデバイスに割り当て、1 つのデバイスの出力をそのまま次へ渡します。通信量は小さく(層境界の活性化だけ)、しかしパイプの気泡が生じます。全デバイスが同じ種の計算をするため、効率は最も遅いカードに支配されます。
- エキスパート並列:混合エキスパート構造のモデル向けに、異なるエキスパートを異なるデバイスに割り当て、各トークンはごく少数のエキスパートへルーティングされます。通信は疎で 1 層 1 回、規模は前 2 者の間にあります。
実際のデプロイでは組み合わせが通常です。デバイス数が少ないときはテンソル並列を、多いときはさらにパイプライン並列で階層化します。しかし組み合わせの選び方は性能の上限を直接左右し、選び方を間違えた代償はパラメータを微調整するより遥かに大きくなります。
通信量の概算
テンソル並列の通信量は「隠れ次元 × シーケンス内のトークン数」に比例して増え、層数と掛けると総量は相当な規模になります。直感的なヒューリスティックで判断するなら、各層の all-reduce は少なくとも隠れ次元の活性化を 1 回転送する必要があり、その隠れ次元の活性化テンソルは長コンテキストでは小さくはないということです。コンテキスト長が数千から数万に増えれば通信量も増え、このとき相互接続の帯域がカード間直結ではなく一般的な外周バスを使っているなら、差は無視できない水準まで拡大します。
そのため有用な経験則があります。並列化の分割はまず同一の物理マシン内で完結させ、其次にカード間直結の高速な接続を備えたマシンを優先して使うというものです。マシンをまたぐ分割は単一マシンに収まらないときに限って使い、throughput 上の明確な損失を受け入れるべきです。この点は調達とデプロイ計画の段階で決めるべきで、負荷テストで見つってから手戻りするのではありません。
並列度は大きいほどよいわけではない
テンソル並列には直感に反る法則があります。並列度を高くすると 1 カードあたりの行列が小さくなり、算術強度が下がり、メモリ律速の問題がむしろ悪化します。もともと 1 枚で効率よく走っていた バッチ は、複数カードへ分割すると各カードが足りなくなります。さらに通信のコストも余分に払うことになります。すべての同期点は最も遅いカードで待たなければならず、全体の遅延は最も遅いデバイスで決まります。たるみの効果がはっきり現れます。
これが「同じモデルに対してカードを増やせば、1 リクエストのレイテンシが悪化し、総スループットがわずかに上がるだけ」になることがあるからです。最適な並列度の位置はモデルの隠れ次元、コンテキスト長、相互接続の帯域、バッチ内のシーケンス数に依存し、汎用的な答えはありません。唯一信頼できる方法は、分割の粒度を段階的に実測し、各段階で 1 リクエストのレイテンシと総スループットの 2 つの数字を記録することです。経験的には、行列がある寸法に到達するまでは加速比がほぼ線形に近いですが,越えたあたりから明確に減衰し始めます。
複数インスタンスか単一マシンの分割か
さらに早い時点の判断があります。小さなマシンを買ってそれぞれに完全なモデルを走らせるのか、それとも大きなマシンを買ってモデルを分割するのか、というものです。前者はデータ並列の考え方です。分離が本質的で、拡張が線形で、通信がなく、マシンをまたぐ信頼性の問題もありません。後者はモデル並列の考え方です。レイテンシは最適ですが、通信と障害ドメインが集中します。
スループットを主眼とするオンラインサービスのほとんどにとって、答えは複数インスタンス側に寄ります。複数の完全なレプリカがあれば、ロードバランサがリアルタイムの負荷に応じて振り分けられ、あるインスタンスが問題を起こしてもそのまま外せますし、マシンをまたぐ通信もありません。代償は、1 リクエストの絶対的なレイテンシはデバイスが増えても速くならないことです。
いつモデルの分割が必要になるのか。単一のレプリカで「モデルと十分な同時実行数」の組み合わせがメモリに収まらないときだけです。これは硬い制約であり、性能上の選択ではありません。複数インスタンスの方式で余計に解く必要があるのは、リクエストのルーティング品質です。最も一般的なのは最も短い待ち行列へルーティングすることですが、リクエストごとの形態の差は大きいため、待ち行列の長さだけで決めると長時間のリクエストが特定のインスタンスに偏ります。
改善の方向は、ルーティング時に長さの推定も併せて考慮するか、あるいは形態ごとに異なる仕様のインスタンス群へルーティングすることです。ルーティング戦略の不備による尾遅延は、監視上は各インスタンスの負荷が著しく不均衡な状態として現れます。これは実用的な排查のシグナルになります。
リクエスト経路の推論以外のコスト
これまでの議論はすべて「リクエストがスケジューラに入った瞬間」から計上してきました。しかしユーザが待つ時間には、その手前と後ろの部分は含まれます。これらの部分は TTFT の内訳で丸ごと無視され、短すぎるリクエストの場面では半分以上を占めることもあります。
手前の部分:トークン化と受け入れ
リクエストはゲートウェイから入り、認証、レート制限、形式の検証、プロンプトの組み立て、トークン化を経て、ようやくエンジンへ渡ります。これらはすべてリクエストごとの固定コストで、短いリクエストでは非常に顕著です。より見えにくいのは、トークン化そのものの時間が入力長に比例して増えることです。また多くのトークン化の実装は純粋な文字の走査であり、行列並列演算ではありません。超長入力では明らかに遅くなります。
TTFT の内訳で「エンジン内部が 3 分の 1 しか占めない」なら、残り 3 分の 2 はここにいる可能性が高いです。費用対効果の順に並べると、ゲートウェイ側で決められる処理を前倒しする(レート制限、認証、形式検証の判定が後続の処理をブロックしないようにする)、トークン化を並列化する(長入力をバイト境界で切って並列処理し、複数バイト文字や複数トークンの結合を壊さないように注意する)、重複したトークン化を避ける(実装によってはプロンプト組み立て時とエンジン入口で 2 回トークン化している)、という 3 つです。
後ろの部分:トークンの復元とストリーミング配信
生成側のコストも過小評価されています。エンジンの内部が扱うのはトークン ID なので、テキストに戻すには 1 回のデコードが必要です。ストリーミング出力なら、各トークンでデコードを 1 回行い、トークンをまたぐ文字の結合問題も処理する必要があります。1 つの文字が 2 つのトークンに分かれたりすることがあるため、そのまま連結すると文字化けします。ストリーミングの配信は背圧にも向き合わなければなりません。クライアントの消費が遅いとき、送信側はバッファするかブロックするかのどちらかになります。どちらの選択でもサーバ側のリソースを消費します。
見落とされがちな遅延の源が 1 つあります。フレームワークが配信回数を減らすためにマイクロバッチ処理をしていると、1 個のトークンの到着が人為的に遅れます。このマイクロバッチ時間は通常数ミリ秒の規模で、毎秒数十トークンのデコード速度では目立ちませんが、TTFT に積み重なり、クライアント側で逐次レンダリングをしているなら見えるようになります。
ネットワークの往復が二重に計上される
ストリーミングのサービスに誤診されやすい現象があります。クライアントが測った「TTFT」には完全なネットワークの往復が含まれますが、その往復の回数は 1 回とは限らないのです。ヘルスチェック、リトライ、プロキシ層のバッファ、ロードバランサの接続再利用戦略、プロトコルの圧縮の有無が、実際の往復回数に影響します。途中でいずれかの層が応答のバッファをしていると、ストリーミングの効果は完全に消えます。ユーザが待つのは逐次表示ではなく、一度にまとめて表示されるものになります。
この種の問題を排查する方法は、サーバ側のログではなく、クライアント側から直接、トークンの到着タイムスタンプの系列を観察することです。サーバのログの送信時刻はクライアントの受信時刻を反映せず、両者の差がリンク上の余分な遅延です。負荷テストのスクリプトが timestamp を逐次読まずに集約型のクライアントライブラリを使っているなら、この部分のコストを系統的に過小評価してしまいます。
固定コストの実測方法
TTFT を「クライアントで観測したもの」と「エンジンが内部で記録したもの」の 2 つに分けて比べ、その差がリンクのコストです。前提は 2 つのタイムスタンプが同じ単調な時計源から来ていることです。サーバとクライアントの時計が同期していなければ、差分は信頼できません。穏当なやり方は、クライアント側で到着時刻を記録し、サーバ側で送信時刻を記録して、双方を報告してログで揃えることです。そうすればマシンをまたぐ時計の精度には依存しません。リンクのコストに対する判断基準はこうです。それがリクエスト間で比較的安定した固定コストなのか、それとも同時実行数とともに上昇するのか。
固定の部分は処理の环节を減らすことでのみ最適化できます。一方、同時実行数とともに上昇する部分は、接続数のボトルネックやプロキシ層の待ち行列、送信側の背圧があることを意味します。この 2 つの問題はまったく違う手段で扱います。前者はアーキテクチャを変え、後者は容量を調整します。固定の部分は工程を減らすことでしか最適化できません。固定の部分は工程を減らすことでしか最適化できません。一方、同時実行数とともに上昇する部分は、接続数のボトルネックやプロキシ層の待ち行列、送信側の背圧があることを意味します。この 2 つの問題はまったく違う手段で扱います。前者はアーキテクチャを変え、後者は容量を調整します。
なぜこの節を独立させる価値があるか
これが典型的な判断の誤りを決めるからです。リンクのコストをエンジンの性能不足と取り違え、数週間かけてエンジンのパラメータを調整するというものです。短いリクエストの TTFT に 3 割がリンクに由来するなら、エンジンのスループットを 2 倍にしてもユーザの体感改善はごく限定的です。一方、マイクロバッチ時間を 10 ミリ秒から 2 ミリ秒に落とせば、効果がすぐ現れます。
この種の誤認を排查するには、早めにリンクの解析を行うことです。負荷テスト環境でゲートウェイをバイパスしてエンジンに直接つなぎ、ストリーミングを切って一度きりの返却に変え、段階的に工程を戻していきます。1 層戻すたびに 1 回測り、TTFT の変化曲線を見ます。傾きが最も大きい区間に、最大の便益があります。この方法は、どの経験的判断よりもあてになります。
サンプリングとデコードパラメータの性能上の代償
サンプリングはデコードループの中で毎ステップ実行されます。コストが高いかどうかは、有効になっている機能の種類で決まります。多くの人は「サンプリングはただのサンプリングだ」と既定で思っています。実際には、いくつかの構成の下では、サンプリングが attention 之外で最も高額な部分になります。
基本的なサンプリングのコスト
最も単純な貪欲デコードは最大確率のトークンを取るだけなので、コストは無視できます。ランダムサンプリングに替えると、乱数生成、累積確率の計算、サンプリング位置の特定が先に必要になります。これらのステップはシーケンスごとの小さな演算なので、コストは高くありませんがゼロでもありません。バッチサンプリングはこの演算をベクトル化でき、コストは許容範囲に収まります。本当の問題は、トークンごとに直列に処理が必要な機能にあります。以下のいくつかはこの部類に入ります。
トークンごとのペナルティ処理
繰り返しペナルティ、頻度ペナルティ、存在ペナルティといった機能は、毎ステップ、そのシーケンスが生成済みのすべてのトークンを走査して出現回数を数え、候補分布を調整する必要があります。コストは「生成済みの長さ」に正比例するので、長い出力の場面では生成の進行とともに線形に増えていきます。ここから直感に反する結論が得られます。同じモデル、同じバッチサイズでも、出力が長くなるほどサンプリングのコスト比率が上昇するというものです。長いコード生成のような場面では、数千トークンが生成された時点ですでに、サンプリングは無視できるレベルから無視できないコストに変わっています。
確認方法は簡単です。ペナルティを有効にした場合と無効にした場合の 1 ステップ所要時間の曲線を比べ、その差が生成の長さにともなって単調に大きくなるかを見ます。この種の機能を最適化できるかは、実装のアルゴリズムに依存します。素朴な実装は毎ステップ全量走査で、最悪の場合は二次複雑さになります。増分で管理する方法なら、コストをほぼ線形に抑えられます。最適化がされているかは、異なるエンジンの実装品質を分ける具体的な指標の 1 つです。
候補の切り詰めと制約デコード
制約デコードでは出力が文法やスキーマの制約を満たす必要があるため、実装は「今許されるトークン集合」という状態を管理し、毎ステップ集合で候補を絞り込み、新しい状態で続けます。この作業は本質的に直列で直前の結果に依存するため、完全にベクトル化するのは困難です。
有効な最適化の向きは 2 つあります。候補の切り詰めです。制約を破らない範囲で候補集合を実際に現れうる十数個まで絞り、モデルに語彙全体で完全な確率正規化を行わせないことです。状態のキャッシュです。同じ制約状態が複数のリクエストで共有されることがあるので、キャッシュで重複の状態遷移を避けます。切り詰めをせずに語彙全体で直接処理すると、毎ステップ数万個のトークンの正規化が必要になります。このコストは完全に省けるもので、検査を 1 回すれば得られる利得です。
乱数と再現性
ストリーミング生成が同じ乱数シードの下で再現可能であることを保証するには、乱数発生器が複数リクエストの並行下でも決定的な状態管理を持つ必要があります。実装には 2 つの一般的な方法があります。リクエストごとの発生器なら状態は独立で再現可能ですが、発生器の数が増えると管理コストとキャッシュ局所性がともに悪化します。全体で 1 つの発生器なら性能は良いものの、並行下で再現できません。
本番での妥協点は通常こうです。既定では高性能な経路を通し、再現が必要なときにはリクエストのパラメータで決定的な生成方法を指定し、その経路の追加コストを受け入れます。確認しておきたいのは、再現スイッチを有効にしたときの性能低下が有多大かです。低下が明らかなら、それは発生器の状態管理に最適化の余地があるということであり、再現そのものがコストが高いということではありません。
これらのコストはどこに集まるか
先の「1 つのスケジューリングステップの時間構成」という枠組みに戻ると、サンプリング段階の所要時間の比率は直接測れる量です。それが 1 ステップ所要時間の 2 割を超えたら、この部分を最適化する労力を使う価値があります。1 割未満なら、労力は別の場所に向けるべきです。この判断は 2 つのよくある誤りを防ぎます。比率 3 パーセントのコストを最適化するためにエンジンのソースを書き換えるとか、比率 30 パーセントのコストを放置したままバッチサイズばかりを調整し続けることです。さらに横串に見る視点もあります。同じエンジンでも、機能の組み合わせが変われば性能の並び順は変わります。
あるエンジンが自由生成で先行していても、制約付きの生成で先行しているとは限りません。ボトルネックがそれぞれ計算とサンプリングに異なるためです。業務が自由生成から大量の構造化出力へ移ったとき、この並び順は変わるので再評価が必要です。これは長期にわたって見落とされがちな選定の罠です。ある業務での性能から、別の業務での性能和類推してしまうことです。
1 つの指標が 2 度把你を騙す
冒頭の式に戻ります。总时延 = TTFT + (n−1) × TPOT。2 つの因子を掛け合わせて足すので、同じエンドツーエンドのレイテンシがまったく違う組み合わせから生じうるということです。
2 つのエンジンを考えてみます。A は TTFT が 2 秒、TPOT が 20 ミリ秒で、50 トークンを出力し、総時延は 3 秒です。B は TTFT が 200 ミリ秒、TPOT が 60 ミリ秒で、総時延は同じく 3 秒です。ユーザの体感はまったく違います。A は「長く待ってから留まることなく流れる」、B は「すぐに返るのにずっと遅く続く」という違いです。ストリーミング製品の体感はほとんどが TPOT で決まり、ユーザがクリックをやめるかどうかは TTFT で決まります。
さらに悪いのは、スループットだけを見ると誤ることです。バッチを 32 から 64 に上げるとクラスタのスループットは上がり、TPOT はほぼ 2 倍になり、エンドツーエンドのレイテンシは変わらないかむしろ悪化します。しかしスループットグラフの曲線は美しく見えます。スループットとレイテンシは対で報告しなければなりません。これがすべての真面目な推論ベンチマークが 2 組の数字を同時に出力する理由です。
確認すべき指標のリスト
| 指標 | 定義 | 主な影響要因 | 悪化時の典型的な症状 |
|---|---|---|---|
| TTFT P50/P99 | リクエスト送信から最初のトークンが返るまで | 待ち行列の長さ、prefill の算力、prefix 命中率 | 待ち行列が滞留する、命中率が 0 になる |
| TPOT / ITL P50/P99 | 隣接する出力トークンの間隔 | バッチサイズ、chunked prefill の構成、量子化カーネル | 大きな prefill による周期的なスパイク |
| 待ち行列の遅延 | エンジンに入ってからスケジューリングが始まるまで | 受け入れレート、同時実行数の上限 | 待ち行列が持続的に伸びる |
| スループット tokens/s | クラスタの単位時間あたりの総出力トークン | 同時実行数、バッチサイズ、量子化 | 利用率が低いのに待ち行列が長い → スケジューラの詰まり |
| goodput | TTFT/TPOT の SLO を満たすリクエストの割合 | 上記のすべて | スループットよりも実際のビジネス上の価値に近い |
| プリエンプション回数/秒 | 追い出しによって再計算や swap を迫られたリクエスト数 | KV ブロックプールの水位、max_model_len | スパイクが TTFT の悪化を伴う |
| 受理長 | speculative decoding の平均受理トークン数 | draft の品質、負荷の飽和度 | 低下は負荷の過大か draft の不適合を示す |
平均値ではなく、必ず分位数を見てください。推論サービスの P99 の悪化は平均値の変化より先に現れることが少なくなく、平均値はスパイクを完全に平らめてしまいます。本番では TTFT と TPOT のそれぞれに P50/P90/P99 のアラートを少なくとも 1 組ずつ設定します。
実用的な負荷テストスクリプトの骨格
チューニングの入力は再現可能な負荷テストデータでなければなりません。鍵となるのは現実の到着パターンを使うことです。固定の同時実行数でのテストでは待ち行列の挙動がまったく測れませんが、待ち行列恰恰がレイテンシの主な原因なのです。
# 压测骨架:同时产出 TTFT / TPOT / 吞吐三个视角
# pip install aiohttp numpy (或直接用 openai SDK)
import asyncio, time, json, statistics, random
import aiohttp
TARGET = "http://127.0.0.1:8000/v1/completions"
MODEL = "your-model"
PROMPT_TOKENS = 2000 # 覆盖你真实的 prompt 长度分布
OUT_TOKENS = 300 # 覆盖真实的输出长度分布
CONCURRENCY = 32
DURATION_S = 300
def make_prompt(seed):
# 注意:这里刻意让每个请求的 prompt 不同,以排除 prefix cache 命中
# 测 prefix cache 时要改成"共享长前缀 + 变化后缀"的形态
head = f"[req {seed}] " + "context " * (PROMPT_TOKENS // 2)
return head + " ".join(f"t{seed}_{i}" for i in range(PROMPT_TOKENS // 2))
async def one(session, sem, stats, seed):
async with sem:
body = {"model": MODEL, "prompt": make_prompt(seed),
"max_tokens": OUT_TOKENS, "temperature": 0, "stream": True}
t0 = time.perf_counter()
first_t, last_t, n = None, None, 0
try:
async with session.post(TARGET, json=body,
timeout=aiohttp.ClientTimeout(total=300)) as r:
async for raw in r.content:
line = raw.decode("utf-8", "ignore").strip()
if not line.startswith("data: "):
continue
if "token" not in line:
continue
now = time.perf_counter()
if first_t is None:
first_t = now
last_t = now
n += 1
except Exception as e:
stats["err"] += 1
return
if first_t is None:
stats["err"] += 1
return
stats["ttft"].append(first_t - t0)
if n > 1:
stats["tpot"].append((last_t - first_t) / (n - 1))
stats["out_tok"] += n
def pct(xs, p):
return statistics.quantiles(xs, n=100)[p - 1] if len(xs) > 5 else 0.0
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
stats = {"ttft": [], "tpot": [], "out_tok": 0, "err": 0}
t0 = time.perf_counter()
conn = aiohttp.TCPConnector(limit=CONCURRENCY * 2)
async with aiohttp.ClientSession(connector=conn) as session:
async def worker_loop(wid):
seed = 0
while time.perf_counter() - t0 < DURATION_S:
await one(session, sem, stats, f"{wid}-{seed}")
seed += 1
await asyncio.gather(*[worker_loop(i) for i in range(CONCURRENCY)])
wall = time.perf_counter() - t0
print(json.dumps({
"wall_s": round(wall, 1),
"ttft_p50": round(pct(stats["ttft"], 50), 4),
"ttft_p99": round(pct(stats["ttft"], 99), 4),
"tpot_p50": round(pct(stats["tpot"], 50), 5),
"tpot_p99": round(pct(stats["tpot"], 99), 5),
"throughput_tok_s": round(stats["out_tok"] / wall, 1),
"errors": stats["err"],
}, ensure_ascii=False, indent=2))
asyncio.run(main())
量子化とスケジューリング:互いに制約し合う二つの仕組み
重みの量子化と KV の量子化では得られるものがまったく違う
重みを INT8 / FP8 / INT4(GPTQ、AWQ のようなもの)にすれば節約されるのはモデル側の固定の VRAM で、そのぶんそのままより多くの KV 領域と大きな batch を買える。したがってこれは「構造的な利益」と呼んでいい。KV Cache を量子化して節約するのは 2 × L × H_kv × d × bytes の bytes の部分で、やはり直接に同時実行数を拡大する。量子化する対象が異なり、利益の出方も異なる以上、別々に評価し、別々に判断すべきであって、「量子化を有効にした」という一つの結論にまとめてはならない。
コスト側も異なる。重みの量子化は主としてモデル出力の数値精度に影響し、これはオフライン回帰で担保できる品質の問題である。KV の量子化は系列長の伸長とともにリスクが積み上がる。長いコンテキストと KV 量子化の組み合わせがもっとも問題を起こしやすい組み合わせで、誤差が長い系列の上で累積するため、検索系タスクの精度が明らかに落ちる、あるいは長い推論の途中で軌道を外す、という形で現れる。
FP8 KV のエンジニアリング上の制約
FP8 の KV Cache には attention kernel のネイティブサポートが必要である。FlashAttention 3、FlashInfer、そして各社の推論エンジンのカスタム kernel は、すでに異なる粒度でスケーリング係数を提供している。per-tensor、per-head、per-token。粒度が細かいほど精度は上がるが、kernel 内の追加計算とメモリアクセスも増える。メモリ律速のデコードフェーズでは、この追加オーバーヘッドがそのまま一部の後続実行の利得を食い潰す。したがって「FP8 に変えたら必ずスループットが 80% 上がる」という約束は過剰で、実際の改善幅は kernel の成熟度、batch size、コンテキスト長に依存する。デプロイ前に必ず実際の長いコンテキストタスクで精度比較を行うべきで、困惑度だけを見ていてはいけない。
量子化とスケジューリングの結合点
この点はあまり明示されないが、量子化の目標は「より少ない VRAM でより大きな batch を買う」ことで、より大きな batch はシステムを算力飽和へと追い込み、算力飽和はすべてのスケジューリング系の最適解を別の姿に変えてしまう。
具体的には次の 3 つの形で現れる。
- speculative decoding の利得が落ちる。前に計算したとおり、メモリ律速のときは空き帯域が draft の計算を吸収できるが、算力飽和後は吸収できない。量子化はシステムを高い batch へ押すため、たちょうどその臨界点を越えてしまう。
- 最適な batch size がずれる。同じ
_seqsであっても FP16 よりも FP8 のほうを高く設定すべきで、これまでの経験値はすべて無効になる。 - attention の計算比率が上がる。KV が小さくなると重みの読み出しが 1 ステップ所要時間に占める割合が下がり、attention kernel の効率に重みが移る。attention 実装の質の影響が大きくなる。
順序は必ず まずスケジュール構造を決め、次に精度を決め、最後に speculative decoding を入れる、という形である。逆順で進めると、各ステップの結論が次のステップで覆される。
長いコンテキスト:専用の判断基準が要る
コンテキスト長をただの通常パラメータとして調整するのは、長いコンテキストの環境でもっとも頻出する誤りだ。8K の時代に積み上げた経験の大半は 128K では成立しない。理由は単純で、スケジューラの多くのコストが系列長に比例して増えていくからである。
線形に増えるコストはどこに来るのか
最初の直接的なコストは VRAM 台帳そのものにある。さっきの式に従えば KV の占有量は系列長に比例し、128K のコンテキストは 8K の 16 倍になる。_seqs を変えていなくても、実際に同時に流せるリクエスト数は 16 分の 1 にすぎない。このオーダーの低下はすぐ連鎖反応を起こす。
- 同時実行数が一桁まで落ち、何かのスパイクが飛べばすぐ待ち行列が積み上がり、TTFT の P99 は完全に横ばいになる( まず下がり続けることはない)。
- プリエンプションが非常に高くなる。再計算が一度起きるたびに 1 万、あるいは 10 万トークン以上の prefill をやり直すことになり、代償は通常リクエストの数十倍になる。
- ブロックプールの水位が 90% 以上で長期に振動し、LRU の退避が続き、Prefix Cache のヒット率が退避ノイズで汚染される。
2 つ目のコストは1 ステップ所要時間の分布が非常に偏ることである。128K の prefill はチャンク分割しても各ステップで数万トークンの attention 計算を行う必要があり、この計算量は系列長とともに増える。一方 decode ステップの計算量はほとんど増えない。同じ batch の中でも prefill ステップと decode ステップの所要時間は 1 桁違うことがあり、スケジューラは両者を同時に満たす設定を見つけにくい。
TTFT の非線形な悪化
長いコンテキストでは TTFT は系列長に比例して増えるのではなく、より急峻に増える。理由は待ち行列の長さが利用可能な VRAM に極めて敏感だからである。同時実行数の崩壊が待ち行列を生み、リクエスト数の増加が 1 リクエストあたりの KV ブロックをさらに圧縮し、発生したプリエンプションが一部のリクエストの再計算を招いて占有時間が長くなる。これは正のフィードバックである。
したがって長いコンテキストの容量計画は「1 カードにいくつの KV が載るか」ではなく、「許容できる待ち行列遅延の範囲での安全同時実行数」から出発して逆算する。実務では、さっきの式でまず許容できる待ち行列遅延の範囲での安全同時実行数を求め、それにインスタンス数を掛けて総容量を出し、その逆にトラフィックのピークが容量に収まるかを検証する。余力は短いコンテキストの場合より大きめに取るべきだ。長いコンテキストでは分散そのものも大きいためである。
RoPE 拡張がもたらす追加リスク
モデルが実際にサポートするコンテキストが学習長を大きく超える場合、位置エンコーディングの外挿(YaRN、NTK のような補間方式)が必要になる。この作業はスケジューリングシステムとは無関係にみえて、しかし見落とされがちな影響が一つある。外挿後の分布は元の長さの範囲内とはちがうため、draft モデル、量子化モデル、小モデルによる補助デコードのように「メインモデルの分布に近いこと」に依存する技術の受理率ははっきり落ちる。
言い換えれば、4K のコンテキストでは高い受理率を出していた speculative decoding の設定でも、64K に広げると利得が半減することがある。大きな位置ではすでにメインモデルについていけないからである。よって長いコンテキストでの speculative decoding のパラメータは再度実測し、短いコンテキストの結論をそのまま引き継いではいけない。KV 量子化にも同じことが言える。外挿が入力分布の変化を拡大するため、量子化誤差はそのような分布の上でより拡大しやすい。
長いコンテキストの正しい分解のしかた
ここまで来て、PD 分離の価値が本当に現れる。長い prompt によって prefill が巨大で、算力集約的で、長さが数百ミリ秒から秒単位の独立したフェーズになるため、小さいステップ幅を必要とする decode と同じ場所に混ぜると両方ともうまくいかない。分離したあとは次のようになる。
- prefill インスタンスは大きなチャンク、大きなバッチで高い利用率孢 keeping、単一リクエストの prefill 完了速度を追いかけられる。
- decode インスタンスは小さいバッチで KV を優先し、TPOT の平滑性を追う。
- 両者は KV 転送で結ぶ。長いコンテキストの KV は大きいが、decode に繰り返して読ませるのでなく、1 度転送すればよい。
代償が KV 転送であることは前に述べた。ここでは再-义上的エンジニアリング上の細目を 1 つ補足しておく。KV 転送の所要時間は、それが節約する待ち行列時間より十分小さくなければ、分離は純損失になる。したがって判断基準は、prefill インスタンスが混在バッチによる待ち行列で TTFT の主要部分を占めているかどうかを見てしまう。TTFT のうち待ち行列の割合が小さければ、PD 分離の利得は主にスループット由来であり、転送オーバーヘッドがその一部を食い潰す。
KV 圧縮の利得の逓減
MLA、ステップ横断の KV 圧縮、KV の退避策略のような技術の第一原理的な目標は KV の占有量を縮めることにある。しかし実務では圧縮の利得は逓減する。理由は 2 層ある。
1 層目は占有量がボトルネックでなくなった後である。GPU の算力がすでに使い切られているなら、KV が小さくなってもスループットは伸びず、ボトルネックがメモリから算力へ移っただけになる。このとき圧縮の限界価値は急落する。2 層目は精度の代償が長さとともに積み上がることである。圧縮が攻撃的になるほど長いコンテキストでの情報損失は目立ちますが、長いコンテキスト恰恰は圧縮が解こうとしている問題そのものである。これは内面的な矛盾だ。
私の推奨順序は、まずページングで無駄を最低限まで削り(精度上の代償はゼロ)、次に FP8 KV を入れ(代償は制御可能で、成熟した kernel がある)、最後にのみより攻めた圧縮や退避策略を考える(自分のタスクに対する精度検証が必要だ)。前段を飛ばして攻撃的な圧縮を入れるのは、まだボトルネックになっていない問題に対して精度の代償を払っていることになる。
再現性:性能の数字が揺れるのはなぜか
推論チューニングで最も時間を奪うのは指標の分析ではなく、先週の結論が今週再現できないと気づくことだ。性能の数字が揺れる原因はほぼ分類でき、対処も難しくない。難しいのは最初に遭遇したときはコードを壊的人物だと疑いやすいことだけである。
コールドスタートとウォームアップ
新規に立てたインスタンスが実トラフィックを受けるまでには、明瞭な性能の登攀期がある。重みがディスクやネットワークから読み込まれる、メモリアロケータの初期化、グラフのコンパイルやカーネルの自動チューニング(ある形状に初めて遭遇したときのコストは定常状態より遥かに高い)、KV ブロックプールの事前確保。これらはベンチマークの最初の数十秒に集中して現れ、TTFT の平均値を著しく歪ませる。
規律としてやるならこうだ。ベンチマークの前に固定構成でウォームアップトラフィックを流し、ウォームアップ時間は出現しうるすべての形状(とくに最大 batch size と最大 chunk)を覆う長さだけ取り、ウォームアップのデータは統計に含めない。同時にコールドスタート時の TTFT 分布は別に記録しておく。サービスが頻繁にスケールアウト/インするなら、コールドスタート自体が独立した SLO であり、本番で起きる種類のジッタの多くは拡張が原因だ。
定常状態のノイズ源
定常状態に入っても、次の要因が数字を数パーセント動かしてしまう。
- クロックと電力:コンシューマー向けやロックされていないカードは長時間の負荷でサーマルスロットリングが起き、ベンチマークは後半ほど遅くなる。ラウンド間で比較するとき、温度は隠れた変数になる。
- 隣のノイズ:同じ物理マシン上の他プロセス、共有 PCIe リンク、同一ラック内のファン。
- リクエスト分布のサンプリング揺らぎ:ベンチマークのリクエストのトークン長はランダムで、サンプル数が足りないと平均の分散が大きくなる。
- ヒット率のウォームアップ効果:Prefix Cache のヒット率はベンチマーク初期、共有 prefix が少しずつ「焼ける」につれて上昇する。ウォームアップせずに測るとヒット率は体系的に低に出る。
- GC とメモリコンパクション:一部の実装の周期的なメモリ整備は、観測可能な周期的なスパイクを生む。
対策は同一構成で 3 回以上繰り返して中央値を取ること、リクエスト分布を固定すること(毎回ランダムではなく固定のトークン列を事前生成する)、そしてレポートにベンチマーク時のハードウェア状態を添えることだ。単発のベンチマークの差が 5% 未満なら、改善ではなくノイズとみなすべきである。
信頼できる A/B をやる
チューニング段階の A/B には特別な難しさがある。2 つの構成を同時期に動かすことが難しいまま、時期をずらすとちょうど上記のノイズ源にぶつかってしまう。緩和策はいくつかある。順番に走らせてから切り替えるのではなく交互に動かす(A、B、A、B)、リクエスト列の再生を固定する、そして同じマシンの同じ型番のカードで比較してマシンをまたがないようにする。
もう 1 つ方法論としての提案がある。一度に変える変数は 1 つだけとし、変更の理由をベンチマーク記録に残す。推論システムのパラメータは互いに強く結合しており、3 つ同時に変えてスループットが 20% 上がったとしても、どれが効いたのか分からず、効果が戻ったときに特定もできない。途中に現れる「役に立たないように見えるが実は重要」な結論は、たいていこの規律から生まれている。
最後に 1 点。ベンチマークの結論には負荷の前提を注記するべきだ。残されるべきは「同時実行数 32、入力 2K・出力 300、ヒット率 X% の負荷において、構成 A の TPOT P99 は Y ミリ秒」であって、「構成 A のほうが速い」ではない。前者は 3 か月後に再検証できるが、後者は再推測しかできない。
指標体系をサービスコミットメントに落地させる
前に指標の一覧を示したが、指標そのものが問題を解決するわけではない。難しいのはそれを、対外的に約束でき、対内的に問責できる一本の線に収束させることである。この節では指標をサービスコミットメントに変える方法を扱う。
目標を先に決め、その後に最適化の話をする
ほとんどのチューニングの失敗はスタートラインで起きている。明確な目標が紙に書かれていなければ、変更のたびに判断基準は「なんとなく速くなった」になり、その感覚の解像度は実際の差より遥かに低い。書き下した目標は 3 つの条件を満たす必要がある。具体的な数値があること、対象が明示されていること(どのビジネス、どの種類のリクエストか)、期限があること。「応答を速くする」は目標ではなく、「文書問答シナリオの TTFT の 90 パーセンタイルを 2.4 秒から 1.5 秒以内に下げる」こそが目標だ。
目標は層別にする。対外の用户体験コミットメントが 1 本、内部の可観測性健全線がもう 1 本である。前者は達成の有無とロールバックの要否を判断するために使い、後者は事前警戒に使う。「ヒット率がこの水準を下回ったらアラーム」「プリエンプション回数が閾値を超えたらアラーム」といったもので、ユーザがすでに影響を受けたことを意味するわけではないが、影響が近いことを示している。
コミットメントの基準となるのは分位数
どの分位数で約束するかは、明示的に選ぶべき判断である。平均で約束するのが最も楽で最も危険でもある。平均は大量の速いリクエストに引き下げられるため、事実上もっとも悪い体験を約束の範囲外に捨てていることになる。99 パーセンタイルで約束するのが最も安全だが最も高く、揺らぎが大きい状況ではほぼ達成できない。
実務では 対外コミットメントは 90 パーセンタイル、内部の赤線は 99 パーセンタイルとするのが現実的である。前者は多くのユーザが満足を感じていて,并且コストも制御できる線で、後者は極端場合の受け皿であり、ここに触れた時点でもうシステムが健全な状態にないことを示す。平均は補助的な参考としてしてもよいが、いかなるコミットメントにも登場させるべきではない。
指標は行動に落とせる粒度であるべき
位置特定できない指標は無意味である。総スループットが 10% 下がっても、これを知っていても何をすべきかがわからない。有用な指標とは因果の連鎖に沿って段階的に掘り返せる指標のことだ。スループットの低下はまず需要側の変化か供給側の変化かを切り分け、次に待ち行列長、ブロックプール水位、プリエンプション回数を見て、続いてバッチ内シーケンス数と 1 ステップ所要時間まで掘る。
構築順序としては、まずこの階層すべてにデータがある状態を保証し、その後にさらに細かい原因究明を考える。階層のないダッシュボードはダッシュボードがないより危険である。誤った方向に時間を費やすことになるからである。
ダッシュボードではなくアラームで駆動する
ダッシュボードは事後の振り返りに使うものであり、アラームはリアルタイム対応に使うものである。用途が異なるので互いに代替できない。実際に命を救うアラームには 3 つの特徴がある。閾値がサービスコミットメントから導出されていて、思いつきではなく導出されていること、ユーザの投诉より前に発火すること、発火後そのまま調査すべき方向を指し示すこと。
この基準で通すと、実際に設定してよいアラームはたいてい一桁に収まる。待ち行列長が継続的に閾値を超える(容量不足)、ブロックプール水位が上限に張り付く(メモリ不足)、プリエンプション頻度が異常(構成不一致かトラフィック変化)、Prefix Cache のヒット率が急落(プロンプトが変更された)、受理長の下限が下がる(負荷変化か draft 不整合)。残りはダッシュボードに置き、調査の折り返しに参照するほうがよい。
長時間安定運転:5 分のベンチマークでは答えられない問題
短時間のベンチマークが検証するのは性能であり、長時間の運転が検証するのは正当性である。本番で問題を起こした設定の多くは、10 分間のベンチマークでは無事だった。
長時間の安定運転でしか見つからない 3 種類の問題
- 緩やかなリソース増加:リクエストのオブジェクトが終了時に解放されない、ブロックの参照カウントが漏れている、メモリ使用量が時間とともに単調に上昇する。この種の問題は数時間のスケールでは「後半ほど遅い」という形で現れ、負荷の変動と誤認されやすい。
- 周期的なスパイク:一部の実装には周期的なリソース整備やキャッシュのフラッシュがあり、数分ごとに 1 ステップ所要時間の尖峰として現れる。短時間のベンチマークはたまたまこれを逃すこともあり、ちょうど 1 回だけ含んでノイズとして扱うこともある。
- ステートマシンのリーク:Prefix Cache のブロックテーブルは特定のシーケンスパターン(繰り返しプレフィックスの追加とロールバックなど)で参照の不整合が起きるがあり、ヒット率が時間とともに緩やかに変化したり、ときどきヒット率が崖のように落ちたりする。
どう走らせれば意味があるか
長時間安定テストの要点は「長く回す」ことではなく、負荷が問題を露呈させることにある。人工的なループリクエストではなく本番トラフィックの再生を使うことを推奨する。ループリクエストではプレフィックスが完全に一致するため、Prefix Cache のヒット率が現実から遊離した高値になり、多くの問題を覆い隠してしまう。本番トラフィック再生の利点は、長さ分布、プレフィックスの重複率、形態の混在がいずれも現実のままという点にある。
観測は 1 分ごとにデータを残し、合計値だけを見ないことである。メモリ使用量の曲線、ブロックプール水位の曲線、ヒット率の移動平均、毎秒のプリエンプション回数の移動平均、そして主要なレイテンシーの分位数。実行後にこれらの曲線を描き、上向きのトレンドが 1 本でもあれば追求する価値がある。当時なお性能が悪化していなくてもだ。
長時間安定運転をリリース手順の一部にする
長時間安定テストはリリース手順の固定の工程であるべきで、「問題が出てから思い出す」ではない。妥当な組み立てはこうなる。構成またはモデルを変更するたびに、代表的な負荷をカバーする短時間ベンチマークを 1 回走らせて退行がないことを確認し、重要な変更(モデルの差し替え、エンジンのバージョン変更、量子化方式の変更)にはさらに長時間安定運転を重ねる。
同時に長時間安定の結果をベースラインとして保存しておく。構成、負荷の説明、各指標の曲線と主要な数値である。次回の変更時に直接比較でき、これは「前回は確かこうだった気がする」という記憶よりずっと信頼できる。性能エンジニアリングが本当に依拠するのは賢さではなく、再現可能な記録である。
本番環境におけるパラメータ調整の順序
ここまでのすべてを実行可能な一本の道に収束させる。核心となる原則はどのステップも観測によって証明か反証ができることであり、原因帰属のできない変更は行わない。
ステップ 0:SLO を書く
SLO のないチューニングに終点は存在しない。まず次を明確にする。TTFT の P99 上限、TPOT の P99 上限、目標スループットまたは日次トークン予算、コンテキスト長の分布、出力長の分布、ピーク QPS と昼夜比。この 5 つが後のすべてのパラメータの妥当な範囲を決める。
ステップ 1:まず測る。まず調整しない
デフォルト構成でデプロイして 1 ラウンド基準測定を実施し、先ほどの指標一覧をすべて記録する。TTFT/TPOT の分位数、スループット、プリエンプション回数、ブロックプール水位、prefix ヒット率すべて。基準値が不完全なチューニングは目つぶりのチューニングに等しい。
ステップ 2:まずメモリの台帳を調整し、アルゴリズムには触らない
この層の影響は最も大きく、もっとも飛ばされやすい。さっきの KV の式で理論上の同時実行数を求め、実際の値と照合する。
max_model_len——実際の分布に合わせて設定し、楽をするためにモデルの上限まで設定しないこと。この項目は単一シーケンスが予約する KV の上限を直接決めるもので、同時実行数に対する打撃は全パラメータの中で最大である。128K に設定したのに業務の実際の最大が 16K なら、同時実行数は直接半分に落ちる。gpu_memory_utilization——通信バッファとサンプリング用の余地を残し、0.98 まで詰めない。max_num_seqs——バッチ内の同時実行数の上限。対話系の業務は小さめ、バッチ処理系の業務は大きめにしてよい。増やす前に TPOT P99 を超えないかを確認すること。- KV ブロックプール水位——長期に 100% に張り付いているならメモリが不足している。
max_model_lenを下げるかカードを増やすべきであり、他のパラメータを延々調整し続けるべきではない。
ステップ 3:構造的なスイッチを入れる
メモリの台帳が正しい前提のもとで、順に確認していく。chunked prefill(現代のエンジンはほぼデフォルトで有効)、prefix caching、CUDA Graph / cudagraph モード、非同期スケジューリング。この層の利得は通常もっとも大きく、しかも精度上のリスクはほとんどない。
ステップ 4:精度に触れるのはその後
スケジュール構造が固まったあとで量子化を行い、重みと KV は分けて判断する。完了したら必ず精度回帰を走らせ、とくに長いコンテキストのタスクでは必須である。このステップで SLO を壊してしまった場合はステップ 3 に戻って同時実行数の設定を洗い直す。パラメータ調整で補おうとしてはいけない。
ステップ 5:最後に speculative decoding
speculative decoding は総合スループットを下げうる唯一の最適化であり、最後に置く。判断材料は前の節の基準、すなわち受理長、TPOT、総合スループットの 3 つを同時に見る。P10 の受理長が悪ければまず無効化し、パラメータ調整で救おうとしないこと。
ステップ 6:ベンチマーク回帰と長時間安定運転
すべての変更についてベンチマーク記録を残す。すべて確定したら長時間安定運転を 1 回走らせる(数時間から 1 日)。メモリリーク風の緩やかな増加、ブロックプール水位の時間的ドリフト、周期的なスパイクを重点的に観察する。5 分のベンチマークを通った設定が 1 日で落ちる場合も珍しくない、とくにマルチテナントの混在負荷の下では。
調整順序の早見表
| 順序 | アクション | 期待される利得の大きさ | リスク |
|---|---|---|---|
| 0 | SLO と実際の負荷分布を定義する | — | やらないと全体が解けない |
| 1 | 基準ベンチマークを取り、完全な指標スナップショットを作る | — | やらないと原因帰属ができない |
| 2 | max_model_len / メモリ水位 / max_num_seqs | 最大 | 設定ミスで同時実行数を直接潰す |
| 3 | chunked prefill / prefix cache / cudagraph / 非同期スケジューリング | 大 | 低。主にメモリと退避 |
| 4 | PD 分離(長い prompt の比率が高いとき) | 大 | KV 転送が新たなボトルネックになりうる |
| 5 | 重み量子化 → KV 量子化,一つごとに評価する | 中〜大 | 精度。とくに長いコンテキスト |
| 6 | マルチテナント QoS / レート制限 / プリエンプション政策 | 中 | 政策が複雑で、可観測性の支援が必要 |
| 7 | speculative decoding | 中、負の可能性もある | 高負荷下でスループットが落ちうる |
| 8 | 長時間安定運転と回帰 | — | 時間コスト |
出発点として使える起動構成
具体的なパラメータ名はバージョンで変わるため、デプロイ前に必ず使用バージョンのドキュメントと照合すること。
# 起步配置:结构优先,精度和投机解码先不开
# vLLM V1 系(参数名请对照当前版本文档)
python -m vllm.entrypoints.openai.api_server \
--model /models/your-model \
--served-model-name your-model \
--tensor-parallel-size 8 \
--max-model-len 32768 \
--max-num-seqs 128 \
--gpu-memory-utilization 0.90 \
--enable-prefix-caching \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--port 8000
# 关键解释:
# --max-model-len 32768 按真实 P99 长度设,别设成模型上限
# --max-num-seqs 128 交互业务起点;TPOT P99 破线就往下调
# --gpu-memory-utilization 留通信/采样余量,不要顶满
# --enable-chunked-prefill 现代版本默认开,显式写出来便于核对
# --max-num-batched-tokens 单步 token 预算;TPOT 有周期性尖刺就往下调
# 确认生效与观测
# /metrics 里核对:num_requests_waiting / num_requests_running
# prefix_cache_hits_total / prefix_cache_queries_total
# num_preemptions_total / gpu_cache_usage_perc
# 之后再逐项开,一次只开一个,改完就压一轮:
# 量化(权重) -> 精度回归 -> 压测
# 量化(KV) -> 长上下文精度回归 -> 压测
# 投机解码 -> 接受长度 / TPOT / 总吞吐 三个数一起看
よくある落とし穴:正确に見えるが後で効いてくる手法
チューニングの過程でもっとも高くつくコストは、計算の誤りではなく、つま看来それらしい直感で調整して速さを失い、あとから 1 週間かけて元に戻すことである。以下は推論エンジニアリングで繰り返し現れるパターンである。
max_model_len をモデルの上限に設定する
これは利得がもっとも大きく、もっともよくある誤りだ。このパラメータは単一シーケンスの KV 予約量の上限を決めるが、KV の予約は多くの実装で「最悪ケース」を基準に記帳される。128K に設定したのに業務の実際の P99 が 12K しかなければ、すべての同時実行数を 16 倍のコストで課金していることになる。
正しいやり方は実際の長さ分布から出発することで、绝大多数のリクエストを覆う値(通常は P99.9 付近)を取り、極端に長いリクエストには専用の劣化経路を用意する。切り詰め、分割、あるいは専用インスタンスへのルーティングである。少数の超長リクエストがクラスタ全体のスループットを沈めることが、この種の誤りでもっとも典型的な現れ方である。
平均レイテンシだけを見る
平均値は推論サービスにおいてほとんど意思決定の価値がない。同じ P50 でもサービス体験はまったく違う。「TTFT 平均 800ms、TPOT 平均 18ms」のサービスは、5% のリクエストで TPOT が 200ms を超えている可能性がある(大きな prefill ステップに当たった分)。この 5% はユーザの投诉リストに載るが、平均値への寄与は 1ms にも届かない。
規律はこうだ。すべてのレイテンシ指標は少なくとも P50 と P99 を報告し、容量計画は P99 を基準線に引く。同時に「SLO を満たすリクエストの割合」(goodput)を、対外報告の唯一の主指標とする。尾部がもともと組み込まれているからだ。
speculative decoding を無料の加速とみなす
すでに式と帯域余裕の考え方で説明した。ここでは実践上の形態だけを強調しておく。低負荷のベンチマークで 2 倍に速くなった speculative decoding の構成が、本番のピークで全体のスループットを低下させる。ピーク時には GPU がすでに算力飽和していて、draft の追加計算が純粋なオーバーヘッドになるからである。
判断の順序は必ず次のようにする。まずピーク時の負荷状態を確定し、そのうえで有効にするかどうかを決める。適用後は受理長、TPOT、総合スループットの 3 つを同時に監視し、総合スループットが落ちていれば、TPOT が明確に改善していても無効化する。この判断はオンコールの手引きに書いておくべきで、次の当番者にその場で決めさせるべきではない。
量子化を無料のメモリとみなす
量子化で節約したメモリは確かに同時実行数の増加に変わるが、精度の代償は目立たない業務シーンのどこかで表面化する。たいてい長いコンテキストでの検索、あるいは正確な数値を必要とする多段推論である。この種のリグレッションは標準ベンチマークでは見つけにくい。スコープが小さすぎるからである。
したがって量子化の検証セットには自分の業務で最も長い実際の入力を含まなければならない。公開ベンチマークのサンプルではない。重みと KV も分けて評価する。重み量子化は全体の出力分布に影響し、KV 量子化は長い系列に影響する。退化の様式が違うからである。
chunked prefill のチャンクサイズを極端に小さくする
「チャンクを小さくして TPOT を平滑にする」という推論は正しいが、半分しか正しくない。メモリ律速のデコードフェーズでは、バッチ内のトークン数が少なすぎると GPU の帯域利用率が直接崩れ、prefill のスループットも落ちる。チャンクを小さくすることは総合スループットを TPOT のスパイクと交換することになるが、そのスパイクはもともと P99 のごく一部分にしか影響しない可能性がある。
正しい順序はこうだ。まずデフォルト構成で TPOT P99 の実際のスパイク幅を測る。スパイクが確かに SLO に違反しているなら、そのときに限りチャンクサイズを下げ、下げるたびにスループットを再測定する。スパイク幅が許容範囲なら触らない。デフォルト値の背後には大量の実際の負荷による検証がある。
命中率を上げるためだけに prompt を再構成する
Prefix Cache は利得が最も直接的リスクが最も低い最適化なので、過度に追いかけられやすい。しかし「命中率を上げる」手法が歪むと、代償は別の場所に移る。リクエストからタイムスタンプ、ユーザ識別子、乱数をすべて削除して命中率を上げれば業務上の意味が壊れる。すべての prompt を無理に 1 つのテンプレートへ寄せて考えれば業務の柔軟性が制限される。変化する内容を prompt の先頭に置く(変化性を無視して「最長の共通部分を前へ」という配置)だと命中率はゼロになる。
原則は「長さ」ではなく「変化頻度」で prefix と後続を分けることである。本当に一定な部分はすべて先頭に、毎回変わる部分(一度しか変わらないように見えるものも含む)はすべて末尾に置く。この分け方は業務担当者と必ず確認し、推論側だけで決めないこと。
1 回のベンチマークで長期構成を決める
ベンチマークは通常固定の同時実行数と固定の長さ分布を使うが、本番の到着は波があり、形態も混在している。もっともよくある失敗はピークのスループットに合わせてリソースを配備し、谷のときに大量のアイドルを見ながらも縮められないこと。縮めたあとにピークが落ちるかどうか分からないからだ。
実務的な対処は平均値ではなく分位数で容量計画を立て、夜間のアイドルを許容することである。夜間のアイドリングのコストが本当に許容できないなら、弾力的な構成を考える。、弾力的な構成を考える。「ピーク構成」と「日常構成」を 2 組のパラメータに分け、時刻やキューの水位で切り替える方法は、多くのシーンで「1 組のパラメータで何でもまかなう」より現実的である。
リリース前のチェックリスト
以下の表は全文を実行可能なアクションに収束させた版である。手順の規定ではなく、問題が起きたときに「どの工程を飛ばしていたか」を思い出すための索引である。
| チェック項目 | やり方 | 合格基準 | やらないとどうなるか |
|---|---|---|---|
| SLO が明確 | TTFT P99、TPOT P99、ピークスループットの 3 つの数値を書く | 具体的な数値と目標日がある | チューニングに終点がない |
| 長さ分布が把握できている | 入出力トークンの P50/P99 と超長リクエストの比率を統計する | 分布図を描ける | max_model_len を決められない |
| 基準値が完全 | デフォルト構成で 1 ラウンド取り、全指標のスナップショットを残す | 平均値ではなく分位数が揃っている | 原因帰属ができない |
| メモリの台帳を照合した | KV の式で同時実行数を逆算し、ブロックプール水位と照合する | 理論値と実測値が同じ桁である | メモリの問題かスケジューリングの問題か区別がつかない |
| ブロックプール水位に余地がある | ピーク時の KV 使用率を観察する | 定常状態で 90% を下回る | プリエンプションが続き、再計算の雪崩が起こる |
| プリエンプション回数にアラームがある | 毎秒のプリエンプション回数に閾値を設定する | ピーク時に発火し続けない | プリエンプションの嵐に誰も気づかない |
| プレフィックスのヒット率が観測できる | クエリ数とヒット数のカウントを取り出す | どの業務に起因するか特定できる | ヒット率が落ちても原因が分からない |
| プロンプトを変化頻度で階層分けしている | system prompt の構成を監査する | 先頭にタイムスタンプや乱数がない | ヒット率が黙ってゼロになる |
| 長リクエストに劣化経路がある | 閾値を超えた長さをどう扱うか決める | 明確なルールがある | 1 件のリクエストがクラスタ全体を沈める |
| マルチテナントにレート制限がある | テナント別の同時実行数と待ち行列を統計する | 1 テナントでバッチを埋め切れない | 大顧客が小顧客を餓死させる |
| 量子化の精度回帰が終わっている | 業務で最も長い実際の入力で比較する | 主要指標に有意な低下がない | 品質事故の切り分けができなくなる |
| speculative decoding の 3 数値が揃っている | 受理長 P10、TPOT、総合スループット | 総合スループットが低下しない | ピーク時に全体が遅くなる |
| 長時間安定運転を検証している | 数時間以上連続で走らせる | メモリ増加と周期的なスパイクがない | 深夜に呼び出しを受ける |
| ベンチマークを再現できる | 固定のリクエスト列、同じマシンでの比較、3 回反復 | 同一構成の変動が 5% 未満 | 結論が信頼できない |
| 構成変更をロールバックできる | パラメータに版があり、基準スナップショットもある | 10 分以内に戻せる | 障害時に手が追わない |
カナリアリリース、構成管理、マルチバージョン
チューニングの成果は一組のパラメータであり、そのパラメータを安全に本番へ進めるのは別の話である。スケジューリングシステムの特殊な点はここにあり、構成ミスの影響範囲は全量に及ぶ。バッチサイズの上限を間違えた構成はすべてのリクエストに同時に影響し、カナリアリリースの余地がない。したがって構成管理は通常の業務よりもここで重要になる。
パラメータは必ずバージョン化する
もっとも基本的で、もっとも飛ばされやすい項目。性能に影響するパラメータはすべてバージョン記録を持ち、具体的なインスタンスまでトレースできるようにする。構成は起動コマンドラインにだけ存在するのではなく(それだとインスタンスが自己申告できない)、一元化された構成記録を持ち、インスタンスは起動時に取得して、バージョン番号を自身のメタデータと起動ログに書き込む。
バージョン化の直接的な利点は障害時に初めて現れる。異常が起きたとき最初に行うのは「どのインスタンスがどの構成で動いているか」を確認することだが、この手順が記憶とチャットの記録の掘り返しに依存していれば、対処時間は 10 分ほど伸びる。複数インスタンス・部分カナリアの状態でこの機能は代替不可能である。バージョン番号がなければ、異常が特定のリリースと関係するかを判断できない。
シャドートラフィック
推論サービスは従来型のリクエスト単位のカナリアリリースが難しい。同じリクエストを旧構成と新構成に投げると結果が異なる可能性があり、業務が受け入れないからだ。ただしシャドートラフィックはできる。本番の実リクエストを複製して新構成のインスタンスに送り、結果だけを記録してユーザには返さない。そして両者の性能指標と出力品質を比較する。
シャドートラフィックの価値は、ベンチマークでは見つからない問題を露呈できる点にある。実トラフィックの長さ分布、形態の混在、プレフィックスの重複率はベンチマークとは異なっており、実トラフィックにはさまざまな異常な入力形式も含まれる。代償としてメモリと帯域が 2 倍になるので、通常は一部のトラフィックだけをシャドウし、シャドウ側の処理が過載によって逆に本番のメインインスタンスへ影響しないことを必ず確認する。
構成変更の正しい順序
構成変更の段階的な適用順は「影響範囲が小さい順」にすべきである。まず単一インスタンスで観察し、次に一部インスタンスへ、最後に全量へ。一度に全量へ出して祈るのとは逆である。この順序の価値はここで述べた典型的な障害で特に明白になる。雪崩、ヒット率のゼロへの転落、量子化による退行。3 つとも全量で初めて気づいた場合の代償は桁違いである。
もう 1 つ見落とされがちな規律がある。変更の後は十分に長い時間を待ってから結論を出す。ヒット率、Prefix Cache のブロックテーブル、speculative decoding の受理長はいずれもウォームアップ期間があり、数分で見た数値は定常状態を代表しない。逆に、一部の退行は遅行性で数時間目に現れる。したがって結論を出す適切なタイミングは指標ごとに異なり、一律に「10 分観察」で処理してよいものではない。
マルチバージョンの共存
モデルアップグレードの期間は新旧 2 つのバージョンが必ず共存し、これが 2 つの問題を生む。第 1 にメモリと容量の割り当て。2 つのバージョンそれぞれが最悪ケースのメモリを予約する必要があり、総量が単一バージョンの容量計画を遥かに超えて、アップグレード期間中のサービス能力が落ちる。対策は共存比率を制御するか、切り替え窓の中で一部機能を能動的に劣化させる(例えば一時的に speculative decoding を止め、バッチサイズ上限を下げる)ことで余りを確保することである。
第 2 にルーティングの品質。新旧で性能特性が異なるため、負荷分散が単なるランダム分配や最短キューだけだと、特定の形態のリクエストが体系的に不適切なバージョンへ割り当てられる。切り替え期間には形態考慮のルーティングを使い、どの種類を先に切り替えるか、どの種類を後から切り替えるかを明示すべきである。通常は低リスクで比率の高い形態を先に切り替え、最後に品質に最も敏感な種類を切り替える。
構成ドリフトの検出
最後の長期的な規律はこれだ。「実際に有効な構成」と「期待する構成」が一致しているかを定期的に照合する。ドリフトの要因には、インスタンス上に手動で行った一時的な調整、環境変数で構成ファイルが上書きされること、環境ごとの起動スクリプトの差異、そしてロールバック時に一部インスタンスしか変更しなかったことなどがある。定常的な運用では、すべてのインスタンスの実効パラメータを集めて相互に一致しているか検証し、期待値から外れたものを自動で告警する。
この作業は些細に聞こえるが、「問題のあるインスタンスが正常なインスタンスの中に混ざったまま存在する」ことを防げる唯一の手段である。形態別ルーティング、バッチ単位のカナリアリリースを使うシステムでは、一部のインスタンスの構成の不一致は集計指標の上で持続的な小さな異常として現れる。何もアラームを発火させないのに、パラメータ調整では決して解決できない。
退避戦略:キャッシュが埋まったら誰を捨てるか
Prefix Cache とブロックプールは同じ VRAM を共有するため、「キャッシュ」と「実行中のリクエスト」は実際には同じリソースプールを奪い合っている。プールが埋まったら選択を迫られ、誰を捨て誰を残すかというこの判断は実装の詳細のように見えるが、実際にはスループットとレイテンシの安定性に直接影響する。
もっとも一般的なのは LRU(least recently used)方式で、最後に触れたのがいちばん古いものを捨てる。汎用キャッシュではまずまずの性能を出すが、推論のシーンには 2 つのはっきりした問題がある。1 つはサイズを見ないこと。2 万トークンのシーケンスと 200 トークンのシーケンスでは退避のコストが 100 倍違うのに、「最久未使用」で並べると同じ 1 件として扱ってしまう。もう 1 つは未来を見ないこと。長対話の中で現在のターンをまだ書き終えていないシーケンスは、その履歴プレフィックスが退避されると次のリクエストを最初から再計算することになり、しかもこのシーケンス恰恰がもっとも価値の高いキャッシュである。
目的によるより揃ったやり方は再計算コストで並べ替えること。構築コストが高く、アクセス価値は低いブロックを優先的に退避する。使えるシグナルが 2 つある。1 つはサイズで、大きなブロックは再計算コストが高いが、大きいこと自体が重要である可能性も含んでいる。もう 1 つは年齢で、同じセッションのブロックは一緒に残るようにして、半数を退避することで另一半も意味を失うことを避ける。
さらに別の考え方として、単純に捨てるのではなく能動的に swap out する方法がある。コールドなブロックを解放する代わりにホストメモリへ書き出すが、代償はメモリ帯域への 1 回の書き込みであり、利得は将来ヒットするかもしれないことである。この戦略は「長い対話で、しかも対話の間隔が長い」業務で利得がはっきり現れる。例えばユーザが 10 分考えてから次の問いをするようなシーンである。しかしヒット率そのものが低いなら、書き込みの帯域コストは純粋な浪費になる。swap out を使うかどうかは、実装の都合ではなくヒット率で決めるべきである。まずヒット率を測ってから決める。
実践でもっとも有効な調整は退避にバッファを残すことである。プールが 100% 埋まってから退避を始めるのではなく、7 割か 8 割の時点で開始する。理由は、埋まった時点で退避動作と新リクエストの確保動作が同じ瞬間に大量に発生してスパイクの波を作ることだからで、早めればその波をならすことができる。この変更は小さいが、効果はしばしば明瞭である。プリエンプション回数が減り、スパイクが減り、スループットはほとんど変わらない。
スケジューリング戦略の組み合わせ
前に先着先サービスと後着先サービスの 2 つの基本戦略を説明したが、実運用ではより複雑な組み合わせが必要になる。複雑さの代償は説明可能性なので、組み合わせの段数はなるべく少なくし、1 段増やすたびに「このリクエストがなぜこの扱いを受けたのか」に答えられるようにすべきである。
第 1 層は優先度である。業務ごとに異なる優先度を与えるのはもっとも直感的なニーズであり、実装ももっとも簡単だ。優先度の高いリクエストをキューの先に置き、同一優先度の中では到着順にする。リスクは優先度の設定が一方向に傾くと飢餓は避けられないという点にある。高優先度のトラフィックがシステム容量を超えた場合、低優先度のリクエストは永遠に待たされる。したがって優先度には必ず割当量を組み合わせ、単純にスケジューラの割り込み挿入の仕組みだけに頼ってはならない。
第 2 層はデッドラインである。デッドラインを持つリクエストはある時刻までに完了する必要があるため、この戦略の考え方は「残りの余裕時間」で並べること。もっとも緊急なものを先に回す。ここでの誤りやすい点は、総所要時間の見積値だけを見て、「自分の前に並ぶリクエストがあとどれくらいかかるか」を無視することだが、実際に期限を守れるかを決めるのは後者の方である。実装では通常、リクエストのデッドラインから見積処理時間を引いたものをソートキーにする。必要ならスケジューリングの中で再評価もする。
第 3 層はコスト考慮である。従量課金のクラウド環境ではハードウェアごとにコスト性能が違い、トラフィックの一部を安価だが遅いインスタンスへ回すことができる。しかしこの戦略は前 2 つより適用範囲が狭い。業務がレイテンシに鈍感であることを要求し、かつスケジューラがどのインスタンスが「より割安」かを正確に判断できることを要求する。後者は負荷変動時に非常に誤りやすく、誤った結果として実際に遅いインスタンスへトラフィックが送られ、監視には負荷の偏りしか見えない。
私のおすすめは高々最初の 2 層までを有効にすることである。2 層を超えると挙動の予測可能性が落ち、切り分けの難しさが急激に上がり、利得は通常逓減する。より重要なのは、戦略を 1 層追加するたびに、まず 1 つの問いに答えるべきだ。それが実際に起きた問題なのかどうか。実ケースに裏付けられたことのない戦略はシステムの複雑性を増やすだけで、複雑性そのものが障害の原因になる。
ハードウェア差分とエンジン適応
最後に、見落とされがちな現実を 1 つ。同一の構成がハードウェアによって示す性能差は、同一ハードウェア上で異なる構成が示す差より大きいかも知れない。そしてこれらの差には明確な出所がある。
1 つ目の出所は演算のカバレッジである。推論エンジンの性能は下層の演算実装の質に強く依存し、これらの演算はハードウェアベンダーとオープンソースコミュニティが共同で提供する。カード種別によってカバレッジの広さ、演算の融合度、対応パラメータの範囲はすべて異なる。ある最適化が 1 種類のカードで有効でも、別のカードに移したら対応する融合演算がないために完全に機能せず、元より遅くなることすらある。したがって「この構成は他社のカードで非常に速く動きます」という言葉は、性能エンジニアリングではほとんど参照価値がない。
2 つ目の出所はコンパイルと自動チューニングである。多くの実装は、ある形状に初めて遭遇したときに自動探索して、最適なアルゴリズムと分割方法を選んで結果をキャッシュする。このプロセスはコールドスタート段階で数分間続くことがある。ベンチマークの時間が短ければ、測定したのは未チューニングの状態であり、結論は体系的に悲観的になる。本番トラフィックが起動後まもなくある形状に到達すると、目立った性能ジッタを起こす。ベンチマークの時間は自動チューニングの収束時間を超えていなければならない。そうでなければ測った数字に参照価値はない。
3 つ目の出所はメモリサブシステムである。カードは VRAM 容量、帯域、キャッシュ階層、インターポート方式が大きく異なり、これらは前の分析で述べてきたすべての曲がり角の位置を直接決める。1 枚のカードに何が載るか、メモリ律速でどの程度のバッチを支えられるか、どこから並列分割が割に合わなくなるか。これらの数字は各環境のベースラインにすべきデータであり、デプロイ前に計算しておくべきで、ベンチマークから逆算で求めるべきものではない。
総合すると、正しいやり方はチューニングの流れそのものを標準化し、任意のハードウェアで再実行できるようにすることである。負荷の説明を固定し、ベンチマーク手順を固定し、指標の測り方を固定し、環境ごとの比較用ベースライン表を作成する。利点は現在の構成好不好がわかるだけでなく、ハードウェアを替えたとき再調整すべき方向が可知ることでもある。VRAM が不足しているのか、帯域が足りなくて並列分割を変えるべきなのか、演算力が足りなくて機器を増やすべきなのか。判断材料はその 3 つの数字であり、直感で推測するよりずっと信頼できる。
デプロイ形態とリリースのチェックリスト
性能チューニング以外のエンジニアリング作業も、サービスを長期に運用できるかどうかを決める。ここでは深入りせず、自己点検用のチェックリストだけを残し、明らかな抜けがないかを確認する。
起動とロードについて:重みはどこから来るのか、コールドスタートにいくらかかるのか、ローカルキャッシュはあるのか、キャッシュが失効したあとの再ロードにどのくらいかかるのか。コールドスタートの時間は頻繁にスケール操作が必要なシーンでは拡張の可否そのものを左右する。インスタンスが 3 分かかっても ready にならないなら、トラフィックが急増したときの最初の 2 分はほとんど捌けず、その時間のリクエストはキューに入るか失敗するかのどちらかになる。
レディネスとライブネスについて:ヘルスチェックは何を調べるべきか。プロセスが生きているだけでは不十分である。プロセスは生きているがスケジューラが既に固まっているという状況は実際にあり得る。妥当なやり方は最小のリクエストを 1 つ送って正しい応答を待ち、同時にスケジューラが正常に進行しているかを確認すること。ライブネスとレディネスは分けて、前者が失敗したら再起動し、後者が失敗したら再起動せずにトラフィックから外す。この区別はジッタのシーンで大量の無意味な再起動を避けられる。
スケールアウトとスケールインについて:スケール操作の判断基準は何か、拡張後どのくらいで ready になるのか、縮めるときに処理中のリクエストをどう失わずに保つか。縮める側は通常、新しいリクエストの受付を止め、処理中のリクエストの完了を待ち、それから解放する、という順番になり、拡張よりずっとゆっくりでなければならない。縮める方針が遅すぎると周期的な振動が起こる。拡張して、縮めて、また拡張して、そのたびにコールドスタートと性能の谷底が伴う。
ログと可視測性について:1 ステップの所要時間、バッチ内のシーケンス数、ブロックプールの水位、ヒット率、プリエンプション回数は周期的に報告すべきであり、エラーのときだけ出力するものではない。ログレベルは明確に分けて、性能関連の常規的な指標はデバッグレベルに置き、本簯で指標を見るために高レベルのログを開いてディスクを満說さないようにする。
耐障害について:単一のリクエストが失敗したらリトライするか、リトライが過負荷を悪化させないか、タイムアウトはどう設定するのが適切か。ここに古典的な罠が 1 つある。サーバが過負荷のときは、無期限に待たせるよりエラーを返したほうがよい。待たされたままのリクエストはリソースを占有し続けて、過負荷を長引かせるからである。適時に拒否して高速に失敗させることはレート制限の一部であり、可用性の後退ではない。
このチェックリストには性能に直接関係する項目は 1 つもないが、これらは性能の成果を保てるか否かの前提である。構成ミスのある再起動は、数週間のチューニングの成果を消し去ることがある。
外部メモリか、それとも長いコンテキストか
長いコンテキストのアプリを作るときの判断は繰り返し出てくる。より多くの内容をコンテキストに詰め込むのか、必要なときに外部から検索して注入するのか。実務上これは現実的な選択で、VRAM の開鎖と最初のトークンまでの遅延を直接左右する。
コストの観点から見れば、検索結果の注入は明らかに安い。検索は通常、数千件の結果を返すが、完全な履歴は数十万件の可能性がある。節約されるのは VRAM と prefill の計算力だけでなく、最初のトークンまでの遅延もである。prefill の所要時間は注入する長さに比例するので、長い対話のシーンではこれが主要な待ち時間になりうる。
しかし外部メモリにはそれ固有の代備があり、かも测りにくい種類の代備である。検索は元の順番と文脈の関連を失い、多次トークの指示関係(「それ」「さっきのあなら」)は検索片の中では解決できないことが多い。輪をまたぐ情報の結合は矛盾を生むことがある。モデルの出力責質はこれによって下がり、下がりの幅はタスクが連続性にどるら依存しているかで変わる。おしゃべりなら影响は小さく、複雑な推理なら影响は大きい。
抑備点としてはハイブラッド戦略がある。最近の若干の輪は検索せず完全な履歴のまま残して近接の連続性を確保し、より旧い履歴は検索と要約で注入して情報を落とさない。輪の闘値は業務ごとに調整が必要で、調整の方向は明確である。対話が長くなるにつれて出力責質が落ちるなら、検索で落ちた情報が重要になってきただからによっており、闘値は大きくすべきであり、最初のトークンまでの遅延が容許できないなら注入量が多すぎるので、闘値は小さくすべきである。
より注目すべきは、この選択が反にキャッシュの利得に影響することである。完全な履歴を注入する方式ではプレフィックスがほぼ再利用できない(毎輪変わる)。検索で注入する方式では、検索結果が安定していればプレフィックスの再利用度が高い。ししてハイブラッド戦略を選ぶなら、安定した履歴要約を前方に、検索結果を後方に置けば、責質・遅延・キャッシュの 3 つの利得を同時に得られる。この 3 つの次元は相互に独立ではない、3 つを同時に最適化できる配置を探すほう、1 つずつ最適化するより効率がよい。
最後に、よく見落とされる案を 1 つ。デプロイするエンジンがキャッシュの階層化をサポートしている、つまり Prefix Cache がホストメモリやローカルストレージへ overflowing できるなら、完全な履歴のコストは明らかに下がる。毎回再計算するのではなく、キャッシュを 1 回読むだけになるからだ。このとき選択は「より多くの完全な履歴を残す」側に傾く可能性があり、長いコンテキストの限界コストが「毎回再計算」から「毎回ディスクを読む」に変わる。インフラの機能が上位アーキテクチャの最適解を変えるのであり、だからこそチューニングは継続的に行うべきで一度切り上げればよいわけではない。
チューニングを定着させる
チューニングの過程でもっとも難しいのは、決定的な変更を 1 つ見出すことではなく、その変更が半年後にこっそり戻されないようにすることである。パラメータは漂流し、バージョンは更新され、新人は理解できない値に疑問を抱く。そして「この数字がなぜそこまで大きいのか」を説明できる人間が誰もいなければ、その値は必ず変更される。
1 つ目のことは結論ではなく、判断根拠を書くことである。「バッチ内最大シーケンス数を 128 に設定する」とだけ書かれた結論文書の価値はない。価値があるのは「入力 2000・出力 300・同時実行数 32 の負荷において、この値は 1 トークンあたりの時間の高分位をある線の内側に保つ。上げるとその線を越え、下げるとスループットが 5% を超える幅で落ちる」である。前者は人が検証でき、負荷が変われば再導出できるが、後者は盲目的に継承されるか覆されるかのどちらかしかできない。
2 つ目のことはベースラインをスクリプト化することだ。ベンチマークスクリプト、負荷の説明、指標の収集、結果の保存は、そのまま再実行できる 1 セットのスクリプトとして用意し、入力は構成、出力は完全な指標スナップショットにする。手作業のベンチマークの問題は遅いことだけではない。操作の細部が毎回違うため、2 回の結果が比較不能になる。スクリプト化の利点は時間短縮ではなく、「変更前後を比較する」という操作のコストをほぼゼロまで下げることにあり、それがまさに「一度に変える変数は 1 つだけ」という規律を長期に実行できる前提になる。
3 つ目のことは回帰用のベースラインを確立して長期保存することである。チューニングの成果ごとに構成、負荷、各指標を含む 1 行のベースラインとして記録する。その後のあらゆる変更は直近のベースラインと比較し、乖離があれば今回の変更が影響を及ぼしていることを意味し、説明が必要になる。この比較は複雑である必要はない。表 1 枚と固定した指標の測り方で足りる。その価値は問題が起きてはじめて真価を発揮する。「この異常が今回の変更によるのか、もともとあったのか」を素早く判断できるからである。
4 つ目のことは性能予算を設計レビューに組み込むことだ。新しい機能がコンテキスト長を大幅に増やしたり、新しいリクエスト形態を持ち込んだり、プレフィックス構造を変えたりするなら、その推論システムのリソース消費は設計段階で見積もれる。見積もり方法は前に挙げた式そのままである。コンテキスト長がキャッシュの占有量を決め、リクエストの形態が平均出力長を決め、この 2 つが全体のトークン産出量を決定する。設計レビューで見積もった容量の影響を書き込んでおけば、拡張プランは前もって作れるので、本番でアラームを待つまで待たずに済む。
5 つ目のことは、人的巡視だけに頼らず番人を置くことだ。前に挙げた、本当に設定してよいアラームの共通の特性は、閾値を導出から計算できるのであり、思いつきではない点にある。番人の価値は人の記憶の盲点を埋めることにあり、半年動いているサービスの大半の時間は誰も見ておらず、問題が起きたとき最初に思うのは「どこかおかしい気がする」であって、「ヒット率がゼロになった」ではない。前者は数時間の調査を要し、後者は監視を 1 眼見れば済む。
この 5 つに共通する論理は、一度きりの賢い判断を、繰り返し実行でき個人の記憶に依存しない手順へ変換することである。性能エンジニアリングが後半に達したあともっとも効いてきるのは、上手なテクニックではなく、この手順が半年後も依然として完全な形で動きつづけていることである。
よくある構成をそのままコピーはできない理由
最後に、主題とは直接関係ないが実用的な点を1つ。
ネットや社内ドキュメントにある構成の例は、その大部分を本番にそのまま使うことはできない。理由が著者の間違いということはない。それらの構成は成立していた前提を離れると意味を失うからである。
もっとも典型的な例は、最大コンテキスト長をモデルの物理的上限として書くことだ。この数字を書いた人が意図しているのは「そうすれば最長の入力を扱える」ことだが、実際の業務の入力分布を知らない。この数字がを下げることも知らない。彼のシーンでは影響は小さいが、あなたのシーンではスループットが半減するかもしれない。
もう1つの典型は、メモリ利用率を非常に高い値で書くことだ。その高い値は、作者の環境がメモリに窮していたから必要なものだった。しかし自分の環境に余裕がある状態でその値をまねると、同時実行数を捨てるだけになる。逆に、誰かの低い値をそのまま採用すれば、自分のモデルはそもそもロードできないかもしれない。この種のパラメータは、例の数値を探すのではなく、毎回自分の環境で計算し直すべきである。
もう1つの分類は、純粋に環境に依存する項目である。並列度、バッチサイズの上限、speculative decoding のステップ数である。
これらの数値の正しい値はハードウェア、モデル、負荷の 3 つで決まる。例にある数字を眺めるだけには意味がない。
正しいやり方は、そのパラメータの仕組みと調整方向を知ってから、自分の環境で実測することだ。
そしてこれこそ、本稿で繰り返してきた主線そのものである。
だからこの稿では、直接コピペできる構成ではなく、仕組みと公式、判定基準、判断の順序をできるだけ多く書いた。理由は単純である。仕組みは安定していて移植できるが、具体的な数値は移植できないからである。仕組みを掌握すれば、どの構成からでも自分の環境における数値を導出できる。一方、誰かが作った構成から分かるのは、他人の環境で他の人が何を測ったかだけである。
2026年の進展と、まだ解決していないこと
業界で合意している3つのこと
エンジンは大規模に書き直されつつある。vLLM V1 はスケジューラを一段下げ、KV 管理を統一し、言語の境界を最小にした。得られるのは性能だけではない。保守性も上がり、初期の Python 側のロジックが大幅に減った。
SGLang は RadixAttention によって、複数ターンの対話と構造化された生成を自然に扱える。TensorRT-LLM はコンパイルの路子を選び、形状を特化すると性能の上限は高くなる一方で動的性は落ちる。
SGLang は RadixAttention によって、複数ターンの対話と構造化された生成を自然に扱える。TensorRT-LLM はコンパイルの方式を選び、形状を特化すると性能の上限は高くなる一方で動的性は落ちる。
合意は明快である。V0 時代の「Python スケジューラ + テンソル連結」という構造は限界に達しており、V1 以降のエンジンはすべて「毎ステップの CPU オーバーヘッドを減らす」という方針で書き直されている。
PD 分離は論文から標準アーキテクチャになった。prefill と decode のリソース特性はまったく異なり、混在バッチの代償は広く認識されるようになった。残る技術的課題は KV 転送に集約されている。RDMA の直接転送、KV の圧縮転送、そしてマルチノードでのフロント側の負荷分散の判断である。
KV Cache の階層化が次の戦場になる。GPU メモリには長いコンテキストの全量 KV が入らないため、ホストメモリや NVMe へ階層的に分ける。SGLang の HiCache がその代表であり、中心的な課題は転送遅延とヒット経路の予測である。どの階層から読むかはトラフィックの反復があるかどうかで決まるが、本質的にはヒット率エンジニアリングの戦場を変えただけである。
ネイティブの自己投机は明快な方向である
表現を共有するので受理率も高くなる。2026 年に追うべきは「speculative decoding が使えるか」ではない。
追うべきはモデル側とシステム側の協調設計のほうである。
学習の時点で MTP ヘッドをメインモデルの分布に合わせておく方が、後から draft モデルを別途に付けるよりはずっとはるかに効く。
これは同時に、モデルを差し替えるときは、speculative decoding の利得を選定基準の1つにするべきだということを意味する。
まだ成熟していない方向
適応的スケジューリングにはまだ結論が出ない。理想的には、スケジューラが負荷に応じて複数のパラメータを自動で選ぶべきである。
しかしこれらの判断は互いに結合しており、貪欲な自動調整は振動を起こしやすい。多くの本番システムは、いまでも固定構成と人手によるチューニングのままである。
量子化の利得は逓減している。FP8 はすでに基準線になっているが、その下の FP4 のような形式は精度でも kernel のサポートでもまだ安定していない。長いコンテキストや複雑な推論タスクへの打撃も大きすぎる。短い回答、分類、抽出のタスクは量子化の恩恵に浴せるが、長い推論の連鎖は浴せない。つまり、統一した量子化方針は2種類の負荷を同時に傷つける。
NVIDIA 以外のハードウェアではスケジューリング経路の差が大きい。例えば国内製のアクセラレータでは、演算のカバレッジと kernel の成熟度が、どの最適化を実際に使えるかを決める。ネイティブの FP8 attention がなければ、KV 量子化の利得は割り引く。CUDA Graph の等価な仕組みのチューニングができなければ、動的バッチのオーバーヘッドを押し下げられない。ハードウェアをまたいで移行するときは、ステップ 2 からステップ 7 まで必ずもう一度やり直すべきであり、構成が移植可能だと仮定してはいけない。
性能を金に換算する
性能指標は最終的に事業の問いに応えなければならない。その方案は1年間いくらかかるか、やり切る価値があるかという問である。
この手順を飛ばすと、ここまでのチューニングはすべて技術的な自己満足にとどまる。
コストの2つの面
この分割から得られる帰結が1つある。スループットの最適化は設備台数の削減に直結し、レイテンシの最適化は事業 이탈の削減に直結する。両者は別の台帳に計算されるべきで、同じ指標で測るべきではない。
多くのチームがスループットに集中するのは、スループットは数量化しやすく報告もしやすいからである。レイテンシがもたらす事業上の利得は、事業側の協力がないと見積もれない。しかし実際の大きさはそちらのほうが大きい。
単位コストの計算
もっとも実用的な口径は「100万トークンあたりのコスト」である。計算は簡単で、単位時間に出力された全インスタンスのトークン総数を、その時間の設備コストで割り、さらに100万を掛ける。
実際に間違いやすいのは分母の口径である。動いていない時間帯を含めるのか。闲散期の大量のアイドルを、全時間帯に按分するのか、それとも負荷があった時間帯だけに按分するのか。2つの計算はまったく違う結論になる。
私の推奨は1日全体に按分することである。アイドリングしている設備も実際にお金を使うので、無視すると最適化の判断が歪む。
そして「夜間のアイドル」が明示的に見えるコスト項目になり、前述の弾力的な構成に数量的な根拠を与える。夜間アイドリングの償却コストが弾力構成の運用コストを上回るときには弾力化は划算であり、逆ならそのままアイドルを受け入れればよい。
そして「夜間のアイドル」が明示的に見えるコスト項目になり、前述の弾力的な構成に数量的な根拠を与える。夜間アイドリングの償却コストが弾力構成の運用コストを上回るときには弾力化は划算であり、逆ならそのままアイドルを受け入れればよい。
品質とコストのトレードオフ
そして「夜間のアイドル」が明示的に見えるコスト項目になり、前述の弾力的な構成に数量的な根拠を与える。夜間アイドリングの償却コストが弾力構成の運用コストを上回るときには弾力化は划算であり、逆ならそのままアイドルを受け入れればよい。
これはもっとも数え漏れやすい帳簿である。量子化、小型モデルへの蒸留、出力長の短縮、コンテキスト保持範囲の縮小。いずれもコストを下げられるが、いずれもどこかで品質を失う。
問題はその損失の一部しか技術指標に現れないことである。自動評価セットで測れる低下は明示的で、測れないもの(事業固有の長い裾のシーンや、専門的な判断が必要な微妙な差)は暗黙である。暗黙の側のほうが大きい。
対処はコスト削減を拒むことではなく、品質の受け入れ検査も手順として明文化することです。
つまり公開ベンチマークではなく、事業自前の実データを含む評価セットを合格条件にする。
公開ベンチマークの役割は明らかに劣化した変更を素早く除外することであり、通過の根拠にはしない。この条項はすべてのコスト削減手段に適用される。量子化、より小さいモデルへの差し替え、コンテキスト長の制限のいずれもである。
容量計画における2本の曲線
需要と供給を同じグラフに描けば、容量計画は簡単な問題になる。需要側の曲線はトークン消費量の時間変化であり、リクエスト数とは線形関係ではない。同じ1000件でも出力が10倍長ければ、コストも10倍に増える。供給側は利用可能台数に換算したスループット能力である。
計画で本当に見るのは2本の曲線の相対的な位置が時間とともにどう変わるかであり、絶対値ではない。
昼夜差の大きい事業では、リソースが完全に足りていてもピーク時の待ち行列遅延は悪化しやすい。容量の問題がレイテンシの問題に変わっていく経路である。よくある対策はピーク構成で設計し、谷で縮小すること。これはすでに述べた。
より徹底した対策は、待ち行列理論意义上的准入制御を導入し、過負荷時にシステムが無理に待たせるのではなく、graceful に劣化するようにすることである。
より徹底した対策は、待ち行列理論の意义上的な准入制御を導入し、過負荷時にシステムが列を並べ続けるのではなく、緩やかに劣化するようにすることである。
より徹底した対策は、待ち行列理論の意义上的な准入制御を導入し、過負荷時にシステムが列を並べ続けるのではなく、緩やかに劣化するようにすることである。
本番で最もよくある3種類の障害
ここまでの議論を逆に辿ると、もっともよくある3種類の障害形态が導かれる。これらを識別できることは、いかなるパラメータの助言を覚えるより有用である。
一、超長リクエストによる雪崩
形態:ある時刻に通常を遥かに超える入力長が現れ、あるいは一批のリクエストが同時に投入される。現れ方はブロックプールの水位が数秒で満杯になり、待ち行列が急速に積み上がり、プリエンプションが発生し、連鎖反応が始まる。プリエンプションされたリクエストは再計算が必要になり、再計算はさらに時間を食い、待ち行列はさらに長くなり、水位はさらに高くなる。
原因:連続バッチ処理には単一リクエストのリソース上限がなく、最悪ケースを基準にした予約により超長リクエストの代償が制御不能なまでに拡大する。同時にこの種の��クエストはスケジューリングステップを異常に長く占有し、同期待機のあいだ他のリクエストがすべて止まる。
対処:第1層は准入側で上限を設けること。閾値を超えたら直接拒否するか非同期キューに回し、スケジューラに到達させない。
第2層はバッチ内で単一リクエストのトークン予算に上限を設けること。超長入力は一度に処理せず分割する。第3層はブロックプールの一部を緊急用の余裕として残し、通常の割り当てに使わない。
重要なのはスケジューラに入る前に拦住しておくことで、入ってくると回収コストが極めて高くなる。
重要なのはスケジューラに入る前に止めておくことで、入ってくると回収コストが極めて高くなる。
二、ヒット率が黙ってゼロになる
形態:エラーも出ず、アラームも出ず、スループットがわずかに下がり、待ち行列がわずかに伸びる。「事業量が増えた」と誤認されやすいが、実際にはプレフィックスの共有が壊れている。
原因:プロンプトのいずれかのフィールドが、黙って変化する値に書き換えられた。
よくある出所は、システムプロンプトにタイムスタンプやバージョン番号を足した人がいた、ルーティング層がプロンプトにリクエスト識別子を注入した、事業側が個別化のため先頭にユーザプロファイルを連結した、あるいは複数ターンの上流が黙って要約圧縮を付けた。これらは通常、推論サービス以外のチームが行った変更である。
対処:これは監視の問題であり、調整の問題ではない。
プレフィックスのヒット率に必ずアラームを置き、事業別・リクエスト種別別に分解できるようにする。「どの種類のリクエストのヒット率が落ちたのか」がわかって初めて、原因に辿り着ける。
修復の作業はプロンプトの組み立て方にある。恒定な部分を先頭に、変化する部分を末尾に置き、プロンプト管理にレビュー規約を設けて、どの挿入位置がプレフィックスに影響するかを必ず記させる。
三、量子化による退行が遅れて爆発する
形態:本番に上げてしばらくは正常だったのに、ある特定の業務シーンで突然品質が落ち、チームが長時間かけて初めて量子が原因だと気づく。標準評価セットの指標はほぼ変わらず、ロールバック後もすぐには再現しない。
原因:退行は条件付きであり、特定の入力特徴に依存する。
よくあるのは長いコンテキスト(誤差の累積)、正確な数値が必要なタスク(数値誤差の拡大)、そして希少な入力である。評価セットはこれらの条件をカバーしていない。
対処:量子化を本番へ上げる前には、事業の実際の長尾データで回帰を1回まわすことが必須である。
その回帰では、長い入力と数値に敏感なサンプルを意図的に作っておく。本番へ上げたあとは高速ロールバックのスイッチを残し、重みの量子化と KV の量子化を別々のスイッチに分ける。退行の様式が異なるので、それぞれ戻せるようにしておく必要がある。
この種の障害から得られる教訓はこうである。カバーされていない条件は「問題が起きていないから安全」ではない。単にまだ引き金を引かれていないだけである。
3つを貫く共通点
この3つには共通点がある。引き金となる条件はいずれも推論サービス自身のコードの中にない。超長入力は事業側から来ている。プロンプトの変更はアルゴリズム側から来ている。量子の適用はコスト削減の目標から来ている。
これが、推論サービスのチームが往々にして障害のあとに初めて何がおきたのかを知る理由である。
対処の方向も共通している。
境界条件を明示化して、准入の層まで前倒しする(長さの上限、形態の検証、レート制限)。チームをまたぐ約束を手順として書き出す(プロンプト変更のレビュー)。不可逆な操作には観測可能な合格基準を付ける(量子の回帰セット)。
これらの作業は推論エンジンのコードの中にはない。しかし推論エンジンを本番で長く置けるかどうかを決めているのが、それらの作業である。
鎖をつなげる:1回の最適化を最後まで通す
ここまでの内容は横方向の分解だった。実際のチューニングは縦に進む。一度に1方向だけ最後まで進め、進めなくなったところで方向を変える。以下に典型的な順序を示す。特定のモデルには依存しないが、各ステップの判定基準と成果はいずれも再利用できる。
出発点:P99 の投诉と異常な曲線
症状は TTFT の P50 はまだ正常だが、P99 が本番のアラーム線にしばしば張り付く。集群のスループットは全然使い切れていない。
最初の動作は基準値を取ること。1ラウンド回して完全なスナップショットを得る。データは2つの信号を指している。
待ちキュー長がピーク時に伸び続けており、これは同時実行数が足りないか単一リクエストが遅すぎることを示す。一方、ブロックプールの水位は6割しかなく、メモリがボトルネックではないことを示している。
この2つの信号を並べれば、もっとも頻出の仮定「メモリが足りない」はすでに除外でき、方向はスケジューリングとリクエスト形態に絞られる。
この基準値を取らずに gpu_memory_utilization を調整したりカードを追加したりしていれば、1 ラウンドの反復を丸ごと無駄にしていたことになる。
第1歩:ピークを削る、長入力の切り離し
長さごとにバケットを分けて集計してみると、ピーク時の入力長は二山になっている。1K 前後の塊(短い対話)と、15K 以上の塊(文書問答)だ。
後者はリクエスト数の3割しかないのに、待ち行列時間の8割を寄与している。
このラウンドでやることは1つだけ。chunked prefill が有効になっていることを確認し、1ステップのトークン予算をデフォルトより2段下げる。
判定基準は、TPOT P99 のスパイク幅が確かに下がったこと、そしてスループットを再測定して明らかな損失がないこと。
これが要点で、スループットを再測定しないと、改善なのか負荷を移しただけなのかが判別できない。2回のベンチマークの中央値を取り、変動が3%以内であったので有効と判断した。
第2歩:台帳を突き合わせる、本当の制約を発見する
ピークを削ったあと、P99 は改善したが目標には届かなかった。台帳に戻る。KV の式で同時実行数を逆算して実際と比較すると、実際の同時実行数は理論値の3割程度しかなかった。
排查の方向は max_model_len に絞られた。32K に設定されていたが、事業の P99.9 は 19K でしかない。
余分な1倍の予約が、長リクエストが集中したときにブロックプールの水位を押し上げていた。
max_model_len を 24K に下げると、ブロックプールの水位は8割で安定し、待ちキューは明らかに短くなった。P99 は目標を満たした。
このステップの教訓はこうである。台帳の無駄と台帳の不確かさのどちらも潰す。前者はメモリの無駄で、後者は同時実行数を判断する確信の無駄である。
第3歩:ヒット率を確実に押し上げる
Prefix Cache はデフォルトで有効だったが、ヒット率はほぼゼロだった。プロンプトを監査すると、システムプロンプトに毎分変化するタイムスタンプが埋め込まれていた。しかもタイムスタンプが先頭にあったため、共有プレフィックスは最初のブロックで断ち切られていた。
タイムスタンプをユーザメッセージの末尾に移すと、ヒット率は一気に高い水準へ跳び、TTFT の P50 は予想を大きく超えて低下した。
注目すべきは、この変更が推論サービスの側に一切ないということだ。それでも利得は全工程でもっとも大きかった。
これが Prefix Cache エンジニアリングでもっとも強調すべき点である。エンジンのパラメータの問題ではなく、チームをまたぐ約束の問題だというのだ。
第4歩:形態を分離する
残る P99 の不足分は、出力が長いリクエストに集中していた。これらがバッチを埋めてしまい、短い対話のリクエストまで遅くなっている。
ここまで来て 나서形態を分離するのが正しい判断である。前の構造的なスイッチ(chunked prefill、台帳、ヒット率)はすでに整っていて、分離後の2つのインスタンスはどちらも健全な基準値を持っている。短い対話のインスタンスはバッチが小さくレイテンシ優先。長い出力のインスタンスは speculative decoding を有効にし、大バッチの方向で運用する。
第5歩:最後に初めて精度に触れる
speculative decoding を本番へ上げる前に、3つの数値の検証を一式行った。受理長 P10 が明らかに低かったため、長いコンテキストの外挿が draft の品質に影響していると判断し、短い出力のインスタンスだけで有効にした。
長い出力のインスタンスは追加モデルを使わない n-gram の方式に切り替えた。この高重複なコード系のタスクでは、逆に良好だった。
量子化は最後に評価した。重みから試し、事業の最長の実入力で主要指標に明らかな低下がないことを確認して残した。KV の量子化は、長いコンテキストの回帰で検索精度の低下が現れたので却下した。この工程で能動的に諦めた唯一の最適化である。
「どの最適化をやめるか、なぜやめるか」を明示できることは、スイッチを1つ増やすよりスケジューリングシステムの理解が深まったことを示す。
この5歩の共通点を見返す
5歩のうち4歩は「先に測ってから変える」であり、純粋にエンジニアリングの直覚で動いたのは第3歩だけだった(それでも直ちに基準値で検証された)。より重要なのは、どのステップでも基準値を新たに立て直했다는ことである。変更のあとで基準値を測り直さないと、その後のすべての判断はすでに失効した比較の上に立つことになる。これが、ベンチマークの方法そのものが厳密であることが、対象のシステムと同じくらい重要である理由である。
定点で見ておきたい方向
スケジューラの適応性
この問題の難所はアルゴリズムではなく、これらの判断が互いに結合しており、どれも遅延の効果を持つところにある。バッチサイズを上げる利得は数十秒後にしか指標に現れないが、切り替え自体にはコストがかかる。貪欲な自動調整は振動を生みやすく、レイテンシ指標の周期的な揺れとして現れる。固定パラメータよりも悪い。
実務的な折衷案は「限定的な自動化」である。影響が大きく、切り替えコストの低い少数のパラメータだけを自動調整する(たとえば低負荷のときに同時実行数の上限を自動的に下げ、高負荷のときに上げる)。他は固定のままにする。この方式は本番化のリスクが制御可能で、利得も最も説明しやすい。
KV Cache の階層化と予測
KV を GPU からホストメモリへ、さらに NVMe へ記録していくのは、長いコンテキストのシーンでは避けられない方向である。
その中核的な難問は選択にある。どの階層から読むかは、そのプレフィックスが将来またヒットするかどうかで決まる。階層を間違えれば、利得はゼロになるうえに転送時間の損失まで被る。
現実解となるヒューリスティックとしては、プレフィックスのアクセス頻度で階層を決める、同じターンの内では同じ階層を保つ、対話終了時は退避ではなく階層を下げる、というものがある。これらが正しいかどうかはトラフィックの形態に依存する。ワークロードが安定した事業では作りやすく、トラフィックが急変する事業では保守的にすべきである。中核の指標はいずれにせよ1つだけだ。ヒット率と、それを有効帯域の節約に換算した値である。
モデルとシステムの協調設計
これが私がもっとも注目し、もっとも見落とされていると思う方向である。モデルの学習の時点で MTP ヘッドを持たせていた、あるいは潜在状態が多段予測に対して自然に分離できるなら、speculative decoding の draft の部分はほぼ無料になり、システム側の追加の投資は不要になる。
逆に、システム側が将来 speculative decoding を使うことを知っているなら、学習の時点でこれらのヘッドの受理率を積極的に最適化できる。
これは選定の観点を変える。「このモデルが大きいか」ではなく「あなたの負荷形態において、このモデルのエンドツーエンドの1トークンあたりのコストはいくらか」という問いになる。
後者には、推論側の構成まで含めて計算し入れて初めて結論が出る。
したがって実務上の提案はこうだ。モデル選定のときに推論側の利得も一緒に測る。デフォルト構成でのスループットの数値だけで比較してはいけない。
ハードウェアの境界が動いている
複数の変化が同時に、最適化の可能性の境界を動かしている。新しいハードウェアの単カード VRAM が大きくなり、より大きな KV ブロックプールと少ない分割が可能になった。より速いカード間連携により、TP 通信と PD 分離の KV 転送のコストが下がり、「より大きな論理バッチ」が現実的になった。より低い精度形式のサポートにより、量子化の実現可能な下限がさらに下がった。
だが、いずれの変化も、ここまでの結論をすべて再検証することを要求する。より大きなブロックプールによって、元のバッチサイズ上限がボトルネックでなくなることがある。より速い連携によって、PD 分離が「割に合わない」から「划算になる」に変わることがある。より攻めた精度形式によって、長いコンテキストの精度問題が「受け入れられない」から「受け入れる」に変わる。
ハードウェアを替えることはマシンを替えることと等しく、チューニング順序をもう一度やり直す。これは厳守すべき規律であり、古い構成の直感をそのまま引き継いでよいものではない。
モデルを替えるとき、システム側で何を変えるべきか
モデルの替えはデプロイ側の作業に見えるが、推論システムの中ではほぼ確実に再調整のラウンドを要求する。理由は単純で、ここまでのすべての結論がモデルのいくつかの属性の上に成り立っているが、その属性はモデルを替えるたびに全部が変わるからである。
この節は連動関係の確認リストであり、「調整しなくてよいはず」と誤って判断するのを避けるためのものである。
1つ目はキャッシュの占有規模である。これは層数、キー・バリュー頭数、頭次元、要素あたりのバイト数の4つで決まる。
この4つのどれか1つが変われば、キャッシュの占有量が一桁変わる可能性があり、同時実行数はそれとほぼ反比例する。
圧縮された attention 構造を使うモデルと、従来の multi-head attention のモデルでは、同じ VRAM で支えられる同時実行数が10倍違うことがある。
直接の結果として、前に調整してあるバッチサイズの上限とメモリ水位は、モデルを替えたあとは保守的すぎて容量を浪費するか、激しすぎて起動すらできないかのどちらかになる。したがってモデルを替える最初のステップはベンチマークではなく、あの式で台帳を計算し直すことである。
2つ目はトークナイザである。同じ長さの日本語テキストがいくつのトークンに切り出されるかを決めるのはこれだからである。
同じ内容でも、トークナイザを替えるとトークン数が数十パーセント違うことがある。これはキャッシュの占有量、prefill の計算量、出力長、経路上のトークン化の所要時間を同時に左右する。
より厄介なのは、長い数字列、コード、表、特殊記号のトークン化効率の差が非常に大きくなるという点である。業務にこのような内容が含まれるなら、新モデルの実際の1リクエストあたりのコストは文字数から見積もった結果よりかなり高い可能性がある。モデルを替えたあとは実際の業務サンプルでトークン数を数えるべきで、古い見積りを引き継いではいけない。
3つ目はコンテキスト能力と実際の最適値である。新しいモデルがより長いコンテキストをサポートしたからといって、上限をさらに長く設定してよいわけではない。
長く設定すれば、キャッシュの予約量が増え、同時実行数が減るだけで、利得はゼロである。正しいやり方は新しいモデルの業務分布に合わせて値を選び直すことで、100万トークンをサポートしていても、実際に必要な長さだけを覆えばよい。
業務が実際により長いコンテキストを必要とするなら、これは容量アップグレードの機会である。新しい台帳で設備数を計画し直すべきで、上限を適当に上げて是否能を確かめる、という手順ではない。
4つ目は draft モデルとメインモデルの互換性である。speculative decoding は draft とメインモデルのトークナイザが一致することを要求し、同じ系統のモデルが望ましい。
メインモデルを替えたあとは、以前の draft 方式はほぼ再評価が必要である。受理率が大きく変わる(分布が変わる)こともあれば、互換性がないこともある(トークナイザが違う)。この項目は「再設定」ではなく「再検証」である。受理長と総合スループットを実測する必要があり、古い結論を引き継いではいけない。
5つ目は推論に関係するアーキテクチャ上の特徴である。新しいモデルが多トークン予測のヘッドを備えているなら、speculative decoding の利得の構造はまったく異なり、draft がほぼ無料になり、受理率もより高くなるので、より攻めて有効化できる。
新しいモデルが MoE 構造なら、expert 並列が主要な分割方法になり、通信パターンも重みの配置も変わり、設備間連携への要求も変わる。新しいモデルが推奨する精度形式を変える場合、たとえばより低いビット幅の重みをネイティブにサポートするなら、量子化の方式とその利得の上限も再評価が必要である。
6つ目はデフォルトの挙動と推奨構成である。同じエンジン系統でも、モデルの作者はモデル説明の中に推奨パラメータを書くことがある。その推奨値はエンジンのデフォルト値と異なることがあり、その差が新モデルと旧モデルの性能特性の違いであることが多い。
正しいやり方は推奨構成と現在有効な構成を1項目ずつ突き合わせ、差がある項目を個別に検証することである。推奨構成一式をそのまま採用するのではなく。推奨構成は一般的なシーン向けのものであり、あなたの負荷形態がそのシーンとは限らないからである。
この6つを通ると、より一般的な結論が得られる。推論システムのチューニング結論には賞味期限があり、その期限はモデルの安定性で決まる。モデルの反復が速いほど、基準値は失効する。
したがって 앞에서述べた「基準値をスクリプト化し、結論と導出の過程を一緒に記録する」という2つの作業は、高速に反復するシーンでは価値が倍増する。
これらが決めるのはその回のチューニングの効率ではなく、モデルを替えたあとに同じ水準に戻すまでにどれだけの時間が必要かというところにある。
症状から入る排查経路
チューニングの効率は、問題に遭遇したときの排查の速さで決まる。
以下の経路は、ここまで述べた仕組みを逆順に組み立てた「何が見えるか、何を調べるか」である。各項目は、もっとも検証しやすく、もっとも成立しやすい原因から始める。
症状が TTFT は高く、TPOT は正常という場合。ボトルネックはリクエストが入ってから最初のトークンが出るまでの段階にある。
まず待ちキューの長さを確認する。キューが長いなら容量かスケジューリングの問題だ。キューは長くないのに TTFT が依然として高いなら、単一リクエスト自体が遅いことになる。さらに入力長の分布とプレフィックスのヒット率を確認する。ヒット率が低いなら prefill の計算量が全量になる。ヒット率が高いのに TTFT がなお高いなら、問題は経路側にある。
この順序で調べれば、たいてい最初の2ステップで特定できる。
症状が TTFT は正常、TPOT は高いという場合。リクエストは入り、出力も遅くはないが、トークン間隔が長すぎる。まずバッチ内のシーケンス数がスループットの転折点を越えていないかを見る。越えているならバッチサイズとレイテンシ制約が衝突しているので、レイテンシ制約の下で下げる方向に動く。
バッチ内のシーケンス数は多くないのに間隔が依然として大きいなら、1ステップの所要時間を重点的に見る。
長リクエストの prefill が定期的に割り込むのでないか。繰り返しペナルティ付きの長い出力で、サンプリング段階の占比偏高でないか。複数カード間の同期点が各ステップを遅らせているのではないか。これら3つの区別は、ステップごとの計測プロファイリング1回でできる。
症状がスループットが上がらないが、キューは長くないしブロックプールも満杯でない場合。これはもっとも誤認しやすい類型である。「リソースは足りているが需要が低い」ように見えるからだ。実際にはスケジューラがリソースを喂ききれていないことを意味することが多い。
1ステップの所要時間がスケジューリングのオーバーヘッドに支配されている可能性がある(バッチ内のシーケンス数が少なく、固定コストが十分に償却されていない)。複数カード間の通信が大部分を占めている可能性がある(毎ステップ最も遅いカードを待っている)。経路内に計算パイプラインを繰り返し空にする同期点がある可能性もある。
判断方法は、低負荷の状態で1ステップの所要時間とバッチ内シーケンス数の関係を見ることだ。線形ならまだ飽和していないので、バッチサイズを上げれば解決する。横ばいならすでにボトルネックなので、方向は1ステップのオーバーヘッドを減らすか、並列分割を改善する方に移る。
症状が時間とともに性能が悪化する場合。まず負荷の変化ではなくリソースのリークを疑う。
VRAM の使用量とキャッシュの水位が単調に上昇していないか、リクエストの終了後に該当リソースがゼロに戻るかを確認する。曲線が滑らかに上昇するのではなく階段状なら、特定のリクエスト形態と関係していることが多い。繰り返し起きるプレフィックスの追加とロールバックなどである。
もう 1 つよくある原因は、自動チューニングのキャッシュが徐々に膨らむことによる検索と再計算のオーバーヘッドの増加である。
対処は手動でのクリーンアップではなく、そのリクエスト形態を再現して、どの段階のリソースが正しく解放されていないかを特定することである。
症状が周期的にジッタchaftむ場合。まず周期がどれくらいで、どのシステム挙動と合っているかを確認する。固定の分単位の周期なら、内部の周期的なメンテナンス、GC、監視の収集、ログのフラッシュを先に疑う。
症状が周期的に揺れる場合。まず周期がどれくらいで、どのシステム挙動と合っているかを確認する。固定の分単位の周期なら、内部の周期的なメンテナンス、GC、監視の収集、ログのフラッシュを先に疑う。
業務に関連する周期なら、トラフィックの波がスケールアウトとスケールインを往復させていることを疑う。長リクエストに関連する周期なら、長リクエストの prefill ステップが短リクエストを周期的に遮っていることを疑う。この種のジッタはレイテンシの曲線上には規則的なスパイクとして現れ、入力長を時刻ごとにバケット分けて描けば容易に確認できる。
ある種類のリクエストだけが極端に遅く、他は正常な場合。これはスケジューリングではなくリクエスト形態の問題である。まず、この群のリクエストの長さ分布が全体から大きくずれていないかを確認する。
長い入力・短い出力なら、まず chunked prefill の構成とプレフィックスのヒット状況を見る。短い入力・長い出力なら、サンプリング段階の追加オーバーヘッドと speculative decoding の有効・無効を見る。非常に長いコンテキストなら、キャッシュ水位とプリエンプション回数を見る。構造制約のある出力なら、候補の切り詰めと構文の状態機械を見る。形態を確認したあとは、対処は前述の各節に対応するものが揃っている。
エンジンのアップグレード、またはハードウェアの交換後に性能が低下した場合。この類型はパラメータ調整から始めず、「どの仮定が変わったか」から始めるべきである。
エンジンのアップグレードは、デフォルトで有効になる機能、デフォルトパラメータ、スケジューリング方針の実装を変えることがある。ハードウェアの交換は、メモリ律速と計算律速の比率、コンパイルキャッシュの収束挙動、並列分割の費用対効果を変えることがある。正しいやり方は、新旧のバージョン構成とハードウェアパラメータを表に並べて1項目ずつ比較し、差がある項目を洗い出してからその項目を検証することである。デフォルト値から盲目に調整するのではなく。アップグレード後の最初の作業は、古い基準値を再現することである。基礎性能に退行がないことを確認してから、新しい機能の話をする。
この経路が前提にしている論理は、もっとも可能性が高くもっとも検証しやすい原因を先に排除し、そのあと段階的に下へ進むというものである。
排查時間の多くは、上層の検証を飛ばして深い層のパラメータを直接変えたことに消費されている。
おわりに
この流れを振り返ると、規則は実際はっきりしている。推論性能の問題は、ほぼすべて「リクエスト間でリソースの配分が偏っている」問題である。静的バッチ処理は短いリクエストを長いリクエストに待たせる。連続バッチ処理は長い prefill で decode をブロックする。ページングによって、メモリを最悪ケースぶん先に確保しておく必要がなくなった。Prefix Cache によって、重複する計算は 1 回で済む。speculative decoding によって、安い計算が高価な直列ステップを置き換える。マルチテナントのスケジューリングによって、リソースの配分はある大顧客が一手に抱えてしまうことがなくなった。
そして「よりよい配分」1 つにつき、必ず新しい代償が伴う。整齐さは無駄と引き換えになり、ページングは間接アドレス指定のオーバーヘッドと引き換えになり、ヒットした再利用は VRAM を圧迫し、無損失な高速化は acceptance rate への依存と引き換えになる。エンジニアリングの実質とは、これらの代償のあいだで、現在の負荷における最適な釣り合いを見つけることに尽きる。そしてその釣り合いは定数ではない。プロンプト長、出力長、同時実行数、コンテキスト分布のドリフトに合わせて動く。
したがって,唯一信頼できる方法論は次のとおりだ。まず TTFT と TPOT の分位数を実測し、スループットとレイテンシの Pareto フロンティアを描き、そのうえで「VRAM 台帳 → 構造スイッチ → 精度 → speculative decoding」の順序どおりに変数を 1 つだけ動かして、各ステップを同じベンチマーク手順で検証する。どれか 1 つでも飛ばすと、得られるのはあるテストスクリプトの下では見栄えのよい数字の組であって、本番環境で生き残れるサービスではない。
最後に、この記事全体を 1 本の主線にまとめると、こうなる。推論システムの性能問題とは、本質的にリソースの配分がリクエスト間で粗すぎるということである。静的バッチ処理は配分の単位をバッチ全体に設定するので、短いリクエストが長いリクエストに拉致される。ページングは配分の単位をシーケンス全体から小さなブロックへ切り刻むので、VRAM がようやくオンデマンドに使える。Prefix Cache は重複する計算を 1 度きりにするので、固定のプロンプトは何度も課金されなくなる。speculative decoding は直列ステップを一度の並列検証へ置き換えるので、下書きの計算力がまとめて処理される。連続バッチ処理と chunked prefill はスケジューリングの単位をリクエストからトークンとブロックへ切り刻むので、長いタスクが短いタスクをブロックしなくなる。プリエンプションと公平性は、リソース不足のときに損失を誰が負担するかを決める。
この主線から、全文を貫く判断基準が導かれる。ある最適化が配分を細かくはしても、誰に対して配分するのかという問いには答えを出していないなら、ある負荷のもとでは裏返る可能性がある。ページングは配分を細かくするが、退避バッファを用意しないと、細分化がむしろスパイクを招く。speculative decoding は計算を安くするが、負荷がすでに計算力飽和していれば、追加の計算は純粋なオーバーヘッドになる。Prefix Cache は計算を再利用するが、再利用されるブロックも VRAM を消費する。ヒット率が非常に高いときは、独立した同時実行数が逆に下がることもある。精度とレイテンシのあいだにあるすべての緊張関係は、この 1 本の線で説明できる。
状況の異なる読者に向けて、直接的な提案を。初めて構築するなら、パラメータを正解にすることより順序どおりに進めることほうがよほど重要だ。まず連続バッチ処理と chunked prefill が有効になっていること、コンテキスト長を実際の分布に合わせて設定すること、Prefix Cache に監視があることを確認し、そのあとで別の話をする。この基礎を正しく作れば、以降のどの最適化にも余地が残る。基礎を間違えれば、調整すれば調整するほど混乱する。すでにしばらく動かしていて、指標の調子が悪いなら、いちばん効くのはベースラインを作ることだ。TTFT と TPOT の高分位値、スループット、プリエンプション回数、キャッシュの水位、ヒット率を揃えて並べ、どの指標がおかしいかを見る。多くの場合は答えが直接、VRAM 台帳かプロンプト構造のどちらかを指しており、この 2 つはたまたま変更量が最小で効果が最大のところである。
「エンジンを使う」のではなく「エンジンを作っている側にいるなら」、ここでもっとも参考になるのは逆向きの制約のほうである。バッチサイズの曲線には折れ点が 1 つではなく 2 つある。スケジューリングステップのなかではフォワード計算だけが必須コストである。サンプリング段階は長出力のシナリオでは長さとともに線形に高くなる。退避が遅すぎるとスパイクが生じる。これらは「どの最適化がどんな条件で破綻するか」のチェックリストであり、「どの最適化が効くか」のチェックリストより長く、それだけ有用である。効くものは他の人が教えてくれるが、破綻しない境界は自分で踏み固めてしか得られないからである。
学習のしかたについても一言だけ触れておく。この分野の変化は速く、エンジンのバージョン、ハードウェアの能力、さらに最適なバッチサイズ戦略すら変わるため、具体的な結論の半減期は短い。しかしメカニズムと公式の半減期は長い。帯域による平準化の論理は、世代を重ねたハードウェアに変わっても失効しない。prefix hash の連鎖構造は、エンジンを変えても変わらない。第 1 百分位を平均値で代替できないことが時代遅れの書き方になることは、有り得ない。したがって、ある技術資料を読む価値があるかどうかの判定基準は、「なぜこうなっているか、どんな条件で成立しなくなるか」を明示しているかどうかである。数字だけがあって条件が書かれていない資料の参考価値は一度限りで、メカニズムを説明している資料こそ、自分のまったく異なる環境へ持ち運べる。