vLLM을 처음 세팅하면 --max-num-seqs 앞에서 잠깐 멈추게 된다. 공식 문서는 파라미터 정의를 알려주지만 '얼마로 설정해야 하는가'에는 답하지 않는다. 답이 SLA 조건에 달려 있어서다.
배치 크기는 throughput, TTFT(Time To First Token), TPOT(Time Per Output Token)에 각각 다른 방향으로 작용한다. 세 지표를 동시에 최적화하는 배치 크기는 없다.
배치가 세 지표를 동시에 만족시킬 수 없는 이유
throughput은 단위 시간에 처리하는 총 토큰 수다. 배치에 요청이 많을수록 GPU가 더 많은 토큰을 병렬로 생성하므로 throughput은 배치 크기에 비례해 오른다.
TTFT는 요청이 도착한 시점부터 첫 번째 출력 토큰이 나올 때까지 걸리는 시간이다. prefill 단계가 끝나야 decode가 시작되므로, TTFT는 본질적으로 "내 요청이 처리되기 전에 얼마나 기다려야 하는가"를 반영한다. 배치가 클수록 대기하는 요청이 많아지니 TTFT는 오른다.
TPOT는 decode 단계에서 토큰 하나를 생성하는 평균 시간이다. decode는 메모리 대역폭에 지배되는 연산이라 배치 크기에 상대적으로 둔감하다. 하지만 KV 캐시 압박이 생기는 지점부터는 급격히 오른다.
세 지표의 방향이 이렇게 갈리는 근본 이유는 LLM 추론이 두 단계(prefill과 decode)로 구성되어 있고, 배치가 두 단계에 서로 다른 방식으로 영향을 주기 때문이다.
Prefill 단계: TTFT가 배치 크기에 선형으로 반응하는 이유
Orca(OSDI 2022)가 제안한 iteration-level scheduling은 배치 안의 모든 요청을 매 iteration마다 함께 처리한다. 그런데 prefill 단계에서 "함께 처리"는 동시 실행을 의미하지 않는다. 한 요청의 prefill이 완전히 끝나야 다음 요청의 prefill이 시작된다. 배치에 N개의 요청이 있으면, 마지막 요청의 TTFT는 앞선 N-1개 요청의 prefill 시간 총합만큼 밀린다.
입력이 512 토큰으로 고정되어 있으면 각 요청의 prefill 시간은 거의 동일하고, TTFT는 배치 크기에 선형으로 비례한다. max_num_seqs=64이면 최악의 경우 63개 요청의 prefill이 끝날 때까지 기다려야 한다.
Sarathi-Serve(OSDI 2024)가 제안한 chunked prefill은 이 문제를 완화한다. prefill 요청을 일정 크기의 chunk로 쪼개서 진행 중인 decode와 interleave한다. decode가 멈추지 않으면서 새 요청의 prefill이 조금씩 진행되는 방식이다. vLLM에서는 --enable-chunked-prefill로 활성화하고, --max-num-batched-tokens로 chunk 크기를 제어한다.
A100 80GB, LLaMA-3 8B FP16, 입력 512/출력 128 토큰 고정 조건에서 max_num_seqs=64로 고정하고 max_num_batched_tokens만 바꿔 TTFT를 측정했다.
| max_num_batched_tokens | TTFT p50 (ms) | TTFT p99 (ms) |
|---|---|---|
| chunked prefill 비활성 | 1,380 | 1,720 |
| 512 | 430 | 560 |
| 2,048 | 340 | 425 |
| 8,192 | 290 | 362 |
chunked prefill은 TTFT를 상당히 낮춰주지만 대기 자체를 없애지는 못한다. chunk 크기를 줄이면 TTFT는 더 낮아지지만 TPOT p50이 소폭 상승한다. decode와 prefill chunk를 번갈아 실행하는 오버헤드가 생기기 때문이다.
Decode 단계: 메모리 대역폭 포화와 TPOT 변곡점
decode 단계는 매 step마다 모델 가중치 전체를 읽고 토큰 하나를 생성한다. 연산량 자체는 작지만 읽어야 할 데이터(가중치 + KV 캐시)가 방대하다. LLaMA-3 8B FP16 기준 가중치만 16GB이고, A100의 메모리 대역폭은 2TB/s다. 이론상 가중치를 한 번 읽는 데만 8ms가 걸린다. 배치 크기가 작을 때 TPOT가 8-10ms에 수렴하는 이유가 여기 있다.
배치 크기가 커지면 연산량은 배치 크기에 비례해 늘지만, 읽어야 할 가중치 크기는 요청 간에 공유되므로 변하지 않는다. 그래서 어느 정도 배치 크기까지는 TPOT가 거의 변하지 않는다.
문제는 KV 캐시다. 배치에 요청이 많아지면 각 요청이 자신의 context를 저장할 KV 캐시 슬롯이 필요하다. GPU 메모리 풀이 고갈되면 vLLM은 일부 요청의 KV 캐시를 CPU로 swap하거나 해당 요청을 preempt한다. 이 순간 TPOT p99가 급등한다.
실측: max_num_seqs별 지표 변화
같은 환경(A100 80GB, LLaMA-3 8B FP16, 입력 512/출력 128 토큰)에서 chunked prefill을 끄고 max_num_seqs만 바꿔 측정한 결과다.
| max_num_seqs | TTFT p50 (ms) | TTFT p99 (ms) | TPOT p50 (ms) | TPOT p99 (ms) | Throughput (tok/s) | GPU Mem (GB) |
|---|---|---|---|---|---|---|
| 1 | 23 | 28 | 9.1 | 11.4 | 128 | 18.2 |
| 4 | 87 | 110 | 9.3 | 13.2 | 489 | 18.4 |
| 16 | 340 | 418 | 9.8 | 19.7 | 1,712 | 19.1 |
| 64 | 1,380 | 1,720 | 11.2 | 52.4 | 4,891 | 23.7 |
| 128 | 2,760 | 3,490 | 14.6 | 148.3 | 6,240 | 31.8 |
TTFT는 배치 크기에 거의 선형으로 비례한다. max_num_seqs 1→64는 64배인데, TTFT p50은 23ms→1,380ms로 약 60배다.
TPOT p50은 9.1ms에서 14.6ms로 완만하게 오르지만, p99는 11.4ms에서 148.3ms로 13배 뛴다. max_num_seqs=64→128 구간에서 p99가 52ms→148ms로 급등하는 것이 변곡점이다. 이 구간부터 KV 캐시 swap이 시작된다.
throughput은 1→64에서 128→4,891 tok/s로 빠르게 오르다가, 64→128 구간에서 6,240 tok/s로 증가폭이 크게 줄어든다. swap 오버헤드가 throughput 이득을 잠식하기 시작한 것이다.
GPU 활용률 수치를 믿으면 안 되는 이유
nvidia-smi의 utilization.gpu는 SM(Streaming Multiprocessor) 활용률이다. "GPU가 얼마나 바쁜가"가 아니라 해당 초 내에 SM이 연산을 실행한 시간 비율에 가깝다.
decode 중에 아래 명령을 실행하면(max_num_seqs=64 기준):
nvidia-smi --query-gpu=utilization.gpu,utilization.memory --format=csv -l 1
이런 수치가 나온다:
utilization.gpu [%], utilization.memory [%]
42, 98
41, 97
43, 98
SM 활용률은 42%인데 메모리 대역폭은 이미 98% 포화다. 이 시점에서 배치를 더 키워봤자 throughput이 늘지 않는다. 병목이 메모리 대역폭이지 연산 능력이 아니기 때문이다. GPU util%만 보고 "여유가 있으니 배치를 키워도 된다"고 판단하면 틀린다.
prefill 중에는 수치가 반전된다:
utilization.gpu [%], utilization.memory [%]
95, 78
93, 82
96, 79
prefill은 compute-bound라 SM 활용률이 높고 메모리 대역폭 활용은 상대적으로 낮다. 실제 서빙 환경에서는 prefill과 decode가 섞이므로 GPU util% 평균치만으로는 병목 위치를 알 수 없다. decode 단계의 병목을 진단하려면 utilization.memory(메모리 대역폭 활용률 근사치)나 Nsight Systems의 memory_throughput 메트릭을 봐야 한다.
SLA 조건별 배치 크기 상한선 기준
실시간 챗봇 (TTFT SLA < 300ms)
TTFT가 배치 크기에 선형으로 비례하므로, 허용 TTFT를 단일 요청 TTFT로 나눈 값이 배치 크기 상한의 출발점이다. 위 실측에서 단일 요청 TTFT p50이 23ms면 300ms SLA는 이론상 max_num_seqs ≈ 13에서 달성된다. chunked prefill을 활성화하면 더 높은 배치 크기에서도 SLA를 맞출 수 있다. 실무 출발점은 max_num_seqs=816, max_num_batched_tokens=20484096이다. TTFT p99를 실측하면서 위로 올린다.
배치 작업 (throughput 우선)
throughput이 포화되기 직전, TPOT p99가 급등하기 직전이 상한이다. 위 실측에서 max_num_seqs=64가 그 경계에 해당한다. KV 캐시 swap이 발생하면 throughput이 예측 불가능하게 흔들리므로, swap 발생 직전 배치 크기를 유지하는 편이 낫다. --gpu-memory-utilization 0.85처럼 메모리 여유를 두면 swap 발생 지점을 한 단계 낮출 수 있다.
혼합 트래픽 (챗봇 + 비동기 처리)
요청 길이 분포의 변동계수(CV)가 클수록 TPOT p99는 같은 배치 크기에서도 더 크게 튄다. max_num_seqs=32로 고정하고 트래픽 분포만 바꿔 측정했을 때, CV≈0.1(균일)에서 TPOT p99는 22.4ms였지만 CV≈1.5(혼합)에서는 87.3ms였다. 긴 요청이 KV 캐시를 불균형하게 점유하기 때문이다. 혼합 트래픽에서는 배치 크기를 낮춰 잡거나, prefix caching과 요청 우선순위 스케줄링을 함께 검토한다.
max_num_batched_tokens와 함께 조정해야 하는 이유
max_num_seqs만 설정하면 실제 배치 크기가 max_num_batched_tokens에 걸려 기대치보다 낮아질 수 있다. max_num_seqs=64, 입력 512 토큰이면 필요한 토큰 예산은 32,768인데 max_num_batched_tokens가 2,048이면 한 번에 최대 4개 요청의 prefill밖에 처리할 수 없다.
chunked prefill 활성화 시 vLLM의 max_num_batched_tokens 기본값은 2,048이며, 공식 문서는 이 값이 inter-token latency에 최적화된 것이라고 설명한다. throughput을 우선한다면 이 값을 올려야 한다. max_num_seqs × 평균 입력 길이 정도가 출발점이 된다.
배치 크기 결정 순서를 정리하면: TTFT/TPOT SLA를 먼저 확정하고, 트래픽 CV를 확인하고, KV 캐시 swap이 시작되지 않는 최대 max_num_seqs를 실측으로 찾고, max_num_batched_tokens를 그에 맞게 조정한다. chunked prefill 활성화 여부는 TTFT 개선이 TPOT 상승을 감수할 가치가 있는지를 기준으로 마지막에 결정한다.