[vllm] vLLM 멀티모달 인코더 CUDA Graph 재설계: 배치당 한 번의 replay와 5120 토큰 상한
PR 링크: vllm-project/vllm#65227 상태: Merged | 변경: +227 / -261
들어가며
vLLM은 ViT 같은 멀티모달 인코더에도 CUDA Graph를 적용합니다. 인코더 forward는 작은 커널을 많이 launch하기 때문에, 이미지가 작을 때는 GPU 연산 시간보다 host 측 kernel launch overhead가 지연을 지배합니다. CUDA Graph는 이 launch 비용을 미리 캡처한 그래프 한 번의 replay로 대체합니다.
문제는 기존(main) 구현이 이 전제를 너무 멀리까지 확장했다는 점입니다.
- 그래프를
max_num_batched_tokens(기본 16384)까지 캡처했고, - 한 배치를 greedy packing으로 여러 그룹으로 쪼갠 뒤, 그룹마다 패딩된 그래프를 각각 replay했습니다.
그런데 수천 토큰을 넘어가면 대부분의 인코더는 이미 GPU-bound입니다. 이때 그래프는 절약할 launch 시간이 없고, 패딩과 추가 replay 비용만 남습니다. 결과적으로 큰 배치는 그래프로 돌리는 게 eager보다 오히려 느린 경우가 많았습니다.
이 PR은 LLM 쪽 CUDA Graph와 같은 철학으로 규칙을 단순화합니다.
- 배치당 replay는 최대 1회. 배치 전체를 담을 수 있는 가장 작은 budget 그래프를 한 번 replay하고, 담을 수 없으면 한 번의 eager forward로 처리합니다.
- 촘촘한 budget grid + 전역 상한. 새 옵션
encoder_cudagraph_max_token_budget(기본 5120)이 LLM의 max capture size처럼 동작합니다.
코드 분석
1. docs/design/cuda_graphs_multimodal.md — 디스패치 모델의 변화
설계 문서 diff가 이 PR의 핵심을 가장 잘 보여줍니다. 먼저 manager의 책임 정의가 바뀝니다.
Before
* [EncoderCudaGraphManager]: orchestrates capture, replay, greedy packing, and data-parallel execution for encoder CUDA Graphs.
After
* [EncoderCudaGraphManager]: orchestrates capture, replay, dispatch, and data-parallel execution for encoder CUDA Graphs.
"greedy packing"이 사라졌습니다. 기존 런타임 섹션은 다음과 같았습니다.
Before
### Greedy Bin-Packing at Runtime
When a batch of images arrives, the manager sorts images by output token count (smallest first) and greedily packs as many images as possible into each sub-batch while staying within the **largest** token budget ...
여기에 더해 "budget-cliff deferral"이라는 복잡한 휴리스틱도 있었습니다. 작은 sub-batch 뒤에 큰 이미지가 오면 b_cur + b_alone < b_merged와 b_alone >= b_cur 두 조건을 검사해 분리 여부를 결정하는 로직입니다. 이 섹션이 통째로 삭제되고 다음으로 대체됩니다.
After
### One Replay per Batch
A graph only saves eager's host launch time. Eager latency is the larger of that launch time and the GPU time, while a replay costs the GPU time of its whole captured shape, padding included. A graph therefore helps only while eager is launch-bound, and splitting a batch into several replays only adds padding and fixed costs.
When a batch of images arrives, the manager replays the **smallest** budget that holds the whole batch, once per path. A batch that exceeds the largest captured budget, or has more images than the maximum batch size, runs as one eager forward.
이 문단이 PR의 성능 모델을 요약합니다. 대략 eager ≈ max(launch_time, gpu_time), replay ≈ gpu_time(padded_shape)입니다. launch-bound 영역에서만 그래프가 이기고, GPU-bound 영역에서 배치를 여러 replay로 쪼개는 것은 순수한 손해입니다.
2. Budget grid — 더 촘촘하게, 더 낮게
Before
Budgets are auto-generated ... quarter-octave steps up to 1024 and half-octave steps above (e.g., `[64, 80, 96, ..., 12288, 16384]`, 25 levels for the range `[64, 16384]`)
After
Budgets are auto-generated ...: quarter-octave steps up to 1024 tokens, steps of 128 up to 2560 tokens, then 1/8-octave steps rounded up to a multiple of 128 (e.g., `[64, 80, ..., 1024, 1152, ..., 2560, 2816, 3072, 3456, 3840, 4224, 4608, 5120]`, 36 levels ...). The largest generated budget is `encoder_cudagraph_max_token_budget` in `CompilationConfig` (default 5120; `None` captures up to the model's largest budget), like the LLM's maximum capture size.
배치당 replay가 1회로 제한되므로 "어떤 budget에 걸리느냐"가 곧 패딩 낭비입니다. 그래서 launch-bound 구간(≤5120)의 grid를 half-octave에서 128 단위/1/8-octave로 촘촘하게 바꿨습니다. 그래프 수는 25→36개로 늘지만, 선행 PR #65084가 모든 budget 그래프가 최대 크기 버퍼 하나의 prefix view를 공유하도록 만들어 두었기 때문에 버퍼 메모리는 "가장 큰 budget" 하나에만 비례합니다. 최대 budget이 16384→5120으로 줄었으니 오히려 메모리가 크게 감소합니다.
3. Multi-path 모델 (DeepSeek-OCR, Step3-VL)
Before
**Multi-path greedy packing.** ... The packing algorithm constrains every path simultaneously:
* Sort images by total output tokens (global + local), smallest first.
* Greedily pack images: ...
* Once any path constraint would overflow, or the budget-cliff deferral condition fires on any path, finalize the sub-batch ...
After
**Multi-path dispatch.** ... The whole batch is dispatched once per path: each non-empty path replays its smallest budget that holds the path's total tokens.
여러 path를 동시에 만족시키는 packing 루프가 "path당 한 번 결정"으로 바뀌었습니다. 고정 크기 아이템 path는 여전히 item 크기의 배수(1~16개는 정확히 맞게)로 budget을 생성합니다.
4. GLM-4.1V 캡처 크기 버그 수정
GLM-4.1V는 dummy 캡처 아이템 크기를 budget // max_batch_size로 내림 계산했습니다. power-of-2 budget에서는 정확했지만, #65084의 촘촘한 budget(예: 96, max_batch_size 64)에서는 64×1×4=256 row만 캡처되고 실제 96토큰 이미지는 384 row가 필요해 _copy_padded_buffer에서 터졌습니다. main에서도 252x252 이미지 하나로 RuntimeError: The size of tensor a (256) must match the size of tensor b (324)가 재현됩니다. 이 PR은 다른 모델처럼 올림으로 바꿔 해결했습니다. grid를 바꿀 때는 그 grid에 암묵적으로 의존하던 정수 나눗셈을 함께 점검해야 한다는 좋은 사례입니다.
5. Eager 경로 정비
상한 위의 배치가 이제 encoder_eager_forward로 가므로, 거의 쓰이지 않던 경로가 갑자기 hot path가 됐습니다. 세 모델을 고쳤습니다.
- DeepSeek-V4.1: 이미지당 forward 1회 → 하나의 varlen 배치 forward로 변경 (기존 방식은 큰 배치에서 2~3배 느림)
- Ernie-4.5-VL: resampler를 두 번 돌려
shape '[-1, 10240]' is invalid로 crash - DeepSeek-VL2:
encoder_eager_forward가 없어None반환
리뷰에서 나온 논의
- 설정 검증: PR Pundit 리뷰는
encoder_cudagraph_max_token_budget <= 0이면_generate_budgets의max(cap, min_budget)때문에 조용히 거의 모든 배치가 eager로 간다고 지적했습니다. 작성자는 0/음수를 거부하고LLM엔트리포인트를 통해 모델 로딩 전에 에러가 나는지 검증하는 회귀 테스트를 추가했습니다. - frame capacity 오버플로: subhashpolisetti는 130프레임 112x112 비디오 하나로
cu_seqlens가 65 vs 66으로 넘치는 crash를 재현했습니다. 작성자는 처음에 기본값에서는 불가능하다고 했다가,get_max_frames_per_video()가 기본 video limit 999로 프레임을 나눠 1을 반환한다는 점을 확인하고 주장을 정정했습니다. main에도 있는 문제라 #60112에서 일반적으로 해결합니다. max_batch_size의미 변화: 이제 9장 배치는max_vision_items_per_batch=8이면 8+1 replay가 아니라 통째로 eager가 됩니다. 문서화가 요청되어 반영됐습니다.- startup probe 폐기: MI355X 서빙 측정에서 이전 리비전의 startup timing probe가 rank마다, 재시작마다 다른 cut point를 골랐습니다. 이를 계기로 결정적인 고정 grid로 전환했습니다. 성능 휴리스틱이 재현성을 해치면 안 된다는 교훈입니다.
왜 이게 좋은가
수치 (GB200, BF16, 현재 main 대비)
| 모델 | 인코더 속도 (geomean) | 2560 토큰 초과 배치 | 그래프 메모리 |
|---|---|---|---|
| InternVL3-8B | 1.133x | 1.172x | 2.89 → 0.98 GiB |
| Qwen2.5-VL-7B | 1.113x | 1.214x | 3.59 → 1.34 GiB |
| MiniMax-M3 | 1.086x | 1.119x | 4.99 → 1.79 GiB |
| DeepSeek-V4.1-Flash | 1.072x | 1.137x | 6.29 → 2.25 GiB |
MI355X에서 MiniMax-M3 end-to-end 서빙은 처리량 +2.9%, 인코더 그래프 메모리 16.9→5.7 GiB/rank로 KV cache 토큰 +8%를 얻었습니다. 그래프 메모리가 KV cache 몫에서 빠지므로 메모리 절감은 곧 서빙 용량입니다. 손으로 튜닝한 budget 리스트보다도 나았습니다.
5120의 근거. 24개 모델 전체의 eager 시간을 함께 측정해 상한 X별로 시뮬레이션한 결과, 5120은 "eager로 떨어져서 main보다 2% 넘게 느려지는 배치가 하나도 없는" 가장 작은 값이었고 geomean(1.169x)도 최고였습니다. 단, Gemma 4에서 정확히 32장(8960 토큰) 배치는 9% 느려지는 알려진 trade-off가 있고, 필요하면 상한을 올리면 됩니다.
일반적 교훈
- CUDA Graph는 launch-bound 영역에서만 이득이다. GPU-bound 영역에서 padding된 그래프는 비용일 뿐입니다. 상한을 무작정 키우지 말고 eager와 교차하는 지점을 측정하세요.
- 영리한 휴리스틱보다 단순한 규칙. budget-cliff deferral 같은 greedy packing을 제거하고 "1 replay or 1 eager"로 바꾸자 더 빠르고 코드도 짧아졌습니다(테스트/문서 제외 −10줄).
- 모델별 튜닝 대신 하나의 전역 상한. 초기 리비전의 per-model cap은 상속 구조 때문에 유지보수가 어려웠고, 전역 5120과 성능 차이는 1~2% 수준이었습니다.
- fallback 경로를 hot path로 승격할 때는 그 경로를 검증하라. 이 PR은 eager 경로의 crash 두 개와 성능 문제 하나를 함께 찾아 고쳤습니다.
참고 자료
- CUDA Graphs — PyTorch docs (torch.cuda.CUDAGraph) — 인코더 그래프 캡처/replay의 기반 API
- torch.cuda.graph — 그래프 캡처 컨텍스트 매니저
- torch.allclose — graph vs eager 정합성 검증에 사용
- CUDA Programming Guide: CUDA Graphs — launch overhead 절감 원리
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [sglang] Kimi-K3.1 전용 MXFP4 Decode MoE 커널을 Shape 템플릿으로 일반화해 DeepSeek-V4까지 확장하기
- 현재글 : [vllm] vLLM 멀티모달 인코더 CUDA Graph 재설계: 배치당 한 번의 replay와 5120 토큰 상한
- 다음글 [flashinfer] GB300 NVFP4 Context Attention: scale slot 조기 반납과 8단 packed K/V ring으로 최대 1.26x 가속
댓글