
vLLM 0.28.0에서 speculative decoding 4개 구성을 비교 측정한 결과.
| 항목 | 값 |
|---|---|
| 모델 | Qwen/Qwen3-32B (bf16, 17 shards / 65.5 GB) |
| 비교 구성 | vanilla · n-gram · improved n-gram · eagle3 |
| 측정 지점 | concurrency 1, 16 → 8건 |
| 데이터셋 | Spec-Bench (480문항 중 16 프롬프트, 출력 512 토큰) |
| 하드웨어 | NVIDIA RTX PRO 6000 Blackwell 96GB × 2노드 |
| 일시 | 2026-08-28 |
eagle3를 쓰세요. concurrency 1에서 2.11배, 16에서 1.83배.
n-gram은 주어진 설정(6 / 4–6)으로는 concurrency 16에서 오히려 0.95배로 느려집니다.
eagle3의 TTFT 비용은 122 → 142 ms로 미미해 대화형 서비스에도 안전합니다.

LLM의 토큰 생성(decode)은 메모리 대역폭에 묶인(memory-bandwidth-bound) 작업이다.
토큰 하나를 만들 때마다 모델 가중치 전체를 읽어야 하는데, 실제 연산량은 그에 비해 적어
GPU 연산 유닛이 논다. Qwen3-32B는 bf16 65.5GB를 매 토큰마다 읽는다.
Speculative decoding은 이 낭비를 이용한다:
1. Draft : 가볍고 빠른 방법으로 다음 k개 토큰을 "추측"
2. Verify : 원본 모델이 k개를 한 번의 forward로 동시에 검증
3. Accept : 맞은 것까지 채택, 틀린 지점부터 폐기
핵심은 2번이다. 토큰 1개를 만들 때나 k+1개를 검증할 때나 가중치를 읽는 비용은 같다.
채택률이 높으면 가중치 읽기 비용을 여러 토큰에 분할상환(amortize)하게 된다.
출력 품질은 변하지 않는다. 검증 단계에서 원본 모델의 분포와 일치하는 토큰만 채택하므로
vanilla와 동일한 출력을 더 빠르게 얻는 것이 목표다. 순수한 속도 최적화다.
| 조건 | 영향 |
|---|---|
| 채택률이 높다 | 이득 ↑ |
| speculation이 자주 발동한다 | 이득 ↑ — 이번 실험에서 n-gram의 결정적 약점 |
| 채택률이 낮거나 발동이 드물다 | 손해 — draft 생성 + 검증 오버헤드가 순손실 |
| 배치가 작다 (저동시성) | 이득 ↑ — memory-bound 구간, 연산 유닛에 여유 |
| 배치가 크다 (고동시성) | 이득 ↓ — 이미 compute-bound |
speculative decoding 을 켜면 얻는 것만 있는게 아니라 반드시 내는 비용(버려지는 토큰)이 있다 실제로 이번에 n-gram이 vanilla보다 느린 사례가 나왔다(§4.2).
드래프트 모델 없이 프롬프트에서 같은 n-gram을 찾아 그 뒤 토큰을 복사한다.
추가 모델도 학습도 필요 없다.
{"method":"ngram", "num_speculative_tokens":6, "prompt_lookup_min":4, "prompt_lookup_max":6}
num_speculative_tokens — 한 번에 몇 개를 추측할지prompt_lookup_min/max — 매칭할 n-gram 길이 범위요약·RAG·코드 편집처럼 입력을 그대로 옮겨 쓰는 과제에 강하고, 자유 생성·추론에는 약하다.
매칭할 패턴의 길이를 늘리고, 추측 개수를 줄인 설정.
{"method":"ngram", "num_speculative_tokens":4, "prompt_lookup_min":2, "prompt_lookup_max":128}
min 4→2 : 짧은 매칭도 허용 → 발동 기회 증가max 6→128 : 긴 매칭도 활용num_spec 6→4 : speculative 발동 1회당 제안할 토큰 갯수실측 효과(§4.3): 발동이 429 → 1,552회로 3.6배 늘었고, 근거가 약해진 만큼
채택 길이는 2.562 → 1.881로 떨어졌다. 순효과는 채택 토큰 2배(670 → 1,367).
개선의 대부분은 max를 넓혀 발동 횟수를 늘린 데서 왔다. num_speculative_tokens를
줄인 것은 버림 비율을 낮추지 못했다(74% → 78%).
별도 학습된 소형 draft 모델을 쓴다. 원본 모델의 중간 hidden state를 입력받아
다음 토큰을 예측하도록 증류된 1개 레이어 네트워크다.
{"method":"eagle3", "model":"/path/to/Qwen3-32B-speculator.eagle3", "num_speculative_tokens":3}
사용한 speculator: RedHatAI/Qwen3-32B-speculator.eagle3 (3.0 GB)
중요: speculator config의
verifier필드가 학습 기준 타깃이다.
이게 실제 서빙 모델과 다르면(예: bf16 기준 학습 → 양자화 모델에 적용)
hidden state 분포가 어긋나 채택률이 떨어진다.
| 지표 | 의미 | 방향 |
|---|---|---|
| output throughput (tok/s) | 초당 생성 토큰 수 | 높을수록 좋음 |
| total token throughput | 입력+출력 합산 처리량 | 높을수록 좋음 |
| TPOT (Time Per Output Token) | 토큰당 생성 시간 | 낮을수록 좋음 |
| TTFT (Time To First Token) | 첫 토큰까지 지연 - 대화형 UX 핵심 | 낮을수록 좋음 |
| ITL (Inter-Token Latency) | 토큰 간 간격 (스트리밍 부드러움) | 낮을수록 좋음 |
spec_decode_acceptance_rate | draft 토큰 채택률 (%) | 높을수록 좋음 |
spec_decode_acceptance_length | draft 1회당 평균 채택 토큰 수 | 높을수록 좋음 |
spec_decode_num_drafts | speculation 발동 횟수 | n-gram 진단의 핵심 |
Speculative decoding의 채택률은 입력 데이터에 절대적으로 의존한다.
Spec-Bench 480문항: translation / summarization / qa /
math_reasoning / rag 각 80문항 + writing, roleplay, reasoning, math, coding, extraction,
stem, humanities 각 10문항.
| 항목 | 버전 |
|---|---|
| OS | Ubuntu 24.04.4 LTS / x86_64 / Python 3.12.3 |
| GPU | RTX PRO 6000 Blackwell 96GB (sm_120) |
| Driver / CUDA | 580.173.02 / CUDA 13.0 |
| vLLM | 0.28.0 |
| torch | 2.13.0+cu130 |
# --- 베스천 ---
python3 -m venv /vllm-venv
/vllm-venv/bin/pip install vllm==0.28.0 --timeout 30 --retries 3
/vllm-venv/bin/pip install "vllm[bench]==0.28.0" # pandas 등 벤치 의존성
/vllm-venv/bin/python -c \
"import torch,vllm; print(vllm.__version__, torch.__version__, torch.cuda.get_arch_list())"
# → 0.28.0 2.13.0+cu130 ['sm_75','sm_80','sm_86','sm_90','sm_100','sm_120']
# --- 워커로 배포 (동일 경로 필수) ---
rsync -a /vllm-venv/ maymust@10.10.30.14:/vllm-venv/
rsync -a /vllm-venv/ maymust@10.10.30.15:/vllm-venv/
네트워크가 불안정해 pip이 자주 정지한다.
--timeout 30으로 짧게 잡고 재시도 루프로 감싸면
pip 캐시에 완료분이 남아 반복할수록 수렴한다.
export CUDA_HOME=/vllm-venv/lib/python3.12/site-packages/nvidia/cu13
export CUDA_PATH="$CUDA_HOME"
export PATH="/vllm-venv/bin:$CUDA_HOME/bin:$PATH" # ninja 등을 위해 필수
export VIRTUAL_ENV=/vllm-venv
export CC=/usr/bin/gcc
export TRITON_PTXAS_PATH="$CUDA_HOME/bin/ptxas"
export VLLM_USE_FLASHINFER_SAMPLER=0
실험 중 두 노드가 커널 6.8.0-137 → 138로 업그레이드되며 재부팅되었고 GPU 인식을 못함
드라이버 문제가 아님 모듈은 정상 빌드되어 있었다:
modinfo /lib/modules/6.8.0-138-generic/kernel/nvidia-580srv-open/nvidia.ko
# vermagic: 6.8.0-138-generic SMP preempt mod_unload modversions ← 커널과 일치
진짜 원인: 부팅 시 자동 로드 설정 부재. /etc/modules, /etc/modules-load.d/에 nvidia 없고
nvidia-persistenced는 static(enabled 아님).
sudo modprobe nvidia # 즉시 복구
# 영구 해결 (적용 완료)
printf "nvidia\nnvidia_uvm\nnvidia_modeset\n" | sudo tee /etc/modules-load.d/nvidia.conf
# 되돌리기: sudo rm /etc/modules-load.d/nvidia.conf
이전 커널로 롤백해도 재발한다. 커널 버전과 무관한 문제
재부팅 후 워커에서 NFS(192.168.160.7) 도달 불가. stale 라우트가 원인:
192.168.160.0/24 dev ens33 scope link ← 10.10.30.x NIC로 잘못 보냄
sudo ip route del 192.168.160.0/24 dev ens33 scope link
sudo ip route add 192.168.160.0/24 dev ens35 scope link src <노드 ens35 IP>
sudo mount -t nfs4 -o ro,vers=4.2,hard,proto=tcp \
192.168.160.7:/media/SSD-Storage/SW /mnt/fabrix
⚠️ 미해결:
192.168.160.179가 두 워커에 중복 할당되어 있다.
worker-2의/etc/netplan/99-ens35.yaml이 자기 주소(.169) 위에 .179를 추가로 붙인다.
ARP 충돌로 간헐적 통신 장애가 생길 수 있어 정리 필요.
hf download Qwen/Qwen3-32B --local-dir ./Qwen3-32B # 65.5 GB, 17 shards
hf download RedHatAI/Qwen3-32B-speculator.eagle3 \
--local-dir ./Qwen3-32B-speculator.eagle3 # 3.0 GB
MODEL=/mnt/models/Qwen3-32B # NFS (~105 MB/s)
DRAFT=/mnt/Qwen3-32B-speculator.eagle3 # 로컬
V=/vllm-venv/bin
NFS에서 65.5GB를 읽으므로 구성당 기동에 약 10분이 걸린다.
단 이는 기동 시간만 늘릴 뿐 측정값에는 영향이 없다(§5.2에서 검증).
공통: --max-model-len 2048 --gpu-memory-utilization 0.95
# (1) vanilla
$V/vllm serve "$MODEL" --served-model-name bench \
--no-enable-log-requests --max-model-len 2048 --gpu-memory-utilization 0.95
# (2) n-gram
$V/vllm serve "$MODEL" --served-model-name bench \
--speculative-config '{"method":"ngram","num_speculative_tokens":6,"prompt_lookup_min":4,"prompt_lookup_max":6}' \
--no-enable-log-requests --max-model-len 2048 --gpu-memory-utilization 0.95
# (3) improved n-gram
$V/vllm serve "$MODEL" --served-model-name bench \
--speculative-config '{"method":"ngram","num_speculative_tokens":4,"prompt_lookup_min":2,"prompt_lookup_max":128}' \
--no-enable-log-requests --max-model-len 2048 --gpu-memory-utilization 0.95
# (4) eagle3
$V/vllm serve "$MODEL" --served-model-name bench \
--speculative-config "{\"method\":\"eagle3\",\"model\":\"$DRAFT\",\"num_speculative_tokens\":3}" \
--no-enable-log-requests --max-model-len 2048 --gpu-memory-utilization 0.95
--disable-log-requests는 vLLM 0.28에서--no-enable-log-requests로 바뀌었다.
$V/vllm bench serve \
--backend openai-chat \
--base-url http://localhost:8000 \
--endpoint /v1/chat/completions \
--model bench \
--tokenizer "$MODEL" \
--dataset-name spec_bench \
--dataset-path /home/maymust/datasets/question.jsonl \
--num-prompts 16 \
--spec-bench-output-len 512 \
--max-concurrency 1 \
--save-result --result-filename qwen_vanilla_conc1.json
설계 판단 3가지
| 선택 | 이유 |
|---|---|
--backend openai-chat + /v1/chat/completions | eagle3 speculator가 chat 템플릿 기준으로 학습됨. raw completions로 재면 채택률이 낮아짐. 4개 구성 모두 동일 엔드포인트로 공정성 유지 |
--tokenizer "$MODEL" | --served-model-name bench로 띄웠으므로 클라이언트가 "bench"를 HF repo id로 찾다 실패. 실제 경로 명시 필요 |
--dataset-name spec_bench | random 합성 토큰은 n-gram 채택률이 구조적으로 0이라 측정 무의미 |
git clone https://github.com/hemingkx/Spec-Bench.git
# 사용 파일: Spec-Bench/data/spec_bench/question.jsonl (480줄, 698KB)
| 파일 | 역할 |
|---|---|
run-bench.sh <model-key> <config> | 1개 구성: 서버 기동 → health 대기 → conc 1·16 벤치 → 정리 |
run-all.sh <model-key> <cfg>... | 여러 구성 순차 실행, 완료 시 DONE_<key> 마커 생성 |
bash run-all.sh qwen vanilla ngram ngram_improved eagle3
스크립트에 넣은 안전장치 (실제 사고를 겪고 추가):
# 1) 부모 프로세스가 죽으면 자식프로세서의 이름이 VLLM::EngineCore로 바뀜. 두 패턴 모두 잡아야 함
# (놓치면 93GB를 점유한 채 남아 다음 구성이 OOM으로 실패)
kill_vllm () {
for pat in "[v]llm serve" "VLLM::EngineCore" "[V]LLM::"; do
for p in $(pgrep -f "$pat" 2>/dev/null); do kill "$1" "$p" 2>/dev/null; done
done
}
# GPU 메모리가 실제로 해제될 때까지 대기
for i in $(seq 1 30); do
U=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits)
[ "${U:-0}" -lt 5000 ] && break; sleep 2
done
# 2) 모델 경로 폴백 — 로컬 사본이 사라져도 NFS로 계속 진행
pick () { for d in "$@"; do [ -f "$d/config.json" ] && { echo "$d"; return; }; done; echo ""; }
MODEL=$(pick /mnt/Qwen3-32B /mnt/models/Qwen3-32B)
[ -n "$MODEL" ] || { echo "FATAL: no readable model dir"; exit 3; }
# 3) health 대기 — NFS 로딩은 65.5GB에 10분 이상 걸리므로 40분까지 허용
for i in $(seq 1 480); do
curl -sf http://127.0.0.1:8000/health >/dev/null 2>&1 && break
kill -0 $SRVPID 2>/dev/null || { echo "SERVER DIED"; tail -40 "$SRVLOG"; exit 1; }
sleep 5
done
💡 tmux 사용: 세션이 끊겨도 실험이 유지되도록 tmux 윈도우에서 실행함
tmux new-window -t bench -n runqwen tmux send-keys -t bench:runqwen "bash run-all.sh qwen vanilla eagle3" C-m
TPOT/TTFT/ITL은 ms(평균). 배수는 같은 노드의 vanilla 대비.
| config | out tok/s | 배수 | total tok/s | TPOT | TTFT | ITL | duration |
|---|---|---|---|---|---|---|---|
| vanilla | 21.29 | 1.00× | 26.81 | 46.94 | 68.79 | 46.84 | 378.65 s |
| ngram (6/4–6) | 22.83 | 1.07× | 28.68 | 43.65 | 119.81 | 47.47 | 357.42 s |
| ngram (4/2–128) | 25.18 | 1.18× | 31.62 | 39.56 | 119.53 | 47.40 | 324.43 s |
| eagle3 (3) | 44.76 | 2.11× | 56.50 | 22.23 | 89.78 | 49.47 | 178.13 s |
| config | out tok/s | 배수 | total tok/s | TPOT | TTFT | ITL | duration |
|---|---|---|---|---|---|---|---|
| vanilla | 301.19 | 1.00× | 379.75 | 51.65 | 128.81 | 51.57 | 26.61 s |
| ngram (6/4–6) | 286.89 | 0.95× ⚠️ | 360.32 | 51.77 | 124.08 | 56.69 | 28.48 s |
| ngram (4/2–128) | 308.85 | 1.03× | 388.03 | 44.73 | 125.55 | 54.34 | 26.41 s |
| eagle3 (3) | 562.60 | 1.83× | 706.20 | 25.33 | 141.73 | 57.20 | 14.56 s |


| config | conc | 채택률 | 채택 길이 | draft 시도 | 채택 토큰 |
|---|---|---|---|---|---|
| ngram (6/4–6) | 1 | 26.0% | 2.562 | 429 | 670 |
| ngram (4/2–128) | 1 | 22.0% | 1.881 | 1,552 | 1,367 |
| eagle3 (3) | 1 | 41.5% | 2.245 | 3,556 | 4,428 |
| ngram (6/4–6) | 16 | 27.7% | 2.661 | 439 | 729 |
| ngram (4/2–128) | 16 | 23.0% | 1.922 | 1,589 | 1,465 |
| eagle3 (3) | 16 | 42.4% | 2.272 | 3,605 | 4,586 |
concurrency 1 : 21.29 → 44.76 tok/s (2.11×) TPOT 46.94 → 22.23 ms
concurrency 16 : 306.70 → 562.60 tok/s (1.83×) TPOT 51.74 → 25.33 ms
16개 요청 처리 시간이 378.65초 → 178.13초로 절반 이하가 된다.


저배치에서 이득이 더 크다. (2.11× vs 1.83×) concurrency 1은 완전히
memory-bound라 분할상환 여지가 크고, 16에서는 배치 자체가 이미 연산 유닛을 채우기 시작한다.
ngram (6/4–6) conc=1 : 1.07× (미미)
ngram (6/4–6) conc=16 : 0.95× ← vanilla보다 느림
원인은 채택률이 아니라 "발동 횟수"
n-gram은 프롬프트에 매칭되는 n-gram이 있을 때만 발동한다.
총 8,159 토큰을 생성하는 동안 단 429번만 시도했다 — decode 스텝의 약 5%다.
나머지 95%는 speculation이 아예 작동하지 않고, 상시 오버헤드만 남아 손해가 된다.
역설적으로 n-gram은 발동만 하면 품질이 좋다.
채택 길이 2.562는 eagle3의 2.245보다 높다.
문제는 3,556회 대 429회라는 발동 횟수 차이다.
prompt_lookup_max를 6 → 128로 넓히면:
| draft 시도 | 채택 길이 | 채택 토큰 | conc=1 | conc=16 | |
|---|---|---|---|---|---|
| ngram (max=6) | 429 | 2.562 | 670 | 1.07× | 0.95× |
| ngram (max=128) | 1,552 | 1.881 | 1,367 | 1.18× | 1.03× |
시도가 3.6배 늘고, 느슨한 매칭이 섞여 채택 길이는 떨어지지만, 순효과는 채택 토큰 2배다.

채택률과 채택 길이를 나눠 보면, n-gram이 지는 이유가 품질이 아니라는 점이 분명해진다 — 채택 길이는 오히려 n-gram이 가장 높다.

⚠️ 서버 로그의
Accepted throughput값만 보면 채택률이 거의 0인 것처럼 오해하기 쉽다.
실제 채택률은 22~26%다. 결과 JSON의spec_decode_num_drafts를 봐야 정확히 진단된다.
| vanilla | eagle3 | 변화 | |
|---|---|---|---|
| conc=1 TTFT | 68.79 ms | 89.78 ms | +30% |
| conc=16 TTFT | 128.81 ms | 141.73 ms | +10% |
대화형 서비스에서도 eagle3를 켤 수 있다는 뜻이다.
speculative decoding은 검증 연산이 prefill을 밀어내 TTFT를 해치는 경우가 있는데,
Qwen3-32B bf16에서는 decode가 충분히 느려(47 ms/token) prefill이 끼어들 여유가 있다.

⚠️ 이 결론은 bf16 타깃 한정이다. decode가 훨씬 빠른 타깃(예: 4bit 양자화 모델)에서는
스케줄러가 decode로 꽉 차 prefill이 밀리고 TTFT가 크게 악화될 수 있다.
다른 타깃에 적용하기 전 반드시 재측정할 것.
| vanilla | eagle3 | |
|---|---|---|
| conc=1 ITL | 46.84 ms | 49.47 ms |
| conc=16 ITL | 51.57 ms | 57.20 ms |
ITL(토큰 간 간격)만 보면 eagle3가 느려 보인다. 하지만 이는 착시다.
speculative decoding은 한 번의 forward로 여러 토큰을 한꺼번에 내놓는다.
따라서 "이벤트 간 간격"은 늘지만 이벤트당 토큰 수가 늘어 TPOT는 절반이 된다
(46.94 → 22.23 ms). 실제 체감 속도는 TPOT가 정확하다.
speculative decoding 평가 시 ITL을 단독 지표로 쓰지 말 것. TPOT를 봐야 한다.

구성을 두 노드에 나눠 측정했으므로(n-gram 계열 → worker-1, eagle3 → worker-2),
vanilla를 양쪽에서 재서 결과 통합 가능 여부를 확인했다.
| vanilla | worker-1 | worker-2 | 차이 |
|---|---|---|---|
| conc=1 tok/s | 21.29 | 21.25 | 0.19% |
| conc=1 TPOT | 46.94 | 47.01 | 0.15% |
| conc=1 ITL | 46.84 | 46.91 | 0.15% |
| conc=16 tok/s | 301.19 | 306.70 | 1.8% |
| conc=16 TPOT | 51.65 | 51.74 | 0.17% |
conc=1은 사실상 동일. conc=16 처리량의 1.8% 차이는 16요청 배치의 통상 편차다.
conc=16에서 3% 미만 차이는 노이즈로 간주할 것.
tensor_parallel_size=1, pipeline_parallel_size=1, world_size=1
gpu-memory-utilization 0.95의 영향이번 측정에는 영향이 없다.
| 가중치 | KV 예산 | KV 토큰 | max concurrency | 실제 KV 사용률 | |
|---|---|---|---|---|---|
| Qwen3-32B bf16 | 61.03 GiB | 25.35 GiB | 103,840 | 50.70× | 0.2 – 0.7% |
conc=16에 max-model-len 2048이면 32,768 토큰만 필요한데 103,840이 잡혀 있다.
0.95든 0.70이든 동일한 수치가 나온다.
다만 여유가 5%(≈4.7GB)뿐이라 다른 프로세스가 GPU를 점유하면 기동이 실패한다.
| 검증된 것 | 검증되지 않은 것 |
|---|---|
| Qwen3-32B bf16 단일 GPU | 양자화 타깃 (별도 측정 필요) |
| concurrency 1과 16 | 그 사이 구간, 16 초과 구간 |
| Spec-Bench (대화·추론·요약·RAG) | 요약/코드 전용 워크로드 (n-gram에 유리할 수 있음) |
num_speculative_tokens=3 (eagle3) | 다른 k 값 |
| TP=1 | TP>1, 멀티노드 |
| 항목 | 판단 |
|---|---|
| eagle3 도입 | ✅ 권장. 2.11× / 1.83×, TTFT 비용 +10%로 대화형에도 안전 |
| n-gram (6/4–6) | ❌ 제외. conc=16에서 vanilla보다 느림 (0.95×) |
| n-gram (4/2–128) | ⚠️ 1.18× / 1.03×. eagle3 대비 열위. 요약·RAG 워크로드라면 재평가 여지 |
| 평가 지표 | TPOT를 볼 것. ITL 단독은 오해를 부름(§4.5) |
| 프로덕션 모니터링 | spec_decode_num_drafts, spec_decode_acceptance_length 수집 |