Continuous batching을 도입하고 나서 "왜 TTFT가 아직도 튀나"라는 질문을 받은 적이 있다. GPU 메모리가 부족한가, 모델이 너무 큰가 — 원인은 그보다 훨씬 가까운 곳에 있었다. 스케줄러 정책이었다. 같은 GPU·같은 모델에서 플래그 하나가 p99 TTFT를 수 배 단위로 바꾼다.
Continuous batching이 해결한 것과 해결하지 못한 것
Static batching 시대의 LLM 서빙 서버는 배치가 가득 찰 때까지 기다렸다가 한꺼번에 실행하는 방식이었다. 가장 긴 시퀀스에 맞춰 나머지를 패딩하고, 배치 안의 모든 요청이 생성을 끝내야 다음 배치를 시작했다. GPU는 이 구조에서 만성 저활용 상태였다.
Orca (Yu et al., OSDI 2022)가 iteration-level scheduling을 제안하면서 패러다임이 바뀌었다. 하나의 forward pass가 끝날 때마다 스케줄러가 개입해 종료된 시퀀스를 빼고 새 요청을 밀어 넣는다. 배치 크기가 아닌 iteration이 스케줄링의 단위가 된다. Anyscale가 공개한 벤치마크에서 순수 continuous batching 기준 static batching 대비 8배, vLLM의 PagedAttention과 합산하면 23배 처리량 개선이 나왔다.
문제는 "해결한 것"의 경계 바로 너머에 있다. Continuous batching이 다음 iteration을 시작하려면 현재 iteration이 먼저 끝나야 한다. Prefill과 decode는 iteration 하나의 소요 시간이 구조적으로 다르다.
요청 길이 편차가 스케줄러를 어떻게 압박하는가
Prefill은 compute-bound 연산이고 decode는 memory-bound 연산이다. 같은 GPU에서 같은 모델을 돌려도 phase별로 iteration 시간이 크게 벌어진다. A100 80GB, Llama-3 70B 참고 수치로 4,096 토큰 prefill 하나가 약 120ms, decode 한 스텝이 약 18ms다.
이 비율이 head-of-line blocking의 근원이다. 120ms 동안 decode는 6~7 스텝을 밟을 수 있었다. Continuous batching 큐에 decode 중인 요청이 10개 있고 거기에 8,192 토큰짜리 입력이 새로 들어오면, 그 prefill이 끝나기 전까지 나머지 10개 요청의 next-token 생성이 전부 멈춘다. FCFS로 스케줄링하면 긴 요청이 큐 앞자리를 차지하는 순간 뒤에 줄 선 짧은 요청들은 수백 ms 동안 첫 토큰을 받지 못한다.
트래픽이 균일하면 continuous batching은 잘 작동한다. 요청 길이의 분산이 커지면 p99 TTFT가 폭발한다.
Chunked-prefill: prefill을 쪼개서 decode와 interleave
Sarathi-Serve (Agrawal et al., OSDI 2024)가 이 문제를 정면으로 다뤘다. 긴 prefill을 한 iteration에 몰아 처리하지 말고 chunk 단위로 잘라 decode iteration 사이에 끼워 넣는다. 8,192 토큰짜리 prefill을 chunk 크기 512로 자르면 16개 chunk가 되고, 각 chunk는 decode step들 사이에 분산된다. 뒤에 줄 선 짧은 요청들은 512 토큰이 처리될 때마다 한 번씩 decode 기회를 얻는다.
vLLM은 --enable-chunked-prefill 플래그로 이 기능을 켜고, V1부터는 기본 활성화 상태다. SGLang은 --chunked-prefill-size로 chunk 크기를 직접 지정한다.
# vLLM (V0 이하에서 명시 활성화)
vllm serve meta-llama/Llama-3-70b-Instruct \
--enable-chunked-prefill \
--max-num-batched-tokens 2048
# SGLang
python -m sglang.launch_server \
--model-path meta-llama/Llama-3-70b-Instruct \
--chunked-prefill-size 4096
chunk 크기 선택이 까다롭다. Sarathi-Serve 논문이 Yi-34B 기준으로 실측한 결과, C=128 미만에서는 attention kernel이 memory-bound 영역으로 떨어져 throughput이 급락했다. C=2048을 넘어서면 blocking 시간이 다시 늘어 chunked-prefill의 효과가 희석됐다. Mistral-7B에서는 C≈512, LLaMA-2 70B TP4에서는 C≈1024가 최적이었다. 논문이 "flat region"이라 부르는 안전 구간이 최적값 기준 1.5~2배 폭으로 존재하므로, 정확한 최적점보다 그 구간 안에 들어오는 게 실질적인 목표다.
vLLM 공식 문서는 max_num_batched_tokens에 대해 "2048 이하면 ITL 개선, 8192 이상이면 throughput 개선"이라고 명시한다. SGLang 문서는 긴 컨텍스트 워크로드에 --chunked-prefill-size 16384, 메모리 압박이 심하면 4096이나 2048로 낮추도록 권장한다.
스케줄러 정책 비교: FCFS, preemption, priority
vLLM, SGLang, TGI는 chunked-prefill을 공통으로 지원하면서도 스케줄러 정책이 다르다.
| 항목 | vLLM | SGLang | TGI |
|---|---|---|---|
| 기본 순서 | FCFS + decode 우선 | decode 우선 + radix cache hit 우선 | waiting-served-ratio 기반 |
| prefill 조정 파라미터 | max_num_batched_tokens | --chunked-prefill-size | --waiting-served-ratio, --max-waiting-tokens |
| 선점(preemption) | swap(CPU) 또는 recompute | recompute | — |
| 캐시 인식 스케줄링 | 없음 | RadixAttention | 없음 |
vLLM이 KV cache 공간 부족으로 실행 중인 요청을 쫓아내야 할 때는 두 경로가 있다. CPU로 KV cache를 swap하거나, 시퀀스를 버리고 나중에 prefill부터 재실행(recompute)하는 것이다. swap은 PCIe 대역폭 한계에 걸린다. Llama-3 70B TP8 환경에서 8,000 토큰 시퀀스의 KV cache는 GPU 하나당 약 2.5 GB다. 이 데이터를 CPU 메모리로 올리는 데만 약 0.1초가 소요되고, KV cache 전송이 weight 로딩과 PCIe 버스를 공유하면서 경합하는 "convoy effect"가 발생하면 P99 latency가 200ms에서 8초까지 치솟는 케이스가 보고됐다. vLLM V1이 기본값을 recompute로 바꾼 이유다.
TGI의 --waiting-served-ratio는 공정성을 제어하는 다른 축이다. "대기 중인 요청 수 / 실행 중인 요청 수" 비율이 이 설정값을 넘으면 현재 decode 배치를 잠시 멈추고 신규 요청 prefill을 시작한다. 기본값 1.2에서는 대기 요청이 실행 요청보다 20% 이상 많아질 때 스케줄러가 신규 유입 쪽으로 기운다. --max-waiting-tokens를 함께 쓰면 "이 토큰 수가 지나면 대기 큐를 강제 소화"라는 상한을 건다. 두 파라미터를 조합하면 긴 요청이 decode를 독점하지 못하도록 상한을 제어할 수 있다.
SGLang의 RadixAttention은 스케줄링 정책과 다른 레이어에서 작동한다. 공통 prefix를 가진 요청의 KV cache를 LRU radix tree에 저장하기 때문에, cache hit이 발생하면 해당 시퀀스의 prefill 연산 자체를 건너뛴다. 멀티턴 대화나 RAG처럼 system prompt가 반복되는 워크로드에서는 스케줄링 파라미터 튜닝과 별개로 TTFT가 구조적으로 낮아진다.
p99 TTFT는 평균 throughput보다 스케줄러에 훨씬 민감하다
max_num_batched_tokens를 낮게 잡으면 각 iteration에 올라가는 prefill 토큰 수가 줄어 decode 요청들이 더 자주 스케줄링 기회를 얻는다. ITL과 p99 TTFT가 좋아지는 대신 throughput이 줄어든다. 이 값을 높이면 처리량은 오르지만 긴 prefill이 한 iteration을 점령하는 빈도가 높아져 p99가 올라간다. vLLM 문서는 이 tradeoff를 명시하며 "2048 이하면 ITL 개선, 8192 이상이면 throughput 개선"이라고 가이드한다.
chunked-prefill을 끄면 throughput은 최대가 되지만 KV cache 경합 상황에서 P99 latency가 200ms에서 8초까지 치솟는 케이스가 프로덕션에서 보고됐다. 긴 prefill 요청 하나가 decode 큐를 통째로 멈추는 이벤트가 반복되면서 발생하는 latency spike다.
Sarathi-Serve 논문이 이 차이의 규모를 보여준다. Yi-34B TP2 환경에서 chunked-prefill + stall-free batching을 적용해 vLLM 대비 3.7배 높은 RPS capacity를 달성했고, Falcon-180B TP4×PP2에서는 5.6배까지 올라갔다. 하드웨어 추가나 모델 경량화 없이 스케줄러 정책 변경만으로 얻은 결과다.
트래픽 패턴에 따른 스케줄러 선택
챗봇처럼 짧은 요청이 대부분이고 tail latency SLO가 엄격한 환경에서는 max_num_batched_tokens를 2048 이하로 잡은 chunked-prefill이 맞다. throughput 일부를 희생하고 p99를 눌러야 할 때의 선택이다.
긴 컨텍스트(RAG, 요약)가 주력이면 chunk 크기를 키워야 TTFT 자체를 낮출 수 있다. SGLang --chunked-prefill-size 16384가 그 용도다. 이 설정에서 짧은 요청의 ITL은 다소 올라간다.
멀티턴 대화나 공통 system prompt가 반복되는 워크로드에서는 RadixAttention이 탑재된 SGLang이 구조적으로 유리하다. 동일 prefix 비중이 높으면 chunked-prefill 설정 튜닝보다 SGLang으로 마이그레이션하는 쪽이 TTFT 개선폭이 훨씬 크게 나온다. cache hit이 발생하는 순간 prefill 연산 자체가 사라지기 때문이다.