Skip to content

Split View: LLM Speculative Decoding 서빙 최적화 플레이북

|

LLM Speculative Decoding 서빙 최적화 플레이북

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 토큰을 한번에 검증하므로 accepted 토큰이 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 모델로 교체.

배포 전 체크리스트

  • 베이스라인 측정 완료 (TTFT, TPOT, E2E, throughput)
  • Draft/Target 모델 tokenizer 호환성 검증 통과
  • num_speculative_tokens 최적값 실험 완료
  • Accept ratio > 0.55 확인 (프로덕션 트래픽 샘플)
  • E2E latency speedup > 1.3x 확인
  • GPU 메모리 사용량이 95% 미만 확인
  • Fallback 자동 전환 로직 구현 및 테스트 완료
  • 트래픽 클래스별 라우팅 규칙 설정 완료
  • Prometheus 메트릭 + Grafana 대시보드 구성 완료
  • 주간 점검 자동화 스크립트 배포 완료
  • 온콜 팀에 speculative decoding 장애 대응 런북 공유 완료

퀴즈

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을 자동으로 비활성화하여 표준 디코딩으로 전환한다. 높은 동시성에서의 성능 저하를 방지하는 안전장치다.||

참고 자료

LLM Speculative Decoding 서빙 최적화 플레이북

LLM Speculative Decoding 서빙 최적화 플레이북

플레이북 Overview

이 문서는 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 토큰을 한번에 검증하므로 accepted 토큰이 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 모델로 교체.

배포 전 체크리스트

  • 베이스라인 측정 완료 (TTFT, TPOT, E2E, throughput)
  • Draft/Target 모델 tokenizer 호환성 검증 통과
  • num_speculative_tokens 최적값 실험 완료
  • Accept ratio > 0.55 확인 (프로덕션 트래픽 샘플)
  • E2E latency speedup > 1.3x 확인
  • GPU 메모리 사용량이 95% 미만 확인
  • Fallback 자동 전환 로직 구현 및 테스트 완료
  • 트래픽 클래스별 라우팅 규칙 설정 완료
  • Prometheus 메트릭 + Grafana 대시보드 구성 완료
  • 주간 점검 자동화 스크립트 배포 완료
  • 온콜 팀에 speculative decoding 장애 대응 런북 공유 완료

Quiz

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의 이점이 줄어든다. Additionally, 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을 자동으로 비활성화하여 표준 디코딩으로 전환. 높은 동시성에서의 성능 저하를 방지하는 안전장치다.||

References