Static batching의 문제는 padding에서 시작된다. 배치 안의 시퀀스들은 현실에서 동일한 길이를 갖지 않으니, 짧은 시퀀스 뒤에 pad 토큰을 채워 가장 긴 시퀀스에 맞춘다. GPU는 pad 토큰에도 연산을 소비하지만 유효한 결과는 없다.
길이 편차가 10배인 배치를 생각해보자. 최소 128 토큰, 최대 1,280 토큰짜리 요청 32개가 섞였을 때, 유효 연산 비율은 다음과 같다:
유효 토큰 = sum(실제 길이) ≈ 평균 700 × 32 = 22,400
총 슬롯 = 최대 길이 × 배치 크기 = 1,280 × 32 = 40,960
실효 연산 비율 = 22,400 / 40,960 ≈ 54.7 %
절반 가까운 연산이 버려진다. 더 나쁜 건 짧은 요청들이 이미 완료됐어도 GPU가 가장 긴 시퀀스가 끝날 때까지 다음 배치를 시작할 수 없다는 점이다.
Iteration-level 스케줄링이란
Orca (Yu et al., OSDI 2022) 논문이 제안한 아이디어는 스케줄링 단위를 request에서 iteration으로 바꾸는 것이다. 전통적인 스케줄링에서는 배치가 한 번 구성되면 내부 시퀀스가 모두 끝날 때까지 멤버십이 고정된다. Iteration-level 스케줄링에서는 각 forward pass 직후 스케줄러가 개입해 완료된 시퀀스를 빼고 대기 중인 새 요청을 넣는다.
# Static batching — request-level
batch = queue.pop(N)
outputs = model.generate(batch) # 전체 배치가 EOS까지 대기
queue.push(next_N_requests)
# Continuous batching — iteration-level
while queue or running:
for seq in list(running):
if seq.is_done():
running.remove(seq)
if queue:
running.add(queue.pop())
outputs = model.forward_one_step(running) # 토큰 하나씩
decode 단계에서 하나의 iteration은 배치 내 각 시퀀스에서 토큰 하나를 생성하는 pass다. 어떤 시퀀스가 EOS를 생성하는 바로 그 iteration에 그 슬롯이 재사용된다. GPU가 전체 배치 완료를 기다릴 필요가 없으니, 요청 길이 분포가 들쭉날쭉해도 가동률이 유지된다. Orca 논문이 GPT-3 175B 기준 NVIDIA FasterTransformer 대비 36.9배 처리량을 보고한 이유가 이 구조 차이다.
처리량이 실제로 얼마나 오르는가
Anyscale의 2023년 벤치마크가 이 차이를 수치로 보여준다. Llama-13B, A100 기준:
| 방식 | 처리량 (tokens/sec) |
|---|---|
| Static batching (HuggingFace) | 약 81 |
| Continuous batching (vLLM) | 약 1,900 |
| 배율 | 약 23× |
이 23배는 요청 길이 분산이 최대인 시나리오다. 분포가 균일해질수록 격차는 좁혀진다. 모든 요청이 동일 길이라면 static batching에서도 padding이 발생하지 않고, vLLM이 매 iteration마다 배치를 재구성하는 스케줄링 오버헤드가 오히려 눈에 띄기 시작하는 구간이다.
현실 LLM 워크로드는 지수 분포에 가깝다. 대부분의 요청은 짧고 일부가 매우 길다. 이 분포에서 continuous batching의 이점이 극대화된다. vLLM의 benchmark_serving.py로 두 시나리오를 직접 비교할 수 있다:
# 균일 분포 — static과 차이 작음
python benchmarks/benchmark_serving.py \
--backend vllm \
--model meta-llama/Llama-3-8B-Instruct \
--dataset-name random \
--random-input-len 128 \
--random-output-len 128 \
--num-prompts 500
# 실세계 분포 근사 (ShareGPT) — continuous batching 이점 극대화
python benchmarks/benchmark_serving.py \
--backend vllm \
--model meta-llama/Llama-3-8B-Instruct \
--dataset-name sharegpt \
--num-prompts 500
두 실행의 throughput 차이가 얼마나 나는지가 자신의 워크로드에서 continuous batching 이득을 예측하는 기준점이 된다. 외부에서 가져온 벤치마크 수치를 그대로 믿기 전에 이 측정을 먼저 해야 한다.
Head-of-Line Blocking
처리량이 올라가는 동시에 latency 문제가 생긴다.
배치에 decode 중인 긴 시퀀스가 자리를 차지하면, 새로 들어온 짧은 요청은 prefill을 시작하지 못하고 대기한다. 자신과 무관한 긴 시퀀스의 남은 decode iteration이 끝나야 슬롯이 생기는 구조다. 배치 크기 32 중 하나가 4,096 토큰짜리 요청으로 decode 3,800번째 iteration을 진행 중이라면, 새로 들어온 50 토큰짜리 요청은 296번의 iteration을 기다린 뒤에야 prefill을 시작한다. 그 50 토큰짜리가 실제로 처리되는 시간보다 대기 시간이 몇 배 길다.
p50 TTFT가 낮아도 p99가 수초를 기록한다면 이 패턴이 원인일 가능성이 높다. GPU SM utilization 수치만으로는 이 문제를 볼 수 없다 — 사용률이 90%여도 그게 긴 요청 하나의 독점 때문인지, 다양한 요청의 병렬 처리 덕분인지 구분되지 않는다.
TGI는 waiting_served_ratio 파라미터로 대응한다. 대기 요청 수가 실행 중 요청 수의 일정 배수를 넘으면 짧은 요청을 우선 스케줄링하는 휴리스틱이다. vLLM에서는 max_num_seqs를 줄여 배치에 동시에 들어올 수 있는 시퀀스 수에 상한을 두는 방식을 택한다. 두 프레임워크가 같은 문제를 다른 파라미터로 노출한다.
배치 크기의 상한: KV 캐시와의 충돌
배치를 키울수록 처리량이 오르지만 KV 캐시 메모리가 한계를 그어둔다. 각 시퀀스는 토큰마다 다음 크기의 KV 캐시를 요구한다:
KV per token = 2 × num_layers × num_kv_heads × head_dim × bytes_per_element
Llama-3 8B (FP16, num_layers=32, num_kv_heads=8, head_dim=128):
= 2 × 32 × 8 × 128 × 2 bytes = 131,072 bytes ≈ 128 KB / token
A100 80GB에서 vLLM 기본 설정(gpu_memory_utilization=0.9) 기준:
가용 메모리 = 80 × 0.9 = 72 GB
가중치 점유 ≈ 16 GB (FP16 8B 모델)
KV 캐시 풀 ≈ 56 GB
4K 컨텍스트: 56 GB / (128 KB × 4,096) ≈ 109 시퀀스
8K 컨텍스트: 56 GB / (128 KB × 8,192) ≈ 54 시퀀스
Llama-3 70B는 FP16 기준 가중치만 140 GB(70B × 2 bytes)다. A100 80GB 2장(160 GB)으로는 KV 캐시에 가용 메모리가 거의 남지 않는다. 4장(320 GB) 구성에서야 KV 캐시 풀이 제대로 확보된다. Llama-3 70B는 num_layers=80, num_kv_heads=8이므로 KV per token이 320 KB로 올라간다:
KV 캐시 풀 ≈ 320 × 0.9 − 140 = 148 GB
4K 컨텍스트: 148 GB / (320 KB × 4,096) ≈ 116 시퀀스
배치가 가득 찬 상태에서 KV 캐시가 부족해지면 vLLM은 일부 시퀀스의 KV 블록을 CPU 메모리로 swap out한다. PCIe 대역폭 병목이 생기면서 해당 시퀀스에 latency 스파이크가 발생한다. max_num_seqs를 계산값에 맞게 설정하지 않으면 이 스파이크가 p99를 지속적으로 오염시킨다.
Chunked Prefill: prefill을 쪼개면 무엇이 달라지는가
Head-of-Line Blocking이 decode 시퀀스가 새 요청을 막는 문제라면, 반대 방향의 문제도 있다. 긴 프롬프트의 prefill은 compute-intensive하며, prefill iteration이 끝날 때까지 decode 요청들이 전부 멈춘다. 4,096 토큰 prefill이 한 번에 실행되면 배치 내 모든 decode 시퀀스의 TBT(Time Between Tokens)가 그 시간만큼 밀린다.
Sarathi-Serve (Agrawal et al., OSDI 2024)가 제안한 chunked prefill은 prefill을 작은 chunk로 쪼개서 decode iteration 사이에 끼워 넣는다. 4,096 토큰짜리 prefill을 매 iteration에 512 토큰씩 처리하면, decode 요청들은 prefill chunk와 같은 iteration에서 함께 진행된다. decode가 멈추는 시간이 chunk 하나 크기만큼으로 줄어드는 것이다.
vLLM에서는 다음 옵션으로 활성화한다:
vllm serve meta-llama/Llama-3-8B-Instruct \
--enable-chunked-prefill \
--max-num-batched-tokens 2048
--max-num-batched-tokens가 한 iteration에 처리할 토큰 수 상한, 즉 chunk 크기를 결정한다. 값을 줄이면 decode TBT가 안정되지만 prefill 완료까지 더 많은 iteration이 필요해져 긴 프롬프트의 TTFT가 늘어날 수 있다. 값을 키우면 긴 프롬프트가 빨리 끝나지만 TBT 지연이 다시 나타난다. 512 / 1024 / 4096 세 값은 throughput과 latency 균형을 각각 다르게 잡는다. 챗봇처럼 짧은 프롬프트에 긴 생성이 주인 워크로드라면 1024가 무난한 시작점이고, 긴 문서 요약처럼 prefill-heavy한 경우라면 4096 쪽이 낫다. 최적값은 워크로드 분포에 달려 있어서, 측정 없이 단일 값을 고정하는 건 권장하지 않는다.
언제 continuous batching이 손해인가, 그리고 무엇을 측정해야 하는가
요청 길이 분산이 작은 워크로드에서는 이득이 사라진다. 동일한 길이의 요청만 들어오면 static batching에서도 padding이 없고, 매 iteration마다 배치를 재구성하는 스케줄링 비용이 순손실이 된다. Embedding 생성이나 분류처럼 decode 단계가 없는 prefill-only 워크로드도 마찬가지다. 오프라인 배치 추론처럼 GPU 하나를 단일 요청에 써도 무방한 시나리오라면 복잡한 스케줄러 자체가 불필요하다.
분리해서 추적해야 할 지표는 TTFT, TPOT, 그리고 길이 버킷별 p99 세 가지다. TTFT는 Head-of-Line Blocking의 직접 신호고, TPOT(Time Per Output Token)는 decode 단계 한 토큰당 지연으로 배치 크기가 커질수록 memory bandwidth 경합으로 늘어난다. 이 두 지표는 입력 길이 버킷(0512 / 5122048 / 2048+ 토큰)별로 p99를 독립적으로 추적해야 한다. 전체 p99가 특정 길이 범위에서만 오는지 확인하지 않으면 max_num_seqs를 조정해도 어디에 영향이 갔는지 알 수 없다. chunked prefill 튜닝도 이 체계 없이는 평가 자체가 불가능하다.