LabHub

ブログ

LLM Speculative Decoding サービング最適化プレイブック

한국어English日本語

LLM Speculative Decoding サービング最適化プレイブック

プレイブックの概要

本ドキュメントは、LLM サービングに speculative decoding を導入する際に従うべき段階別の実行ガイドである。概念の説明よりも「どの順序で、どの設定で、どの基準で判断するか」に焦点を当てた。

Speculative decoding の中心的なアイデアは単純だ。小さく速い draft モデルが複数のトークンを一度に推測し、大きく正確な target モデルがそれを一度の forward pass で検証する。Draft トークンの大半が受理(accept)されれば、target モデルが一トークンずつ生成するより 2-3 倍速い。原論文は Leviathan et al.(arXiv:2211.17192) が提示したもので、出力分布が target モデルと数学的に同一であることが証明されている。

Phase 1: 現在のサービング状態を測定する

speculative decoding の導入前に、現状を定量的に把握しておく必要がある。比較の基準がなければ効果を証明できない。

ベースライン測定スクリプト

import time
import json
import statistics
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")

def measure_baseline(
    model: str,
    prompts_file: str,
    num_runs: int = 3,
) -> dict:
    """現在のサービングの latency/throughput ベースラインを測定"""
    prompts = json.load(open(prompts_file))

    all_ttft = []    # Time To First Token
    all_tpot = []    # Time Per Output Token
    all_e2e = []     # End-to-End latency
    total_tokens = 0

    for run in range(num_runs):
        for prompt in prompts:
            start = time.perf_counter()
            first_token_time = None
            token_count = 0

            stream = client.chat.completions.create(
                model=model,
                messages=[{"role": "user", "content": prompt["text"]}],
                max_tokens=prompt.get("max_tokens", 256),
                temperature=0.0,
                stream=True,
            )
            for chunk in stream:
                if chunk.choices[0].delta.content:
                    if first_token_time is None:
                        first_token_time = time.perf_counter()
                    token_count += 1

            end = time.perf_counter()

            all_ttft.append(first_token_time - start)
            all_e2e.append(end - start)
            if token_count > 1:
                all_tpot.append((end - first_token_time) / (token_count - 1))
            total_tokens += token_count

    return {
        "ttft_p50_ms": round(statistics.median(all_ttft) * 1000, 1),
        "ttft_p95_ms": round(sorted(all_ttft)[int(len(all_ttft) * 0.95)] * 1000, 1),
        "tpot_p50_ms": round(statistics.median(all_tpot) * 1000, 1),
        "tpot_p95_ms": round(sorted(all_tpot)[int(len(all_tpot) * 0.95)] * 1000, 1),
        "e2e_p50_ms": round(statistics.median(all_e2e) * 1000, 1),
        "e2e_p95_ms": round(sorted(all_e2e)[int(len(all_e2e) * 0.95)] * 1000, 1),
        "total_tokens": total_tokens,
        "avg_tokens_per_sec": round(total_tokens / sum(all_e2e), 1),
    }

# 使用例
baseline = measure_baseline("meta-llama/Llama-3.1-70B-Instruct", "eval_prompts.json")
json.dump(baseline, open("baseline_metrics.json", "w"), indent=2)
print(json.dumps(baseline, indent=2))

測定項目の定義

指標説明期待される改善幅
TTFT (Time To First Token)最初のトークンまでの待機時間変化なしか小幅に増加
TPOT (Time Per Output Token)トークンあたり生成時間2-3x 改善
E2E Latency応答完了までの総時間1.5-2.5x 改善
Throughput (tokens/sec)秒あたり生成トークン数1.5-2.5x 改善
Accept ratioDraft トークンの受理率0.6-0.85 が目標

Phase 2: Draft モデルの選択

Draft モデルの選択が speculative decoding の性能の 70% を決める。誤った選択をすると、かえって baseline より遅くなる。

選択基準と候補

Target モデル -> Draft モデルのマッチングガイド

Llama 3.1 70B  -> Llama 3.1 8B (同じ family、語彙が同一)
                  または EAGLE-3 draft head (学習が必要、最高性能)

Mistral Large  -> Mistral 7B (同じ tokenizer)

Qwen 2.5 72B  -> Qwen 2.5 1.5B または Qwen 2.5 7B

自前学習モデル  -> 同じ tokenizer の小型モデル
                  または Medusa head / EAGLE head の学習

Draft モデル種別ごとの比較

種別代表手法Accept ratio追加メモリ学習の要否論文
独立した小型モデルVanilla SD0.5-0.7モデルサイズ分不要arXiv:2211.17192
Medusa headsMedusa0.6-0.75~数百 MB軽量な学習arXiv:2401.10774
EAGLE headEAGLE-1/2/30.7-0.85~1-2 GB学習が必要arXiv:2401.15077
Self-speculativeLayerSkip0.4-0.6なし不要-
N-gram ベースPrompt Lookup0.3-0.6なし不要-

Draft モデルの互換性検証

from transformers import AutoTokenizer

def verify_draft_compatibility(target_model: str, draft_model: str) -> dict:
    """Draft/Target モデル間の tokenizer 互換性を検証"""
    target_tok = AutoTokenizer.from_pretrained(target_model)
    draft_tok = AutoTokenizer.from_pretrained(draft_model)

    # 1. 語彙サイズの一致を確認
    vocab_match = target_tok.vocab_size == draft_tok.vocab_size

    # 2. 特殊トークンの一致を確認
    special_match = (
        target_tok.bos_token_id == draft_tok.bos_token_id and
        target_tok.eos_token_id == draft_tok.eos_token_id and
        target_tok.pad_token_id == draft_tok.pad_token_id
    )

    # 3. サンプルテキストのエンコード結果を比較
    test_texts = [
        "Hello, how are you?",
        "ソウルの天気はどうですか?",
        "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)",
    ]
    encoding_match = all(
        target_tok.encode(t) == draft_tok.encode(t) for t in test_texts
    )

    return {
        "vocab_size_match": vocab_match,
        "special_tokens_match": special_match,
        "encoding_match": encoding_match,
        "compatible": vocab_match and special_match and encoding_match,
        "target_vocab_size": target_tok.vocab_size,
        "draft_vocab_size": draft_tok.vocab_size,
    }

result = verify_draft_compatibility(
    "meta-llama/Llama-3.1-70B-Instruct",
    "meta-llama/Llama-3.1-8B-Instruct"
)
print(result)
# {'vocab_size_match': True, 'special_tokens_match': True,
#  'encoding_match': True, 'compatible': True, ...}

Phase 3: vLLM のサービング設定

独立した Draft モデル方式

# vLLM で speculative decoding を有効化 (Llama 3.1 70B + 8B)
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-3.1-70B-Instruct \
    --speculative-model meta-llama/Llama-3.1-8B-Instruct \
    --num-speculative-tokens 5 \
    --speculative-disable-mqa-scorer \
    --tensor-parallel-size 4 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 4096 \
    --port 8000

EAGLE-3 方式 (推奨)

# EAGLE-3 draft head を使用 (より高い accept ratio)
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-3.1-70B-Instruct \
    --speculative-model eagle3-llama3.1-70b-instruct \
    --speculative-method eagle \
    --num-speculative-tokens 5 \
    --tensor-parallel-size 4 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 4096 \
    --use-v2-block-manager \
    --port 8000

主要パラメータのチューニング

# speculative_decoding_config.yaml
# この設定を基に実験し、accept ratio と latency を見ながら調整する

# Draft トークン数: 多すぎると reject が増え、少なすぎると利点が減る
num_speculative_tokens: 5 # 開始値。3-7 の範囲で実験する

# Speculative decoding が効果を持たない場合は自動的に無効化
speculative_disable_by_batch_size: 8 # バッチ 8 以上なら無効化

# temperature > 0 のときの扱い
# typical acceptance sampling を使用 (品質を維持)
speculative_draft_tensor_parallel_size: 1 # draft は TP=1 で十分

num_speculative_tokens の最適値を見つける

import subprocess
import json

def find_optimal_spec_tokens(
    target_model: str,
    draft_model: str,
    eval_prompts: str,
    candidates: list[int] = [3, 4, 5, 6, 7, 8],
) -> dict:
    """さまざまな num_speculative_tokens の値でベンチマークを実行"""
    results = {}
    for n in candidates:
        print(f"Testing num_speculative_tokens={n}")
        # サーバ起動 (実際には subprocess で管理)
        # ここでは結果収集のロジックのみ示す
        metrics = run_benchmark(target_model, draft_model, n, eval_prompts)
        results[n] = {
            "accept_ratio": metrics["accept_ratio"],
            "tpot_p50_ms": metrics["tpot_p50_ms"],
            "e2e_speedup": metrics["baseline_e2e"] / metrics["e2e_p50_ms"],
            "gpu_memory_gb": metrics["gpu_memory_gb"],
        }

    # 最適値の選択: speedup が最も高い値
    best_n = max(results, key=lambda n: results[n]["e2e_speedup"])
    results["recommended"] = best_n
    return results

Phase 4: Accept Ratio のモニタリング

Accept ratio は speculative decoding の健全性を示す中心的な指標だ。vLLM はこのメトリクスを自前で公開している。

Prometheus メトリクスの収集

# prometheus.yml - vLLM メトリクスのスクレイピング設定
scrape_configs:
  - job_name: 'vllm-speculative'
    scrape_interval: 15s
    static_configs:
      - targets: ['vllm-server:8000']
    metrics_path: /metrics

vLLM が公開する speculative decoding 関連のメトリクス:

# draft トークンの受理率
vllm:spec_decode_draft_acceptance_rate

# 位置別の受理率 (position 0 が最も高く、後ろに行くほど下がる)
vllm:spec_decode_per_position_acceptance_rate{position="0"}
vllm:spec_decode_per_position_acceptance_rate{position="1"}

# 平均受理長
vllm:spec_decode_mean_accepted_length

Grafana のアラートルール

# grafana_alerts.yaml
groups:
  - name: speculative_decoding_alerts
    rules:
      # Accept ratio が 0.5 以下に下がったら警告
      - alert: LowAcceptRatio
        expr: vllm:spec_decode_draft_acceptance_rate < 0.5
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: 'Speculative decoding accept ratio の低下'
          description: |
            Accept ratio が {{ $value | printf "%.2f" }} まで低下しました。
            0.5 未満では speculative decoding がかえって overhead になります。
            Draft モデルの交換または speculative decoding の無効化を検討してください。

      # Accept ratio が 0.3 以下なら即時の無効化を推奨
      - alert: CriticalAcceptRatio
        expr: vllm:spec_decode_draft_acceptance_rate < 0.3
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: 'Speculative decoding の無効化が必要'
          description: |
            Accept ratio {{ $value | printf "%.2f" }}。直ちに fallback デコーディングへ切り替えてください。

Phase 5: トラフィッククラス別のルーティング

すべてのリクエストに speculative decoding を適用してはならない。リクエストの特性によって効果が大きく異なるからだ。

ルーティングの意思決定マトリクス

リクエスト特性Speculative Decoding理由
長い出力 (256+ tokens)ONトークン生成時間が支配的なので効果が最大
短い出力 (< 32 tokens)OFFDraft モデルのオーバーヘッドが利点を上回る
temperature=0 (greedy)ON (最適)Draft の予測精度が最も高い
temperature > 1.0OFF高いランダム性で accept ratio が急落
高い同時リクエスト (batch > 8)OFFバッチ処理時に speculative のオーバーヘッドが増加
ストリーミング応答ON (条件付き)TTFT の増加を許容できる場合

NGINX ベースのルーティング設定

# /etc/nginx/conf.d/llm-router.conf
upstream vllm_speculative {
    server 10.0.1.10:8000;  # speculative decoding 有効サーバ
}

upstream vllm_standard {
    server 10.0.1.20:8000;  # 標準デコーディングサーバ
}

# Lua ベースの動的ルーティング
server {
    listen 80;

    location /v1/chat/completions {
        access_by_lua_block {
            local cjson = require "cjson"
            ngx.req.read_body()
            local body = cjson.decode(ngx.req.get_body_data())

            -- ルーティング条件の判定
            local use_speculative = true

            -- temperature が高ければ標準デコーディング
            if body.temperature and body.temperature > 1.0 then
                use_speculative = false
            end

            -- max_tokens が短ければ標準デコーディング
            if body.max_tokens and body.max_tokens < 32 then
                use_speculative = false
            end

            -- stream=false かつ短い応答なら標準デコーディング
            if not body.stream and body.max_tokens and body.max_tokens < 64 then
                use_speculative = false
            end

            if use_speculative then
                ngx.var.upstream = "vllm_speculative"
            else
                ngx.var.upstream = "vllm_standard"
            end
        }

        proxy_pass http://$upstream;
        proxy_set_header Host $host;
    }
}

Phase 6: ロールバックと Fallback

自動 Fallback の判定ロジック

import requests
import time
from dataclasses import dataclass

@dataclass
class FallbackConfig:
    accept_ratio_threshold: float = 0.4
    latency_regression_pct: float = 20.0  # baseline 比で 20% 以上遅くなったら
    check_interval_sec: int = 60
    consecutive_failures: int = 3

class SpeculativeDecodingGuard:
    """Speculative decoding の状態を監視し、自動 fallback を判断する"""

    def __init__(self, config: FallbackConfig, prometheus_url: str):
        self.config = config
        self.prometheus_url = prometheus_url
        self.failure_count = 0

    def query_prometheus(self, query: str) -> float:
        resp = requests.get(
            f"{self.prometheus_url}/api/v1/query",
            params={"query": query},
        )
        result = resp.json()["data"]["result"]
        return float(result[0]["value"][1]) if result else 0.0

    def should_fallback(self) -> tuple[bool, str]:
        # 1. Accept ratio の確認
        accept_ratio = self.query_prometheus(
            'vllm:spec_decode_draft_acceptance_rate'
        )
        if accept_ratio < self.config.accept_ratio_threshold:
            self.failure_count += 1
            if self.failure_count >= self.config.consecutive_failures:
                return True, f"accept_ratio={accept_ratio:.2f} < {self.config.accept_ratio_threshold}"
        else:
            self.failure_count = 0

        # 2. Latency regression の確認
        current_p95 = self.query_prometheus(
            'histogram_quantile(0.95, rate(vllm:e2e_request_latency_seconds_bucket[5m]))'
        )
        baseline_p95 = self.query_prometheus(
            'vllm:baseline_e2e_p95_seconds'  # ベースラインのメトリクスを別途記録する必要あり
        )
        if baseline_p95 > 0:
            regression_pct = ((current_p95 - baseline_p95) / baseline_p95) * 100
            if regression_pct > self.config.latency_regression_pct:
                return True, f"latency regression {regression_pct:.1f}% > {self.config.latency_regression_pct}%"

        return False, "healthy"

    def run(self):
        while True:
            should_fb, reason = self.should_fallback()
            if should_fb:
                print(f"[FALLBACK] Speculative decoding を無効化: {reason}")
                self.trigger_fallback()
            time.sleep(self.config.check_interval_sec)

    def trigger_fallback(self):
        """標準デコーディングサーバへトラフィックを切り替える"""
        # 実際の実装: ロードバランサの重み変更または feature flag のトグル
        requests.post(
            "http://config-server/api/v1/flags",
            json={"speculative_decoding_enabled": False},
        )

Phase 7: 定期点検 (週次)

週次点検の自動化スクリプト

import json
import datetime
from typing import Any

def weekly_speculative_decoding_report(
    prometheus_url: str,
    baseline_file: str,
) -> dict[str, Any]:
    """週次の speculative decoding 運用レポートを生成"""
    baseline = json.load(open(baseline_file))

    report = {
        "report_date": datetime.date.today().isoformat(),
        "period": "last_7d",
    }

    # 1. Accept ratio の推移
    report["accept_ratio"] = {
        "current_avg": query_prom(prometheus_url,
            'avg_over_time(vllm:spec_decode_draft_acceptance_rate[7d])'),
        "min": query_prom(prometheus_url,
            'min_over_time(vllm:spec_decode_draft_acceptance_rate[7d])'),
        "max": query_prom(prometheus_url,
            'max_over_time(vllm:spec_decode_draft_acceptance_rate[7d])'),
    }

    # 2. Latency の改善率
    current_e2e_p50 = query_prom(prometheus_url,
        'histogram_quantile(0.5, rate(vllm:e2e_request_latency_seconds_bucket[7d]))')
    report["speedup"] = {
        "e2e_p50_speedup": round(baseline["e2e_p50_ms"] / (current_e2e_p50 * 1000), 2),
        "baseline_e2e_p50_ms": baseline["e2e_p50_ms"],
        "current_e2e_p50_ms": round(current_e2e_p50 * 1000, 1),
    }

    # 3. Fallback の発生回数
    report["fallback_count"] = int(query_prom(prometheus_url,
        'count_over_time(ALERTS{alertname="LowAcceptRatio"}[7d])'))

    # 4. リソース使用量 (speculative の追加分)
    report["gpu_memory_overhead_gb"] = query_prom(prometheus_url,
        'avg_over_time(vllm:gpu_cache_usage_perc[7d])') * 80  # A100 80GB 基準

    # 5. 推奨事項
    recommendations = []
    if report["accept_ratio"]["current_avg"] < 0.55:
        recommendations.append("Draft モデルの交換または EAGLE-3 head の学習を推奨")
    if report["speedup"]["e2e_p50_speedup"] < 1.3:
        recommendations.append("改善幅が 1.3x 未満。費用対効果の再検討が必要")
    if report["fallback_count"] > 5:
        recommendations.append(f"週次 fallback {report['fallback_count']}回。Draft モデルの品質点検")

    report["recommendations"] = recommendations
    return report

トラブルシューティング

1. Speculative decoding 適用後にかえって遅くなる

症状: E2E latency が baseline 比で 10-30% 増加

診断の順序:

# 1. Accept ratio の確認
curl -s http://localhost:8000/metrics | grep spec_decode_draft_acceptance_rate
# 0.3 未満なら draft モデルの問題

# 2. Draft モデルの推論時間を確認
curl -s http://localhost:8000/metrics | grep spec_decode_draft_latency
# Draft の推論が target の単一トークン推論の 50% 以上なら非効率

# 3. GPU メモリ不足による swap の確認
nvidia-smi --query-gpu=memory.used,memory.total --format=csv
# 95% 以上なら KV cache 不足で speculative の利点が相殺される

解決: accept ratio < 0.5 なら draft モデルを交換する。GPU メモリ不足なら num_speculative_tokens を 3 に下げる。

2. 特定のプロンプトでのみ accept ratio が急落する

原因: コード生成や数式計算など、draft モデルが苦手なドメイン

解決: リクエスト分類器を追加し、該当ドメインは標準デコーディングへルーティングする。

3. ストリーミング応答でトークンがまとまって出る

症状: ユーザーが体感するストリーミングが途切れる -> 複数トークンが一度に -> 途切れる、の繰り返し

原因: Speculative decoding は draft トークンを一度に検証するため、受理されたトークンが burst で届く

解決: クライアント側でトークンをバッファリングし、一定間隔でレンダリングする。または --disable-frontend-multiprocessing オプションを確認する。

4. RuntimeError: Draft model and target model have different vocab sizes

RuntimeError: Draft model vocab size 32000 != target model vocab size 128256

原因: tokenizer が異なるモデル family を draft として使った場合 (例: Llama 2 draft + Llama 3 target)

解決: 同じ model family、同じ tokenizer を使う draft モデルへ交換する。

デプロイ前チェックリスト

クイズ

Q1. Speculative decoding が出力分布を変えない理由は? 答え: ||Draft モデルが生成したトークンを target モデルが検証する際に rejection sampling を用いるからである。 受理されたトークンは target モデルの分布と正確に一致し、棄却された位置では target モデルが 再サンプリングする。||

Q2. Accept ratio が 0.3 のとき speculative decoding を維持すべきか? 答え: ||維持すべきではない。Accept ratio 0.3 では 5 個の draft トークンのうち平均 1.5 個しか受理されず、draft モデルの推論 オーバーヘッドが利点を相殺する。一般に 0.5 未満なら標準デコーディングの方が速い。||

Q3. EAGLE-3 が独立した小型 draft モデルより accept ratio が高い理由は? 答え: ||EAGLE-3 は target モデルの second-to-top-layer feature を入力として次のトークンを 予測するため、target モデルの内部表現へ直接アクセスできるからである。独立したモデルはこうした情報なしに 自前で予測しなければならない。||

Q4. 大きな batch size で speculative decoding が非効率になる理由は? 答え: ||Speculative decoding の利点は autoregressive decoding の memory-bound な特性を緩和することにあるが、 batch size が大きくなるとすでに compute-bound となり、speculation の利点が減るからである。さらに draft トークンの検証に必要な追加メモリと演算がバッチ処理の効率を下げる。||

Q5. Temperature が高いと accept ratio が下がる理由は? 答え: ||Temperature が高いと target モデルの出力分布がより uniform になるため、draft モデルが正確な トークンを予測しにくくなる。Greedy decoding(temperature=0) では最も確率の高いトークンを一つ 当てればよいが、高い temperature では予測の不確実性が大きくなる。||

Q6. vLLM の speculative_disable_by_batch_size パラメータの役割は? 答え: ||処理中のリクエスト数(batch size)が指定値以上になると speculative decoding を自動的に 無効化し、標準デコーディングへ切り替える。高い同時実行性での性能低下を防ぐ安全装置である。||

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます