3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMでのRAG の回答が遅い。それ毎回同じ長文を prefill していませんか? — prefix cache / RadixAttention を RTX 4070 で実測

3
Posted at

はじめに

RAG や Agent を運用していると、ほぼ毎リクエスト同じ長文を先頭に積んでいます。system prompt、参照ドキュメント、tool 定義——これらは質問が変わっても変わりません。にもかかわらず、素朴に動かすとエンジンは毎回その長文をゼロから prefill します。RAG のコードを書きながら自分の実装を見返したら、見事にやっていました。

このブログではこれまで、量子化・投機デコード・KV キャッシュ・エンジン選択と、生成を速くする手段を測ってきました。どれも要は「生成という計算をいかに速くするか」の話です。ですが、もう 1 つ方向の違う手段があります——「同じ計算を二度しない」。一度計算した prefix の KV cache を使い回す、prefix cache (vLLM) / RadixAttention (SGLang) です。

この記事はそれを RTX 4070 12GB で実測します。先に結論を言うと、同じ prefix の 2 回目以降、最初の 1 文字までの待ち時間 (TTFT) が最大 88% 落ちました。そして prefix が長いほど効果が大きい——つまり RAG の長文ほど効きます。

仕組み — 何がキャッシュされ、何が再利用されるか

prefix cache が効くのは prefill 側です(prefill は入力を一括処理して最初の応答までの時間を決める段階、decode は 1 token ずつ生成する段階)。

  • vLLM (automatic prefix caching): KV cache を 16 token の block 単位で管理し、先頭から一致する block の KV を再利用します。次のリクエストが同じ prefix で始まれば、その分の prefill をまるごとスキップできます。デフォルトで有効。
  • SGLang (RadixAttention): リクエスト群の prefix を radix tree(基数木)で共有管理し、共通する接頭辞の KV を木のノードとして使い回す仕組みです。こちらもデフォルトで有効。

どちらも「先頭が一致する部分の prefill を省く」点は同じです。末尾だけ違う質問を投げれば、共通の長い prefix はキャッシュにヒットし、差分の質問文だけを新たに prefill すればよくなります。RAG の「同じ文書 + 違う質問」はこの形そのものです。

測定設計 — cold と warm を分離する

prefix cache の効果を測る難しさは、「キャッシュに乗る前 (cold)」と「乗った後 (warm)」をきれいに分けることにあります。普通に同じプロンプトを 2 回投げると、2 回目は当然速くなりますが、それが本当に prefix cache のおかげなのか、他の暖機(JIT/autotune)のおかげなのか切り分けられません。

そこで cold/warm を分離する計測クライアントを書きました。中心はこのループです。試行 r ごとに先頭マーカを変えて prefix を毎回 cold に戻し、同じ prefix へ末尾の質問だけ変えて 2 回投げます。

# 暖機: JIT/Triton を温めるため、別の捨て prefix で 1 回だけ投げて捨てる
warmup = "(warmup throwaway)\n" + prefix[:512] + "\n\n" + question_a
request_stream_once(warmup)            # 結果は統計に含めない

# 計測本体: R 回の cold/warm ペアを逐次送信
for r in range(1, runs + 1):
    marker = f"Session ID: {r}\n"      # 試行ごとに変わる先頭マーカ
    p = marker + prefix                # → サーバから見てこの prefix は毎回「新規」

    # cold: この prefix を初めて prefill する回
    cold = request_stream_once(p + "\n\n" + question_a)
    # warm: 同じ prefix は既にキャッシュ済み。末尾の質問だけ違う
    warm = request_stream_once(p + "\n\n" + question_b)

    cold_ttft.append(cold["ttft_ms"])
    warm_ttft.append(warm["ttft_ms"])

TTFT そのものは streaming(SSE)で測ります。stream=True でリクエストし、最初に中身のある chunk が届いた時刻と送信時刻の差を TTFT、以降の chunk 間隔を ITL として記録するだけです。

def request_stream_once(full_prompt) -> dict:
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": full_prompt}],
        "max_tokens": 128, "temperature": 0,
        "stream": True,
        "stream_options": {"include_usage": True},          # prompt_tokens を取得
        "chat_template_kwargs": {"enable_thinking": False},  # Qwen の thinking を無効化
    }
    req = urllib.request.Request(
        url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Content-Type": "application/json"}, method="POST",
    )

    start = time.time()
    first_content_at = 0.0
    previous_at = 0.0
    itl_ms = []
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        for raw_line in resp:                       # SSE を 1 行ずつ読む
            line = raw_line.decode("utf-8").strip()
            if not line.startswith("data:"):
                continue
            data = line[5:].strip()
            if data == "[DONE]":
                break
            chunk = json.loads(data)
            content = chunk["choices"][0].get("delta", {}).get("content", "")
            if not content:
                continue
            now = time.time()
            if first_content_at == 0.0:
                first_content_at = now              # ← 最初のトークン到達 = TTFT
            if previous_at:
                itl_ms.append((now - previous_at) * 1000)  # 直前トークンとの間隔
            previous_at = now

    return {"ttft_ms": (first_content_at - start) * 1000,
            "p50_itl_ms": statistics.median(itl_ms) if itl_ms else 0.0}
  • 各試行の先頭にユニークなマーカを付け、試行ごとに prefix を新規(cold)に戻します。
  • 同じ試行内で末尾の質問だけ変えて 2 回送り、1 回目を cold、2 回目を warm として TTFT を測ります。prefix は完全共有なので、warm は prefix にヒットするはずです。
  • JIT/autotune の暖機は別の捨て prefix で 1 回だけ行い、統計から除外します。これだけやらないと「2 回目が速い」を素直に喜べません——ベンチは疑ってかかるものです。
  • 各条件 R=5 試行の median を取り、p50 / p95 を併記します。

比較は 4 構成 × 4 ワークロード:

config framework cache
vllm_prefix_on vLLM on(--enable-prefix-caching
vllm_prefix_off vLLM off(--no-enable-prefix-caching
sglang_radix_on SGLang on(RadixAttention 既定)
sglang_radix_off SGLang off(--disable-radix-cache
workload prefix 長 (実測 tokens) 想定
short_512 555 短い system prompt
sys_2k 2,206 標準的な system prompt
rag_4k 4,340 RAG 文書 1 本
rag_8k 7,412 長文 RAG(本命)

共通条件: 同一文書を文字数で切り詰めて prefix 長だけを振る/モデル RedHatAI/Qwen3.5-4B-FP8-dynamic/flashinfer/vLLM は --enforce-eagermax-model-len 16384gpu-memory-utilization 0.90/temperature 0/concurrency 1 逐次。off 構成は「再利用しない世界」のベースラインとして、warm ≈ cold になることを確認に使います。

結果① — 削減率は prefix が長いほど上がる

まず本命、cache on での TTFT 削減率(warm が cold からどれだけ落ちたか):

prefix 長 vLLM on SGLang on
~555 tok 55.6% 0.9%
~2,206 tok 78.7% 85.3%
~4,340 tok 83.4% 90.8%
~7,412 tok 88.5% 80.2%

prefix が長いほど削減率が上がるのが両エンジン共通の傾向です。これは直感どおりで、prefix が長いほど「省ける prefill」が大きいからです。RAG の長文ほど効く、というメッセージがそのまま数字に出ています。vLLM は 8k で 88.5%、SGLang は 4k で 90.8% がピークでした。

結果② — warm TTFT は prefix 長にほぼ依存しなくなる

削減「率」だけでなく、実際の TTFT (ms) の絶対値を見ると、もっと本質的なことが起きています。

prefix 長 vLLM cold vLLM warm SGLang cold SGLang warm
~555 tok 117 52 71 71
~2,206 tok 254 54 256 38
~4,340 tok 337 56 493 45
~7,412 tok 510 59 862 170

cold TTFT は prefix 長に比例して伸びます(vLLM は 117→510ms、SGLang は 71→862ms)。当然で、長い文書をゼロから prefill するからです。

ところが warm TTFT はほぼフラットです。vLLM は prefix が 555 でも 7,412 tokens でも 52〜59ms にほぼ張り付き、SGLang も(512 を除けば)38〜170ms に収まります。

これが prefix cache の本質です。2 回目以降の TTFT を「prefix の長さに比例する量」から「ほぼ定数」に変える。RAG で参照文書をどれだけ長くしても、2 回目以降の応答開始の速さは(ほぼ)変わらない——運用上とても嬉しい性質です。

結果③ — off は削減ゼロ。効果はキャッシュ由来である

「2 回目が速いのは本当に prefix cache のおかげか?」を確かめるのが off 構成です。

prefix 長 vLLM off 削減% SGLang off 削減%
~555 tok 6.1% 0.0%
~2,206 tok 1.6% -0.0%
~4,340 tok 0.8% 0.1%
~7,412 tok 0.3% -0.1%

cache を切ると、両エンジンとも削減率はほぼ 0%(cold ≈ warm)。vLLM off の 8k で warm も 1,012ms と、cold の 1,016ms とほぼ同じです。つまり on で見えた最大 88% の削減は、暖機やノイズではなく間違いなく prefix cache / RadixAttention の効果だと確認できました。off は「同じ長文を毎回 prefill し直す世界」そのもので、TTFT は prefix 長に比例し続けます。

結果④ — vLLM と SGLang の分岐点

同じ「prefix 再利用」でも、2 エンジンには明確な違いが出ました。

短い prefix (512 tok) では vLLM だけが効きます。 vLLM は 555 tokens でも 55.6% 削減しますが、SGLang RadixAttention は 0.9%(ほぼ非ヒット)。SGLang は ~2k 以上で初めて強く効き始めます。短い system prompt 程度の再利用なら vLLM が拾ってくれます——実運用で効く差です。

長い prefix では vLLM の warm がよりフラットです。 8k で vLLM の warm TTFT は 59ms に対し、SGLang は 170ms まで上がります。一方で SGLang は cold が遅め(8k で 862ms vs vLLM 510ms)なので、初回の重さと 2 回目以降の安定性のトレードオフがあります。

なお decode 側の token 間隔 (ITL) は SGLang ~14ms < vLLM ~17〜18ms で、SGLang がわずかに速い傾向です。これは並列・scheduler を扱った前シリーズの所見とも整合します。

結論

RTX 4070 12GB・Qwen3.5-4B FP8 での prefix cache / RadixAttention 実測から言えることです:

  1. 2 回目以降の TTFT は最大 88% 落ちます(vLLM rag_8k 88.5%、SGLang rag_4k 90.8%)。prefix が長いほど削減率は上がります。RAG の長文ほど効きます。
  2. warm TTFT はほぼ定数になります(vLLM ~55ms、SGLang 38〜170ms)。prefix 長に比例していた応答開始の遅さが、再利用後はほぼ一定になります。
  3. off は削減 ~0%。効果は確かにキャッシュ由来で、暖機やノイズではありません。
  4. 短い prefix なら vLLM(512 で 55.6% 効くが SGLang は非ヒット)、長文 RAG はどちらも強い(vLLM は warm がフラット、SGLang は cold が重いが warm は十分速い)。
観点 結論
効果の大きさ 2 回目以降の TTFT を最大 88% 削減
prefix 長との関係 長いほど削減率↑、warm はほぼ定数化
効果の出る下限 vLLM は ~512 tok から、SGLang は ~2k から
off との対照 off は両者 ~0%。効果はキャッシュ由来
エンジン選択 短 prefix は vLLM、長文 RAG はどちらも有効

①〜④の手段で「生成を速くする」ことを突き詰めてきましたが、RAG / Agent のように同じ長文を繰り返し投げる用途では、prefix を再利用するだけで応答開始が桁違いに速くなりますしかも多くの場合デフォルトで有効です。 「毎回同じ長文を prefill していないか」——まずそこを疑う価値があります。それが今回の実測の結論です。


実験環境: RTX 4070 12GB / Ubuntu / vLLM nightly (v0.21.1rc1.dev243) / SGLang lmsysorg/sglang:latest / モデル: RedHatAI/Qwen3.5-4B-FP8-dynamic / flashinfer / max-model-len 16384 / temperature 0 / concurrency 1 逐次 / 暖機別 prefix 1 + 計測 R=5 median

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?