Chunked Prefill: 왜 하나의 긴 프롬프트가 LLM 서버를 멈추게 하는가
요약
긴 프롬프트 처리 시 발생하는 prefill-decode 간섭 현상과 이를 해결하기 위한 chunked prefill 기술을 설명합니다. 프롬프트를 청크 단위로 나누어 디코딩 작업과 교차 실행함으로써 지연 시간 스파이크를 방지하는 메커니즘을 다룹니다.
핵심 포인트
- Prefill은 연산 중심, Decode는 메모리 중심 작업임
- Chunked prefill은 프롬프트를 나누어 Decode와 교차 실행함
- 청크 크기에 따라 TTFT와 ITL 사이의 트레이드오프 발생
- vLLM의 max_num_batched_tokens 설정을 통해 조절 가능
- 완전한 격리를 위해서는 disaggregated prefill 방식 권장
당신은 LLM 서비스를 배포했습니다. p50 지연 시간(latency)은 매우 훌륭해 보입니다. 그러다 한 사용자가 40페이지 분량의 계약서를 채팅창에 붙여넣자, 다음 400밀리초 동안 다른 모든 사용자의 토큰 전달이 중단됩니다. 그들의 스트림은 멈췄다가, 이후 한꺼번에 몰려서 전달됩니다. 대시보드에는 명확한 원인 없이 토큰 간 지연 시간(inter-token latency) 스파이크가 나타납니다. 아무것도 충돌하지 않았고, 속도 제한(rate-limit)에 걸린 것도 아닙니다. 단 하나의 긴 프롬프트가 이 일을 저질렀습니다.
이것은 prefill-decode 간섭(prefill-decode interference)이며, 그 해결책인 chunked prefill은 LLM 서빙 스택에서 거의 아무도 의도적으로 조정하지 않지만 가장 영향력이 큰 조절 장치 중 하나입니다. 여기 그 메커니즘과 설정 방법이 있습니다.
요약 (TL;DR)
- Prefill은 연산 중심(compute-bound)이며 하나의 거대한 순전파(forward pass)로 실행됩니다. 반면 decode는 메모리 중심(memory-bound)이며 한 번에 하나의 토큰씩 실행됩니다. 단순한 스케줄러는 긴 prefill을 단일 배치 단계로 실행하며, 이 작업이 완료될 때까지 진행 중인 모든 decode 요청이 중단됩니다.
- Chunked prefill은 프롬프트를 고정된 크기의 청크(chunk)로 나누고, 이를 동일한 순전파 단계에서 decode 토큰과 교차 실행(interleave)하여, 단계별 시간을 제한함으로써 decode 지연 시간이 일정하게 유지되도록 합니다.
- 트레이드오프는 TTFT(Time-to-First-Token) 대 ITL(Inter-Token Latency)입니다. 청크 크기가 작을수록 토큰 간 지연 시간은 부드러워지지만, 긴 프롬프트의 첫 토큰 생성 시간(TTFT)이 늘어나고 전체 처리량(throughput)이 감소합니다. 청크 크기가 크면 그 반대 현상이 일어납니다.
- vLLM에서 조절 장치는
max_num_batched_tokens입니다. 지연 시간에 민감한 채팅의 경우 낮게(~2048), 처리량/배치 작업의 경우 높게(8192+) 설정합니다. - 만약 두 과정을 완전히 격리해야 한다면, 교차 실행 대신 disaggregated prefill(분리된 prefill)을 사용하세요. 즉, prefill과 decode를 위한 별도의 GPU 풀을 사용하는 방식입니다.
왜 하나의 긴 프롬프트가 서버 전체를 멈추게 하는가?
Prefill과 decode는 동일한 GPU를 차지하기 위해 싸우는 두 가지 서로 다른 종류의 작업이며, 기본적으로는 더 긴 작업이 전체 타임스텝(timestep)을 차지해 버리기 때문입니다.
Prefill은 프롬프트의 모든 토큰을 병렬로 처리합니다. 32K 토큰 프롬프트의 경우, 모든 레이어의 어텐션(attention)과 MLP를 통과하는 32K개의 쿼리 위치(query positions) 배치와 같습니다. 이는 밀집 행렬 곱셈(dense matmuls), 높은 연산 강도(arithmetic intensity)를 가지며 연산 중심(compute-bound) 작업입니다. 이는 작업이 완료될 때까지 GPU의 FLOPs를 포화시킵니다.
디코딩 (Decode)은 그 반대입니다. 실행 중인 각 생성 프로세스는 단계(step)당 하나의 토큰을 생성합니다. 해당 단계에서는 전체 모델 가중치(model weights)와 요청의 KV 캐시 (KV cache)를 로드하여 단 하나의 새로운 위치를 계산합니다. 이동되는 바이트당 산술 연산이 거의 없으므로, 이는 메모리 대역폭 제한 (memory-bandwidth-bound) 작업입니다. 디코딩 단계는 개별적으로 비용이 저렴하며, 사용자의 스트리밍 경험 자체가 바로 그 리듬(cadence)이기 때문에 일정한 리듬으로 자주 발생해야 합니다.
단순한 연속 배치 (continuous-batching) 스케줄러는 순전파 (forward pass)를 하나의 원자적 단계 (atomic step)로 취급합니다. 거대한 프리필 (prefill)이 도착하면, 스케줄러는 해당 프리필을 별도의 단계로 예약하거나(또는 몇 개의 디코딩과 묶어서) 처리합니다. 긴 프롬프트의 경우 이 단일 단계가 300500ms가 소요될 수 있습니다. 이 300500ms 동안에는 어떤 디코딩 단계도 실행되지 않으므로, 모든 스트리밍 사용자는 토큰 생성이 멈추는 것을 보게 됩니다. 프리필이 완료되면 디코딩이 재개되고, 대기 중이던 토큰들이 한꺼번에 쏟아져 나옵니다. 이것이 바로 토큰 간 지연 시간 (ITL, inter-token latency) 스파이크입니다. 하나의 요청이 모든 요청의 품질을 저하시키는 것 — 이는 GPU 스케줄링 계층에서 발생하는 전형적인 헤드 오브 라인 블로킹 (head-of-line blocking) 문제입니다.
Chunked prefill이란 무엇인가?
청크드 프리필 (Chunked prefill)은 프롬프트의 프리필을 고정된 크기의 청크 (chunks)로 나누고, 단일 순전파 (forward pass) 내에서 진행 중인 디코딩 토큰들과 이 청크들을 교차 배치합니다. 이를 통해 어떤 단계도 하나의 긴 프롬프트에 의해 지배되지 않도록 합니다.
"32K 토큰 전체를 한 단계에서 프리필한다" 대신, 스케줄러는 단계당 토큰 예산 (token budget)을 정의합니다 (예: 2048 토큰). 각 단계에서 스케줄러는 먼저 실행 중인 모든 요청의 디코딩 토큰(각각 하나의 쿼리 위치)을 예약한 다음, 남은 예산을 대기 중인 프리필의 한 청크로 채웁니다. 32K 프롬프트는 약 16개의 단계에 걸쳐 분산된 약 16개의 청크가 되며, 각 단계는 다른 모든 사용자의 디코딩 토큰도 함께 처리합니다.
결과적으로: 단계 소요 시간은 가장 큰 프롬프트가 아니라 토큰 예산에 의해 제한됩니다. 이제 디코딩 토큰은 매 단계마다 함께 처리되므로, 그 리듬이 거의 일정하게 유지됩니다. ITL 스파이크는 작고 일정한 비용(tax) 수준으로 평탄화됩니다.
이로 인한 커널 비용(kernel cost)은 실재합니다. 이제 각 단계는 _혼합 배치 (mixed batch)_로 실행됩니다. 즉, 일부 위치는 프리필 (prefill, 자신의 청크와 이전에 캐싱된 모든 KV를 참조)이고, 일부는 단일 쿼리 디코딩 (single-query decode) 위치입니다. 이것이 현대의 서빙 스택이 프리필과 디코딩 위치를 한 번의 런치 (launch)로 처리하는 가변 길이 FlashAttention 커널에 의존하는 이유입니다. 혼합 배치 어텐션 (mixed-batch attention) 지원 없이는 청크형 프리필 (chunked prefill)이 불가능합니다.
vLLM에서 청크형 프리필을 어떻게 설정하나요?
vLLM에서 청크형 프리필은 플래그 (flag)와 토큰 예산 (token budget)에 의해 제어됩니다. 최신 버전에서는 많은 모델에 대해 기본적으로 활성화되어 있지만, 명시적으로 설정하는 것이 좋습니다.
from vllm import LLM
llm = LLM(
...
또는 서버에서:
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--enable-chunked-prefill \
--max-num-batched-tokens 2048 \
...
각 단계의 스케줄링 로직은 대략 다음과 같습니다:
budget = max_num_batched_tokens
batch = []
...
디코딩 우선순위 (Decode-priority)가 핵심 세부 사항입니다. 프리필 청크보다 디코딩을 먼저 스케줄링함으로써, vLLM은 실행 중인 생성 작업이 매 단계 최소 한 개의 토큰은 항상 전진하도록 보장하며, 이것이 실제로 ITL을 보호하는 역할을 합니다. 프리필은 남은 예산을 흡수할 뿐입니다.
TTFT와 ITL 사이의 트레이드오프(trade-off)는 무엇인가요?
청크형 프리필은 모두에게 발생하는 레이턴시 스파이크 (latency spike)를 _긴 프롬프트 (long prompt)_에 대한 약간 더 느린 첫 번째 토큰 레이턴시 (first-token latency)로 변환합니다. 이것이 전체적인 트레이드오프이며, max_num_batched_tokens가 그 조절 다이얼입니다.
예산을 낮게 설정할 경우 (예: 2048):
- 각 단계가 짧으므로 디코딩 ITL이 매끄럽고 지터 (jitter)가 낮습니다. 대화형 채팅에 적합합니다.
- 긴 프롬프트의 프리필이 더 많은 단계에 걸쳐 분산되므로, 첫 번째 토큰 도달 시간 (TTFT)이 증가합니다.
- 전체 처리량 (throughput)이 감소합니다. 청크가 작을수록 단계당 산술 강도 (arithmetic intensity)가 낮아지고 가중치 로드 (weight loads)가 더 자주 반복되므로, GPU FLOPs를 충분히 활용하지 못하게 됩니다.
예산을 높게 설정할 경우 (예: 8192 또는 16384):
- Prefill(프리필)이 더 적고 큰 단계(steps)로 완료됩니다. 이는 더 나은 GPU 활용도(utilization), 더 높은 초당 토큰 수(tokens/sec) 처리량(throughput), 그리고 긴 프롬프트에 대한 더 낮은 TTFT(Time To First Token)를 의미합니다.
- 하지만 각 단계가 더 길어지므로, 해당 단계를 공유하는 디코드(decode) 토큰들은 더 많은 지터(jitter)를 경험하게 됩니다. 프리징(freeze) 현상이 중간에 발생하게 됩니다.
세상에 공짜 점심은 없습니다. 당신은 어떤 SLO(Service Level Objective)를 보호할지 선택하는 것입니다. 만약 당신의 제품이 스트리밍 채팅 어시스턴트라면, ITL(Inter-Token Latency)의 부드러움이 곧 사용자가 느끼는 경험이므로 작은 값 쪽으로 편향(bias)을 두어야 합니다. 만약 토큰이 스트리밍되는 것을 아무도 지켜보지 않는 오프라인 배치 요약(offline batch summarization)을 실행한다면, 큰 값 쪽으로 편향을 두어 처리량을 확보하십시오.
한 가지 눈에 띄지 않는 효과는 다음과 같습니다: Chunked prefill이 활성화되면 디코드와 프리필이 단계를 공유하므로, 순수 디코드 전용 배치에 비해 순수 디코드 처리량이 약간 감소할 수 있습니다. 당신은 꼬리 지연(tail spikes)을 제거하기 위해 일정한 상태 유지 비용(steady-state tax)을 지불하는 것입니다. 지연 시간 SLO(latency-SLO)에 묶여 있는 서비스라면 이 거래는 거의 항상 옳습니다.
max_num_batched_tokens를 어떻게 튜닝하나요?
처리량(throughput) 수치가 아니라, 당신의 지연 시간 목표(latency target)에서 시작하십시오. 최악의 경우 단일 단계 시간(single-step time)이 ITL SLO를 넘지 않도록 유지하는 예산을 선택한 다음, 지터가 다시 나타날 때까지만 그 값을 높이십시오.
실질적인 절차는 다음과 같습니다:
- 단일 포워드 단계 시간(single forward-step time) 측정: 사용 중인 모델과 GPU에 대해 후보 예산(2048, 4096, 8192)별로 측정합니다. 단계 시간은 연산 능력이 포화될 때까지 토큰 수에 비례하여 대략적으로 증가합니다.
- ITL SLO 설정: 예) "p99 기준 토큰 간 대기 시간이 50ms를 초과하지 않음". 최악의 경우 디코드 토큰이 한 단계를 통째로 기다려야 하므로, 단계 시간은 반드시 이 범위 안에 들어와야 합니다.
- 단계 시간이 여전히 SLO에 부합하는 가장 큰 예산 선택: 이렇게 하면 지연 시간 약속을 어기지 않으면서 처리량을 최대화할 수 있습니다.
- 실제 긴 프롬프트가 섞인 부하 상황에서 p99 ITL 관찰: 단순히 합성된 짧은 프롬프트가 아니라 실제 긴 프롬프트를 섞어서 테스트하십시오. 스파이크(spike)는 진정으로 긴 프리필이 활성 디코드와 충돌할 때만 나타납니다.
max_num_seqs로 동시성(concurrency) 제한: 디코드 토큰만으로 예산을 초과하지 않도록 합니다. 만약 256개의 시퀀스가 실행 중이라면, 어떤 프리필 청크가 들어가기 전에도 단계당 256개의 디코드 토큰이 존재하게 됩니다.
경험 법칙(Rule of thumb): 대화형 채팅(interactive chat)은 2048–4096, 혼합 트래픽(mixed traffic)은 4096–8192, 처리량 우선 오프라인(throughput-first offline) 작업은 8192 이상으로 설정하고 ITL(Inter-Token Latency)은 신경 쓰지 마세요.
대신 언제 분리된 프리필(disaggregated prefill)을 사용해야 할까요?
높은 부하 상황에서 TTFT(Time To First Token)와 ITL을 둘 다 보호해야 하는데, 인터리빙(interleaving) 방식이 둘 중 하나를 타협하게 만든다면, 프리필(prefill)과 디코드(decode)를 별도의 GPU 풀로 분리하는 방식인 분리된 프리필(disaggregated prefill)을 사용하십시오.
청크형 프리필(Chunked prefill)은 두 단계 사이에서 하나의 GPU를 공유하므로 여전히 자원 경합이 발생합니다. 반면 분리(Disaggregation) 방식은 한 세트의 GPU에서는 프리필을 실행하고 다른 세트의 GPU에서는 디코드를 실행하며, 계산된 KV 캐시(KV cache)를 인터커넥트(interconnect)를 통해 프리필 노드에서 디코드 노드로 스트리밍합니다. 프리필 GPU는 연산 제한적(compute-bound)으로 동작하며 포화 상태를 유지하고, 디코드 GPU는 프리필의 간섭 없이 일정한 속도로 메모리 제한적(memory-bound)으로 동작합니다. 각 단계는 자신의 병목 지점에 맞춰 하드웨어가 최적화됩니다.
그 대가는 상당한 복잡성입니다. 이제 네트워크를 통해 KV 캐시를 이동시켜야 하며(대역폭 및 지연 시간에 민감함), 트래픽에 맞춰 비율을 조절해야 하는 두 개의 풀을 프로비저닝해야 하고, TTFT에 전송 홉(transfer hop)이 추가됩니다. 이는 규모가 커질 때, 즉 엄격하고 별개인 TTFT 및 ITL SLO(Service Level Objectives)를 가진 대규모 배포 환경에서는 효과가 있지만, 단일 노드 서비스에는 과합니다. 대부분의 팀에게는 잘 선택된 max_num_batched_tokens를 사용하는 청크형 프리필(chunked prefill)이 운영 비용 부담 없이 이점의 대부분을 가져다줍니다. 분리(disaggregation) 방식은 단일 GPU가 SLO 내에서 두 단계를 진정으로 모두 처리할 수 없을 때만 고려하십시오.
그렇다면 왜 하나의 긴 프롬프트가 LLM 서버를 멈추게 할까요?
왜냐하면 prefill (프리필)과 decode (디코딩)가 동일한 GPU를 두고 경쟁하기 때문입니다. 단순한 스케줄러(naive scheduler)는 긴 prefill을 하나의 원자적 순전파(atomic forward) 단계로 실행하며, 이 단계가 완료될 때까지 진행 중인 모든 decode를 차단합니다. 이는 배치 레이어(batching layer)에서의 head-of-line blocking (선두 차단) 현상을 초래합니다. Chunked prefill은 프롬프트를 고정된 크기의 청크(chunk)로 나누고, 각 단계에서 이를 decode 토큰과 교차(interleaving)시킴으로써 이 문제를 해결합니다. 이를 통해 단계별 시간을 제한하여 스트리밍이 매끄럽게 유지되도록 합니다. 이 설정은 max_num_batched_tokens를 통해 조정할 수 있습니다. 작은 값(~2048)은 TTFT (Time To First Token)와 처리량(throughput)의 일부 희생을 감수하더라도 대화형 채팅을 위한 토큰 간 지연 시간(inter-token latency)을 부드럽게 만듭니다. 반면 큰 값(8192+)은 배치 작업의 처리량을 극대화합니다. 청킹(chunking)을 사용하더라도 하나의 GPU가 TTFT와 ITL (Inter-Token Latency) SLO (Service Level Objective)를 모두 충족할 수 없는 경우에는, prefill과 decode를 별도의 풀(pool)로 분리(disaggregate)하십시오. 먼저 지연 시간 목표에 따라 예산을 설정한 다음, 지터(jitter)가 다시 나타날 때까지 그 값을 높이십시오. 그 경계가 바로 당신의 해답입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기