DeepSeek-V4-Flash 304B를 RTX PRO 6000 두 장에서 10배 빠르게 — 기동시키려 켠 플래그가 범인이었다
요약
304B DeepSeek-V4-Flash(NVFP4)를 RTX PRO 6000 Blackwell 두 장에 띄워 놓고 쓰다가, 단일 요청 생성 속도가 10.34 tok/s밖에 안 나온다는 걸 알았다. 에러는 하나도 없었다. 화려한 최적화들(speculative decoding, MoE 백엔드 교체, 블록 크기 조정)을 차례로 시도했지만 셋 다 "이 조합은 지원하지 않는다"로 죽었고, 범인은 엉뚱한 데 있었다 — 서버를 처음 띄울 때 응급처치로 켜 뒀던 플래그 하나였다. 그걸 빼자 토큰당 시간이 96.60ms → 9.03ms, 약 10.7배 빨라졌다.
대상과 장비
| 항목 | 값 |
|---|---|
| 모델 | DeepSeek-V4-Flash-0731-NVFP4 (304B, NVFP4 양자화, 가중치 약 175.6GB) |
| GPU | RTX PRO 6000 Blackwell 96GB × 2 (compute capability 12.0, SM120) |
| 엔진 | vLLM 0.26.1rc1.dev77 (Docker, tensor parallel 2) |
| 컨텍스트 | 250,000 |
가중치 175.6GB에 총 VRAM 192GB — KV 캐시와 활성화에 쓸 여유가 GPU당 10GB 남짓이다. 이 빠듯함이 이야기 내내 발목을 잡는다.
최종 구성
--model /path/to/model --tensor-parallel-size 2
--max-model-len 250000 --max-num-seqs 8 --max-num-batched-tokens 4096
--gpu-memory-utilization 0.97 --kv-cache-dtype fp8 --block-size 256
--tokenizer-mode deepseek_v4 --trust-remote-code
--enable-auto-tool-choice --tool-call-parser deepseek_v4
--kv-cache-dtype fp8— 이 모델은 필수다.auto면fp8_ds_mla layout only supports fp8 kv-cacheassert로 죽는다.--gpu-memory-utilization 0.97— 0.95는 물리 메모리의 약 4.7GB를 남긴다. 0.97로 올리면 KV 캐시가 602,920 → 720,387 토큰(+19.5%)이 된다.--enforce-eager가 없다. 이 글의 본론이다.
삽질기
증상: 장애가 아니라서 아무도 안 봤다
서버는 멀쩡했다. 정상 기동하고 모든 요청에 200을 주고 에러 로그가 0건이다. 단지 느렸다.
Output token throughput (tok/s): 10.34
Mean TPOT (ms): 96.60
토큰 하나에 97ms. 304B를 GPU 두 장에 욱여넣은 것 치고 느리긴 한데, 비교 대상이 없으면 "원래 이 정도인가 보다" 하고 넘어간다. 실제로 그 상태로 쓰고 있었다.
가설 1: MTP speculative decoding — 설정은 통과, 가중치에서 죽음
모델 카드에 MTP(Multi-Token Prediction) 가중치가 들어 있고 config.json에 num_nextn_predict_layers: 1도 있다. 켜면 되겠지.
설정 계층은 통과했다. 로그에 이렇게 찍힌다.
WARNING [speculative.py:687] method `deepseek_mtp` is deprecated and replaced with mtp.
INFO [core.py:121] ... speculative_config=SpeculativeConfig(method='mtp', num_spec_tokens=1)
모델도 엔진도 MTP를 받아들였다. 그런데 그다음에 죽는다.
vllm/v1/worker/gpu_model_runner.py:5333 load_model → self.drafter.load_model(self.model)
vllm/models/deepseek_v4/nvidia/mtp.py:463 load_weights → param = params_dict[name]
KeyError: 'model.layers.43.mtp_block.main_norm.weight'
체크포인트 인덱스를 직접 뒤졌다. 로더가 찾는 mtp_block이라는 이름은 0건이다. 대신 DeepSeek 원본 규약인 mtp.0.* / mtp.1.* / mtp.2.* 로 4,705개가 들어 있었다. 로더가 찾던 것에 대응하는 실물은 mtp.0.main_norm.weight로 멀쩡히 존재한다.
양쪽 다 가중치는 있는데 그 사이를 잇는 매핑이 없다. 하드웨어 문제가 아니라 체크포인트 저장 규약과 로더 기대값의 불일치였다. 인자로 뚫릴 성질이 아니다.
가설 2: MoE 백엔드 교체 — "후보 목록"에 속았다
기동 로그에 이런 줄이 있다.
Using 'FLASHINFER_CUTLASS' NvFp4 MoE backend out of potential backends:
['FLASHINFER_TRTLLM', 'FLASHINFER_CUTLASS', 'MARLIN']
후보에 TRTLLM이 있으니 명시적으로 지정하면 되겠지 싶었다. --moe-backend flashinfer_trtllm. 결과는:
vllm/model_executor/layers/fused_moe/oracle/nvfp4.py:242 _return_or_raise
ValueError: NvFp4 MoE backend 'FLASHINFER_TRTLLM' does not support the deployment
configuration since kernel does not support current device cuda.
소스를 열어 보고서야 이해했다. 저 potential backends 목록은 device 지원 여부를 반영하지 않는다. 명시 지정 경로는 is_supported_config를 물어보고 실패하면 fallback 없이 그 자리에서 raise한다. 그리고 Using '<백엔드>' 로그는 지원되는 경로에서만 찍힌다 — 즉 자동 선택이 CUTLASS를 고른 것 자체가 이미 device 판정 결과였다. 내가 "선택지"로 읽은 목록은 사실 답이 이미 나와 있는 목록이었다.
가설 3, 4: 블록 크기와 배치 크기
--block-size 128:
vllm/v1/worker/utils.py:321 select_common_block_size
ValueError: No common block size for 128.
DeepseekV4IndexerBackend.get_supported_kernel_block_sizes()가 정확히 [256]을 반환한다. 협상 가능한 값이 아니었다.
--max-num-batched-tokens를 4096 → 16384로:
ValueError: To serve at least one request with the model's max seq len (250000),
14.02 GiB KV cache is needed, which is larger than the available KV cache memory (7.26 GiB).
prefill 청크를 4배 키운 대가로 KV가 무너졌다. 메모리 상한을 최대로 올려도 9.16GB — 4.86GB 모자란다. 250k 컨텍스트를 포기하지 않는 한 성립하지 않는다.
반전: 범인은 처음부터 실행 커맨드에 있었다
특화 최적화가 전부 막히고 나서야 기본 플래그를 다시 읽었다. 그러다 --enforce-eager에 눈이 갔다. 이건 왜 켜져 있지?
이 서버를 처음 띄울 때, 07-31 이후 vLLM nightly가 번들 DeepGEMM에서 SM120 커널을 빼 버려 기동 자체가 안 되던 시기가 있었다. 그때 온갖 우회를 시도하면서 **"일단 뜨게 하려고" 켠 것이 --enforce-eager**였다. 결국 진짜 해법은 이미지 태그를 07-29 빌드로 내리는 것이었고, 그 시점에 문제는 해소됐다.
그런데 플래그는 남았다. "모델 카드 구성에 충실"이라는 그럴듯한 사유까지 붙어서.
그동안 이 플래그는 CUDA graph를 통째로 끄고 있었다.
WARNING [vllm.py:1172] Enforce eager set, disabling torch.compile and CUDAGraphs.
INFO [vllm.py:1401] Cudagraph is disabled under eager mode
INFO [gpu_worker.py:789] ... and 0.0 GiB for CUDAGraph memory.
43개 레이어 × MoE의 커널 런치 오버헤드를 매 토큰마다 물고 있었던 것이다. 한 줄을 빼고 재기동했다.
enforce_eager=False
cudagraph_mode: <CUDAGraphMode.FULL_AND_PIECEWISE: (2, 1)>
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 5/5
Capturing CUDA graphs (decode, FULL): 4/4
| 전 | 후 | |
|---|---|---|
| Mean TPOT | 96.60 ms | 9.03 ms |
| Output throughput | 10.34 tok/s | 105.11 tok/s |
대가는 KV 캐시 −7.8%, CUDA graph 캡처 메모리 GPU당 0.2GB뿐이었다.
두 번째 반전: 내 측정이 판정을 뒤집었다
KV 손실을 메우려고 메모리 상한을 0.95 → 0.97로 올려 봤다. 측정하니 throughput이 1.48 tok/s 떨어졌다. 악화로 판정하고 버렸다.
그런데 커밋 직전 리뷰에서 이상한 점이 나왔다. 같은 구성을 세 번 쟀는데 89 / 105 / 105였다. 첫 회만 낮다. 확인해 보니 워밍업 요청 한 건으로는 예열이 끝나지 않았다 — 부팅 직후 첫 요청은 TTFT 2~2.8초를 먹고, 생성 토큰이 고정된 짧은 벤치에서는 그 2초가 그대로 −15%가 된다.
두 후보를 같은 예열 상태에서 다시 쟀다.
| 웜 3회 | 평균 | TPOT | |
|---|---|---|---|
| 상한 0.95 | 105.23 / 104.96 / 105.46 | 105.22 | 9.07 |
| 상한 0.97 | 104.97 / 105.54 / 105.57 | 105.36 | 9.04 |
동률이었다. 오히려 0.97이 미세하게 빠르다. 그 "1.48 악화"는 통째로 콜드 스타트 인공물이었다. 같은 속도에 KV를 19.5% 더 주니 당연히 0.97을 채택해야 했고, 판정을 뒤집었다.
덤: 짧은 요청은 멀쩡한데 긴 요청만 죽는 결함
KV를 더 늘리겠다고 --kv-cache-memory-bytes로 바이트를 직접 못박아 봤다. 기동도 되고 KV도 809,916 토큰까지 늘었다. 그런데 회귀 검사에서 13만 토큰짜리 입력이 500을 뱉었다.
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 514.00 MiB.
vllm/model_executor/layers/sparse_attn_indexer.py:500 → fp8_fp4_mqa_logits
원인은 vLLM 자신의 로그에 있었다.
reserved 13.1 GiB memory for KV Cache as specified by kv_cache_memory_bytes config and
skipped memory profiling. This does not respect the gpu_memory_utilization config.
이 플래그는 메모리 프로파일링을 통째로 건너뛴다. 활성화 몫을 예약하지 않으니 장문맥 prefill이 쓸 여유가 남지 않는다. 짧은 요청은 전부 멀쩡했다. 회귀 검사에 "13만 토큰 실제 추론"을 넣어 두지 않았다면 조용히 채택돼 운영에서 터졌을 것이다.
교훈
- 우회책은 원인보다 오래 산다. "일단 뜨게 하려고" 넣은 플래그는 넣는 순간 주석에 왜 넣었는지와 무엇이 해소되면 뺄 수 있는지를 함께 적어라. 원인이 해소되는 커밋에서 같이 빼는 것이 원래 한 걸음이다.
- 플래그가 존재한다 ≠ 그 경로가 동작한다. CLI에 있고 파싱도 되지만 커널이 없을 수 있다. 소스에서 플래그를 찾은 것은 인자 전달의 증거일 뿐이다.
- 성능 저하는 기능 검사에 안 잡힌다. 느린 것은 깨진 것이 아니다. 재실행 가능한 측정 스크립트와 기준 수치를 코드와 같은 곳에 두어야 "느려졌다"를 판정할 수 있다.
- 구성을 비교할 땐 예열 상태를 맞춰라. 그리고 throughput보다 TPOT를 우선 지표로 보라 — 같은 조건에서 throughput이 18% 흔들리는 동안 TPOT는 0.04ms 안에 들어왔다.
- 회귀 검사에 "가장 무거운 실사용 경로"를 넣어라. 짧은 요청만 검사하면 긴 요청만 죽는 결함을 통과시킨다.
부록
CUDA graph가 실제로 켜졌는지 확인 — 인자만 보지 말고 로그로 확인한다.
docker logs <컨테이너> 2>&1 | grep -c "Cudagraph is disabled under eager mode" # 0이어야 정상
docker logs <컨테이너> 2>&1 | grep -E "enforce_eager=|Capturing CUDA graphs"
우회책 감사 — 실행 커맨드에서 "응급처치" 계열 플래그를 뽑아 하나씩 물어본다: 이걸 넣은 이유가 아직 유효한가?
grep -nE -- '--enforce-eager|--disable-|VLLM_USE_[A-Z_]+=0|--no-' run.sh
웜 상태로 벤치하기 — 첫 회를 버리고 3회를 잰다.
for i in 0 1 2 3; do
vllm bench serve --backend openai-chat --endpoint /v1/chat/completions \
--base-url <서버>:<포트> --model <모델명> --tokenizer /path/to/model \
--trust-remote-code --dataset-name random --max-concurrency 1 \
--num-prompts 5 --random-input-len 64 --random-output-len 256 --ignore-eos \
| grep -E "^(Output token throughput|Mean TPOT)"
done # i=0 결과는 버린다댓글
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.