Prefill과 Decode는 같은 트랜스포머 모델에서 일어나지만 GPU에게는 전혀 다른 연산이다. Prefill은 프롬프트 토큰 전체를 한 번에 처리하는 행렬×행렬 곱셈이고, Decode는 토큰 하나씩 생성하는 행렬×벡터 곱셈이다. 이 구조 차이가 두 단계를 근본적으로 다른 하드웨어 병목으로 갈라놓고, 그 갈라짐이 서빙 아키텍처 선택 전체를 결정한다.
Prefill은 compute-bound, Decode는 memory-bandwidth-bound
Arithmetic intensity는 메모리에서 읽어온 바이트당 수행하는 연산량(FLOP/byte)이다. GPU에는 ridge point가 있는데, 이 값을 넘으면 compute-bound, 못 미치면 memory-bandwidth-bound다.
A100의 ridge point는 약 153 FLOP/byte다. FP16 피크 연산량 312 TFLOPS를 메모리 대역폭 2,039 GB/s로 나눈 값이다. Llama 계열 모델(hidden dim 4096) 기준으로 두 단계의 arithmetic intensity를 나란히 놓으면:
| 단계 | 조건 | Arithmetic Intensity | 병목 |
|---|---|---|---|
| Prefill | seqlen=512 | ~410 FLOP/byte | compute |
| Prefill | seqlen=1024 | ~680 FLOP/byte | compute |
| Decode | batch=1 | ~1 FLOP/byte | memory BW |
| Decode | batch=32 | ~32 FLOP/byte | memory BW |
Prefill은 ridge point를 2~4배 넘어 GPU 연산 능력이 병목이다. Decode는 배치 크기를 32로 키워도 153의 20%에 불과하다. Decode 중 GPU는 대부분의 시간을 가중치를 메모리에서 다시 읽어오는 데 쓴다. 실제 행렬 곱셈은 그 로딩 시간 안에서 끝난다. Prefill 스텝에서 SM 활용률이 90%를 넘다가 Decode로 전환하는 순간 20% 아래로 떨어지는 이유다.
이 두 단계를 동시에 최적화하는 GPU 설정은 없다. Prefill에 최적인 설정은 Decode에 쓸모없는 compute를 줄 뿐이다.
같은 GPU에 묶으면 생기는 간섭
Continuous batching이 도입된 이후, Prefill 중인 요청과 Decode 중인 요청이 같은 배치에 들어가는 건 자연스러운 일이다. 짧은 프롬프트가 대부분이면 이 간섭은 무시할 만하다. 문제는 긴 프롬프트 요청이 하나라도 섞이는 순간이다.
2,048 토큰짜리 프롬프트 요청이 배치에 들어오면 그 스텝에서 GPU 연산 자원 대부분을 Prefill이 독점한다. 이미 Decode 중이던 다른 요청들은 그 스텝이 끝날 때까지 멈춰 기다린다. TPOT(Time Per Output Token) p99가 수십 ms에서 수백 ms로 튀는 현상이 여기서 발생한다. 요청 하나가 배치 전체를 막아버리는 head-of-line blocking이다.
TTFT(Time To First Token)와 TPOT는 서로 다른 SLO에 묶인다. TTFT는 새 요청이 첫 번째 토큰을 받기까지의 시간, TPOT는 이미 응답 중인 요청의 토큰 간격이다. 두 지표가 같은 GPU 자원을 두고 경쟁하기 때문에 TTFT를 낮추려고 Prefill을 빠르게 처리하면 TPOT가 나빠지고, TPOT를 보호하려고 Decode를 우선하면 TTFT가 올라간다. Continuous batching은 이 근본적인 트레이드오프를 해소하지 못한다.
DistServe(Zhong et al., OSDI 2024)는 이 간섭을 "prefill-decoding interference"로 정식화했다. 논문이 실험으로 보인 것은, 기존 시스템이 TTFT와 TPOT 둘 다 엄격한 SLO 안에 넣으려면 GPU를 과잉 프로비저닝하거나 어느 한쪽을 포기할 수밖에 없다는 것이었다.
Chunked Prefill: 단일 인스턴스 안에서 간섭 줄이기
Chunked Prefill의 아이디어는 단순하다. 긴 프롬프트를 한 스텝에 통째로 처리하지 않고, 작은 청크로 쪼개 Decode 스텝 사이사이에 끼워 넣는다. 2,048 토큰짜리 프롬프트라면 512 토큰씩 4개 청크로 나눠 4개 스텝에 분산하는 식이다. 각 스텝에서 Prefill 청크가 차지하는 비중이 줄어 Decode 요청이 덜 막힌다.
Sarathi-Serve(Agrawal et al., OSDI 2024)가 이 방식을 "stall-free batching"으로 체계화했다. Mistral-7B 단일 A100에서 vLLM 대비 2.6x, Yi-34B 2× A100에서 3.7x의 처리량 향상을 보였다.
vLLM에서는 --enable-chunked-prefill 플래그로 활성화하고, --max-num-batched-tokens로 청크 크기를 제어한다. V1 엔진부터는 기본값으로 켜져 있다.
vllm serve meta-llama/Llama-3-8B \
--enable-chunked-prefill \
--max-num-batched-tokens 2048
--max-num-batched-tokens 값에 따른 트레이드오프:
| 값 | 주 효과 | 적합한 상황 |
|---|---|---|
| 512 ~ 2048 | TPOT p99 안정 | Decode 위주, 스트리밍 응답 품질이 우선 |
| 4096 ~ 8192 | TTFT 개선 | 짧은 응답 생성, 대화형 서비스 |
| 8192+ | Prefill throughput 최적 | 대형 GPU + 소형 모델 조합 |
청크 크기가 작을수록 TPOT는 안정되지만 Prefill throughput이 줄어든다. Prefill을 여러 스텝에 걸쳐 처리하면 그 사이에 KV 캐시를 메모리에 계속 들고 있어야 하고 스텝 간 오버헤드도 생긴다. 최적값은 모델 크기와 GPU 메모리 대역폭에 따라 달라지기 때문에 7B와 70B에서 같은 설정을 쓸 수 없다.
Chunked Prefill은 Prefill과 Decode가 여전히 같은 GPU를 공유한다. 간섭을 줄이는 것이지 없애는 게 아니다. 프롬프트가 극단적으로 길거나(8K 토큰 이상), TTFT·TPOT SLO가 모두 수십 ms 이내로 엄격한 경우에는 이 절충이 부족하다.
Disaggregated Prefill-Decode: 아예 다른 인스턴스로
Disaggregated Serving은 Prefill 전용 GPU 풀과 Decode 전용 GPU 풀을 분리하고, Prefill이 끝난 KV 캐시를 네트워크로 Decode 인스턴스에 전송하는 방식이다. 각 풀을 자신의 병목에 맞게 독립적으로 최적화할 수 있다. Prefill 풀은 compute 활용률을 높이도록, Decode 풀은 메모리 대역폭을 최대한 쓰도록 설정한다.
DistServe는 이 아키텍처로 기존 시스템 대비 7.4x 더 많은 요청을 처리하거나 동일 처리량에서 12.6x 더 엄격한 SLO를 달성했다. Kimi 팀의 Mooncake(arxiv 2407.00079)는 프로덕션 환경에서 동일 GPU 수로 75% 더 많은 요청을 처리했고, 시뮬레이션 시나리오에서는 최대 525%의 처리량 향상을 보였다.
현재 주요 구현체:
| 구현체 | KV 전송 방식 | 상태 |
|---|---|---|
| DistServe | InfiniBand RDMA | 연구 프로토타입 |
| Mooncake | NVLink/IB + 커스텀 Transfer Engine | Kimi 프로덕션 적용 |
| vLLM (experimental) | Mooncake Transfer Engine 통합 | v0.7+ 실험적 지원 |
이 아키텍처의 전제 조건은 KV 캐시 전송 시간이 Prefill 처리 시간보다 짧아야 한다는 것이다. Llama-3 8B 기준 2,048 토큰의 KV 캐시는 약 270 MB, 70B 기준으로는 약 670 MB다. InfiniBand HDR(이론 대역폭 200 Gb/s)로 이 데이터를 전송하면 수십 ms가 걸린다. 짧은 프롬프트 워크로드에서는 Prefill 자체가 수 ms 안에 끝나기 때문에 전송 비용이 지배적이 된다. 소형 모델(7B 이하)이나 프롬프트 길이가 주로 512 토큰 이하인 트래픽에서 Disaggregated Serving은 득보다 실이 많다.
언제 무엇을 고르는가
| 요청 패턴 | 추천 전략 | 핵심 이유 |
|---|---|---|
| 짧은 프롬프트(< 512t) + 짧은 생성 | Continuous Batching | Prefill 간섭 미미, 추가 복잡도 불필요 |
| 짧은 프롬프트 + 긴 생성(> 512t) | Chunked Prefill | Decode 비중 높아 TPOT 안정이 우선 |
| 긴 프롬프트(> 2Kt) + 짧은 생성 | Disaggregated (Prefill 풀 확대) | Prefill 집중적, TTFT가 핵심 지표 |
| 긴 프롬프트 + 긴 생성 | Disaggregated (균등 분할) | 양쪽 모두 무거워 완전 분리가 최적 |
| 혼재 트래픽 | Chunked Prefill → 필요시 Disaggregated | 단계적 확대가 운영 비용 면에서 현실적 |
Disaggregated Serving은 운영 복잡도가 크다. GPU 풀 이원화, KV 캐시 전송 인프라, 풀 간 라우팅 로직이 추가된다. TTFT와 TPOT를 동시에 수십 ms SLO 안에 넣어야 하고, 프롬프트가 충분히 길어서 KV 캐시 전송 비용을 정당화할 수 있을 때만 선택할 이유가 생긴다. 그 조건이 아니라면 Chunked Prefill이 복잡도 대비 효과가 훨씬 좋다.
병목 위치부터 확인한다
어느 전략을 택하든 측정이 먼저다. TTFT와 TPOT를 p50/p99로 수집하면 문제가 어디 있는지 대부분 드러난다.
TTFT p99만 높고 TPOT는 정상이면 Prefill이 큐에서 밀리고 있는 것이다. TPOT p99가 간헐적으로 튀지만 TTFT는 괜찮다면 Decode 중에 Prefill이 끼어드는 전형적인 간섭 패턴이다. 이 패턴이라면 --enable-chunked-prefill과 적절한 --max-num-batched-tokens만으로 TPOT p99가 크게 개선된다.
torch.profiler나 NVIDIA Nsight Systems로 GPU 타임라인을 보면 prefill 스텝과 decode 스텝의 SM 활용률 차이가 바로 보인다. Prefill 스텝에서 SM 활용률 90% 이상, decode 스텝에서 20% 이하가 반복된다면 Decode 풀이 memory-bound로 놀고 있는 것이다. 이때 Decode 인스턴스의 GPU를 메모리 대역폭이 더 큰 모델로 교체하면 — 예를 들어 A100(2,039 GB/s)에서 H100(3,350 GB/s)으로 — Prefill 쪽 GPU를 교체하는 것보다 TPOT를 훨씬 직접적으로 개선한다.