LLM 성능 최적화: Prefill과 Decode의 차이점
LLM의 지연 시간은 Prefill과 Decode 단계로 나뉩니다. Prefill은 연산 집약적이며 첫 토큰 생성 시간(TTFT)을 결정하고, Decode는 메모리 대역폭에 따라 초당 토큰 생성 수(TPS)가 결정됩니다. 각 단계를 분리하여 측정하고 최적화하는 방법을 확인하십시오.
Prefill과 decode의 비교
Prefill과 decode의 구분은 자체 호스팅 LLM(large language model)의 지연 시간 문제 대부분을 설명하는 핵심 요소입니다. Prefill은 전체 프롬프트를 한 번에 읽어 들이며 연산 성능의 제한을 받습니다. 반면 decode는 답변을 토큰 단위로 하나씩 생성하며 메모리 대역폭의 제한을 받습니다. 첫 토큰이 생성되기까지 걸리는 시간(Time to first token)은 prefill 단계의 지표이며, 초당 토큰 생성 수(Tokens per second)는 decode 단계의 지표입니다. 두 단계 모두 동일한 GPU(graphics processing unit)에서 같은 가중치를 사용하여 동일한 프로세스 내에서 실행되므로, 이를 하나의 작업으로 간주하기 쉽습니다. 그러나 이들은 하나의 장치를 공유하는 서로 다른 두 프로그램처럼 동작합니다. 이 둘을 분리해서 생각하면 혼란스러웠던 수많은 결과값이 명확하게 이해됩니다.
왜 프리필(prefill)은 연산 집약적인가?
프리필은 전체 프롬프트를 모든 레이어에 한 번씩 통과시킵니다. 2,000 토큰의 프롬프트는 모든 행렬 곱셈에 2,000개의 행을 처리하게 하므로, GPU는 로드하는 가중치 바이트당 많은 산술 연산을 수행합니다. 이동하는 바이트당 산술 연산의 비율을 산술 강도(arithmetic intensity)라고 하며, 프리필은 이 값이 높습니다. 장치는 연산 한계치에 가깝게 작동하며 메모리 버스에는 여유가 생깁니다.
프리필은 두 가지를 생성합니다. 모든 프롬프트 토큰에 대한 KV 캐시(키 및 값 텐서)와 첫 번째 출력 토큰입니다. 이 과정이 끝나기 전까지는 아무것도 사용자에게 전달되지 않으며, 이것이 프리필 시간과 첫 토큰 생성 시간(TTFT)이 거의 동일한 측정값인 이유입니다.
프리필 비용은 프롬프트 길이에 따라 증가합니다. 선형적인 부분은 레이어별 행렬 연산입니다. 이차적인 부분은 어텐션(attention)으로, 각 토큰이 이전의 모든 토큰을 참조하며 긴 컨텍스트에서 중요해지기 시작합니다. 따라서 프롬프트가 두 배가 되면 TTFT는 최소 두 배가 됩니다.
이 현상은 1분 만에 확인할 수 있습니다. 서버에 200 토큰 프롬프트를 보낸 뒤, 2,000 토큰 프롬프트를 보내 각각 동일한 수의 출력 토큰을 요청해 보십시오. TTFT는 급격히 상승합니다. 반면 첫 토큰 이후의 스트리밍 속도는 거의 변하지 않습니다.
디코드(decode)가 메모리 대역폭에 제한되는 이유는 무엇입니까?
디코드는 단계마다 하나의 토큰을 생성합니다. 이 단일 토큰을 생성하기 위해 GPU는 모델의 모든 가중치를 메모리에서 읽어와야 하며, 각 가중치를 몇 번의 연산에 사용한 뒤 폐기합니다. 산술 강도(arithmetic intensity)가 1에 가깝기 때문에 연산 장치는 대부분의 시간을 대기하며 보냅니다.
디코드는 각 토큰마다 모델 전체를 메모리에서 읽어야 하므로 속도가 느립니다. 따라서 메모리 버스가 속도를 결정하며 연산 장치는 유휴 상태가 됩니다.
이로 인해 단일 스트림 디코드 속도의 상한선은 종이 위에서 계산 가능한 산술적 수치가 됩니다. 메모리 대역폭을 가중치가 차지하는 바이트 수로 나누면 됩니다.
The data behind this chart
[
{
"device": "CPU, dual channel DDR5-5600",
"mem_bandwidth_gb_s": 90,
"decode_ceiling_tok_s": 6
},
{
"device": "NVIDIA A10G",
"mem_bandwidth_gb_s": 600,
"decode_ceiling_tok_s": 38
},
{
"device": "NVIDIA L40S",
"mem_bandwidth_gb_s": 864,
"decode_ceiling_tok_s": 54
},
{
"device": "NVIDIA RTX 4090",
"mem_bandwidth_gb_s": 1008,
"decode_ceiling_tok_s": 63
},
{
"device": "NVIDIA A100 80GB SXM",
"mem_bandwidth_gb_s": 2039,
"decode_ceiling_tok_s": 127
},
{
"device": "NVIDIA H100 SXM",
"mem_bandwidth_gb_s": 3350,
"decode_ceiling_tok_s": 209
}
]대역폭 열에는 각 벤더가 공개한 사양 수치가 기재되어 있습니다. 상한선 열은 해당 수치를 16비트 정밀도로 저장된 80억 개의 파라미터 모델 크기인 16 GB로 나눈 값입니다. 이는 벤치마크 결과가 아닌 산술적 계산값입니다. 실제 측정 속도는 이보다 낮게 나타나며, 그 차이를 파악하는 것은 서빙 스택을 수정해야 할지 하드웨어를 교체해야 할지 판단하는 데 유용합니다.
6개의 행을 순서대로 읽어보면 패턴이 명확해집니다. 듀얼 채널 DDR5를 사용하는 CPU는 약 90 GB/s의 대역폭을 가지며, 해당 모델의 디코드 속도는 초당 약 6 토큰으로 제한됩니다. L40S는 초당 약 54 토큰 수준에 도달합니다. 공개된 대역폭이 3350 GB/s인 H100 SXM은 초당 약 209 토큰 근처에 위치합니다.
이것이 바로 양자화(quantization)가 디코드 속도를 높이는 가장 강력한 단일 수단인 이유입니다. 동일한 모델을 16비트 대신 8비트로 저장하면 토큰당 읽어야 하는 바이트 수가 절반으로 줄어들어 상한선이 대략 두 배로 늘어납니다. 연산량을 추가하지 않고도 메모리 이동량을 줄인 결과입니다.
자신의 서버에서 각 단계를 측정하려면 어떻게 해야 합니까?
Ollama는 응답 본문에 분할 정보를 포함하여 반환합니다. 스트리밍을 사용하지 않는 완료를 요청한 뒤 카운터를 확인하십시오.
curl -s http://localhost:11434/api/generate -d '{
"model": "llama3.2",
"prompt": "Explain memory bandwidth in two sentences.",
"stream": false
}' | jq '{prompt_eval_count, prompt_eval_duration, eval_count, eval_duration}'실제로 내려받은 모델 태그를 사용하십시오. ollama list 명령으로 확인할 수 있습니다. prompt_eval_count와 prompt_eval_duration는 프리필(prefill) 정보로, 프롬프트 토큰 수와 해당 단계에 소요된 시간입니다. eval_count과 eval_duration은 디코드(decode) 정보입니다. 지속 시간은 나노초 단위이므로, 디코드 속도는 eval_count / eval_duration * 1e9이고 프리필 속도는 prompt_eval_count / prompt_eval_duration * 1e9입니다. 동일한 요청에서 프리필 속도가 디코드 속도보다 훨씬 높게 나타나는 것이 정상입니다. 이 차이가 바로 이 문서에서 설명하는 모든 현상의 원인입니다.
vLLM과 같이 OpenAI 호환 서버를 사용하는 경우, curl을 사용하여 첫 번째 바이트가 도착하는 시간을 측정할 수 있습니다.
curl -N -s -o /dev/null \
-w 'pretransfer %{time_pretransfer}s first_byte %{time_starttransfer}s\n' \
http://localhost:8000/v1/completions \
-H 'Content-Type: application/json' \
-d '{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "Explain memory bandwidth.", "max_tokens": 128, "stream": true}'time_starttransfer은 본문의 첫 번째 바이트가 도착한 시점이므로, "stream": true을 사용하면 TTFT(Time To First Token)에 연결 설정 시간을 더한 값이 됩니다. time_pretransfer를 빼면 설정 비용을 제외할 수 있습니다. 첫 번째 호출에는 모델을 불러오는 콜드 로드 시간이 포함될 수 있으므로, 두 번 실행한 뒤 두 번째 결과를 사용하십시오.
vLLM은 /metrics을 통해 Prometheus 메트릭으로 분할 정보를 게시하기도 합니다. curl -s http://localhost:8000/metrics | grep -E 'time_to_first_token|inter_token_latency'를 실행하면 vllm:time_to_first_token_seconds와 vllm:inter_token_latency_seconds 히스토그램을 얻을 수 있습니다. 큐 깊이를 확인하려면 vllm:num_requests_running과 vllm:num_requests_waiting을, 캐시 압박을 확인하려면 vllm:kv_cache_usage_perc를 추가하십시오. 이 다섯 가지 항목이 대시보드의 전부입니다.
부하가 걸린 상태에서는 vllm bench serve --model <name> --num-prompts 200 --request-rate 4이 서버를 구동하며 첫 토큰까지의 시간과 출력 토큰당 지연 시간을 백분위수로 보고합니다. 이것이 두 단계가 서로 어떻게 영향을 주는지 확인할 수 있는 유일한 방법입니다. 설정을 조정하기 전에 깨끗한 기준값을 먼저 측정하십시오. 로컬 LLM의 초당 토큰 측정에 설명된 방법을 사용하면 재부팅 후에도 유지되는 기준값을 얻을 수 있습니다.
긴 시스템 프롬프트가 첫 번째 토큰의 지연 시간은 늘리지만 스트리밍 속도에는 영향을 주지 않는 이유는 무엇입니까?
시스템 프롬프트는 오직 프리필(prefill) 작업으로만 처리되기 때문입니다. 시스템 프롬프트는 첫 번째 토큰이 생성되기 전, 나머지 프롬프트와 함께 한 번의 패스로 처리됩니다. 해당 패스가 완료되면 시스템 프롬프트는 KV 캐시 항목으로만 존재하며, 이후 디코딩 과정에서 다른 데이터와 함께 읽힙니다. 따라서 3,000 토큰의 시스템 프롬프트는 모든 요청에서 TTFT(Time To First Token)를 증가시키지만, 초당 토큰 생성 속도(tokens per second)는 거의 변화시키지 않습니다.
'거의' 변화시키지 않는 것이지, 완전히 동일한 것은 아닙니다. 추가된 KV 항목은 모든 디코딩 단계마다 다시 읽히므로, 매우 긴 프롬프트는 디코딩 속도를 약간 저하시킵니다. 이에 대해서는 다음 섹션에서 다룹니다.
이 문제를 해결하려면 동일한 접두사(prefix)를 반복해서 계산하지 않아야 합니다. 접두사 캐싱(prefix caching)을 지원하는 서버는 공유 접두사의 KV 캐시를 유지하고 재사용하므로, 동일한 시스템 프롬프트를 포함한 두 번째 요청부터는 프리필 과정을 완전히 건너뜁니다. vLLM은 이를 자동 접두사 캐싱(automatic prefix caching)이라고 부릅니다. 기본 설정이 릴리스마다 변경되었으므로 사용 중인 버전의 vllm serve --help 설정을 확인하십시오. GPU 내부의 KV 캐시는 API 제공업체가 요금을 청구하는 프롬프트 캐시와는 다른 개념이며, KV 캐시와 프롬프트 캐시의 차이를 먼저 읽어본 뒤 설정을 조정하는 것이 좋습니다.
컨텍스트가 채워질수록 디코딩 속도가 느려지는 이유는 무엇입니까?
두 가지 이유가 있으며, 모두 KV 캐시와 관련이 있습니다.
첫 번째는 대역폭입니다. 모든 디코딩 단계에서 어텐션(attention)은 이전 모든 토큰의 키와 값을 읽습니다. 가중치는 토큰당 고정 비용이지만, KV 캐시는 계속 커지기 때문입니다. 모델의 config.json를 통해 크기를 계산할 수 있습니다. 토큰당 바이트 수는 2에 num_hidden_layers, num_key_value_heads, 헤드 차원(hidden_size를 num_attention_heads으로 나눈 값), 요소당 바이트 수를 곱한 값과 같습니다. 앞의 2는 키 하나와 값 하나를 의미합니다.
일반적인 80억 파라미터 레이아웃(32개 레이어, GQA(grouped query attention) 하의 8개 키 및 값 헤드, 헤드 차원 128, 16비트 정밀도)의 경우, 2 x 32 x 8 x 128 x 2 = 131,072 바이트, 즉 토큰당 약 128 KiB가 됩니다. 따라서 8,000 토큰의 대화는 요청당 약 1 GB의 KV 캐시를 차지합니다.
두 번째는 용량입니다. 해당 1 GB는 가중치나 다른 사용자의 컨텍스트를 저장할 수 없는 메모리입니다. 서버는 시작 시점에 --gpu-memory-utilization을 통해 vLLM에서 KV 풀의 크기를 한 번 결정하며, 풀이 가득 차면 새로운 요청은 대기하게 됩니다. vllm:num_requests_waiting은 상승하는데 vllm:kv_cache_usage_perc가 1 근처에 머무는 상태가 바로 이를 나타내는 징후입니다. 일부 스택은 대기열에 넣는 대신 실행 중인 요청을 선점(preempt)하고 나중에 캐시를 다시 계산하는데, 사용자는 스트리밍 도중 멈춤 현상으로 이를 경험하게 됩니다.
긴 컨텍스트는 두 가지 측면에서 비용을 발생시킵니다. 시작 시 더 많은 프리필(prefill) 작업이 필요하며, 답변이 생성되는 동안 토큰당 더 많은 메모리 읽기가 발생합니다.
배치 처리가 처리량(throughput)은 높이고 꼬리 지연 시간(tail latency)은 악화시키는 이유는 무엇입니까?
디코딩은 대역폭 제한적(bandwidth bound) 작업이므로, 연산 측면에서 추가 요청은 거의 비용이 들지 않기 때문입니다. 가중치를 한 번 읽으면 배치 내 모든 시퀀스에 대해 토큰을 생성할 수 있으므로, KV 풀이 고갈되거나 배치가 다시 연산 제한적(compute bound) 상태가 될 만큼 커지기 전까지는 배치 크기에 비례하여 전체 처리량이 거의 선형적으로 증가합니다. 연속 배치(continuous batching)는 매 단계마다 배치를 재구성하므로, 완료된 요청은 즉시 빠져나가고 대기 중인 요청은 주변 요청을 기다릴 필요 없이 바로 합류합니다.
문제는 백분위수(percentiles)에서 발생합니다. 각 사용자의 다음 토큰은 이제 공유된 단계 중 가장 느린 부분을 기다려야 하므로, 중앙값인 p50은 허용 가능한 수준을 유지하지만 100건 중 1건의 가장 느린 요청인 p99는 지연 시간이 늘어납니다. p99는 문장 중간에 발생하는 멈춤 현상으로 체감되기 때문에 사용자가 즉각적으로 인지하게 됩니다.
프리필(prefill)은 이 현상을 더 두드러지게 만듭니다. 스트리밍 도중에 대규모 프롬프트가 들어오면 장치가 한 번의 긴 단계 동안 점유되어, 현재 스트리밍 중인 모든 사용자가 지연을 겪게 됩니다. 청크 프리필(chunked prefill)은 긴 프롬프트를 여러 조각으로 나누고 각 조각을 디코딩 배치에 섞어 넣음으로써 이러한 지연의 대부분을 제거합니다. 2026년 8월 기준으로 vLLM V1 엔진은 이를 기본적으로 수행하며 --max-num-batched-tokens을 통해 균형을 조정할 수 있게 합니다. vLLM 튜닝 문서에서는 이 상충 관계를 명확히 설명합니다. 2048 정도의 작은 값은 프리필이 디코딩을 방해하는 횟수를 줄여 토큰 간 지연 시간(ITL)을 개선하고, 큰 값은 더 많은 프리필 토큰을 한 배치에 담을 수 있어 TTFT(Time To First Token)를 개선합니다. 이 단일 플래그는 프리필과 디코딩 사이의 균형을 숫자로 조절하는 도구입니다. p99가 허용 범위를 벗어나는 지점은 용량의 문제이며, 자체 호스팅 LLM이 동시에 처리할 수 있는 사용자 수에 대한 내용은 동일한 지표를 통해 분석할 수 있습니다.
더 큰 GPU를 사용해도 성능 변화가 없는 이유는 무엇입니까?
일반적으로 더 큰 GPU는 더 많은 연산 능력을 의미하지만, 디코딩 작업은 연산 능력을 요구하지 않기 때문입니다.
위 차트의 두 행을 비교해 보십시오. A100 80GB는 2039 GB/s의 공시 대역폭을 가지며, L40S는 864 GB/s를 가집니다. 디코딩 한계치 또한 정확히 이에 비례하여 각각 127 토큰/초와 54 토큰/초를 기록합니다. RTX 4090은 대부분의 지표에서 매우 빠른 카드이며, 1008 GB/s의 대역폭으로 63의 한계치를 보입니다. 두 카드 사이에 다른 사양 차이가 있더라도, 단일 스트림 디코딩 성능은 사양서의 대역폭 수치를 그대로 따라갑니다.
따라서 디코딩 속도를 높이는 방법은 두 가지뿐입니다. 토큰당 읽는 바이트 수를 줄이거나(가중치 양자화 또는 더 작은 모델 사용), 더 높은 대역폭을 확보하는 것입니다. 프리필(prefill)은 정반대의 경우입니다. 프리필은 연산 능력을 요구하므로, 더 빠른 카드를 사용하면 긴 프롬프트에 대한 TTFT(Time To First Token)를 확실히 단축할 수 있습니다. 첫 번째 토큰이 생성되는 데 4초가 걸리는 것이 문제라면 더 좋은 하드웨어가 해결책이 될 수 있습니다. 하지만 텍스트가 출력되는 속도가 느린 것이 문제라면, 하드웨어 교체로 해결될 가능성은 낮습니다.
Prefill과 decode를 별도의 워커에서 실행해야 합니까?
대규모 서빙 스택은 정확히 이 방식을 사용하며, 이 기술을 prefill과 decode 분리(disaggregation)라고 부릅니다. 한 워커 풀은 prefill만 실행하고, 두 번째 풀은 decode만 실행하며, 첫 번째 풀에서 생성된 KV cache는 빠른 인터커넥트를 통해 두 번째 풀로 전송됩니다. 이 방식이 효과적인 이유는 각 단계가 요구하는 하드웨어와 스케줄링 방식이 다르기 때문입니다. Prefill은 연산 능력과 대규모 토큰 배치를 필요로 합니다. 반면 decode는 대역폭과 다수의 동시 시퀀스를 필요로 합니다. 이들을 분리하면 각 풀을 독립적으로 확장할 수 있으며, 거대한 프롬프트 하나가 모든 활성 스트림을 지연시키는 현상을 방지할 수 있습니다.
GPU가 하나인 단일 VPS(virtual private server) 환경에서는 이 방식을 사용하는 것이 거의 의미가 없습니다. 단일 장치를 스스로 분할하는 꼴이 되며, 포인터 참조를 기가바이트 단위의 캐시 네트워크 전송으로 바꾸게 되기 때문입니다. 이 기술은 각 단계에 전체 장비를 할당할 수 있을 만큼 가속기가 충분하고, 두 풀을 모두 가동할 수 있을 만큼 트래픽이 꾸준할 때 효과를 발휘합니다. 그 이하의 규모라면 chunked prefill을 사용하는 것만으로도 하나의 플래그 설정만으로 거의 동일한 격리 효과를 얻을 수 있습니다.
수치가 좋지 않을 때 변경해야 할 사항
TTFT가 너무 높을 때:
- 프롬프트를 단축합니다. Prefill 비용은 프롬프트 토큰을 기준으로 계산되며, 시스템 프롬프트는 모든 요청마다 비용이 발생합니다.
- 반복되는 접두사가 매번 계산되지 않도록 prefix caching을 활성화합니다.
--max-num-batched-tokens값을 높여 각 단계에서 더 많은 prefill 작업이 처리되도록 합니다.- 모델을 탓하기 전에 대기열을 확인합니다.
vllm:num_requests_waiting값이 0보다 크다면 요청이 시작되지 않은 상태이며, 이는 용량 문제입니다.
초당 토큰 수(tokens per second)가 너무 낮을 때:
- 가중치를 양자화(quantize)합니다. 가중치당 바이트 수가 줄어들면 토큰당 읽는 바이트 수도 줄어듭니다.
- 사용 중인 카드의 공식 메모리 대역폭을 위 차트와 비교하여 한계치에 얼마나 근접했는지 확인합니다.
--max-num-batched-tokens값을 낮추어 prefill이 decode를 방해하는 빈도를 줄입니다.- 컨텍스트 길이를 확인합니다. 수천 개의 토큰으로 늘어난 대화는 매 단계마다 훨씬 더 큰 KV cache를 읽게 됩니다.
런타임 또한 중요한 요소입니다. Ollama와 vLLM은 prefill과 decode를 다르게 스케줄링하므로, 한쪽에서 효과적인 설정이 다른 쪽에서는 아무런 영향을 주지 않을 수 있습니다. 먼저 두 단계 모두에서 측정값을 확인한 뒤, 한 번에 한 가지만 변경하십시오.
FAQ
첫 번째 토큰은 몇 초가 걸리는데 나머지는 왜 빠르게 스트리밍되나요?
첫 번째 대기 시간은 프리필(prefill) 단계이며, 이후는 디코드(decode) 단계입니다. 프리필은 출력물이 나오기 전에 전체 프롬프트를 한 번의 연산 집약적 패스로 처리하므로, 프롬프트 길이에 비례하여 비용이 증가합니다. 디코드는 메모리 대역폭에 의해 결정되는 속도로 단계마다 토큰을 하나씩 생성하며, 이는 프롬프트 길이와 거의 무관합니다. 모든 요청에 긴 시스템 프롬프트를 포함하는 것이 일반적인 원인입니다. 프리픽스 캐싱(prefix caching)을 사용하면 반복되는 비용을 제거할 수 있습니다.
프롬프트가 길어지면 초당 토큰 처리 속도가 느려지나요?
TTFT(첫 토큰 생성 시간)와는 다른 이유로 약간 느려집니다. 모든 디코드 단계는 이전의 모든 토큰에 대한 키와 값을 읽어야 하므로, KV 캐시가 커질수록 토큰당 읽어야 할 바이트 수가 늘어납니다. 일반적인 80억 파라미터 모델 구조에서 캐시는 토큰당 약 128 KiB이므로, 8,000 토큰의 컨텍스트는 매 단계마다 약 1 GB의 데이터를 참조하게 됩니다. 긴 프롬프트가 미치는 영향은 여전히 스트리밍 속도보다는 TTFT에 더 큽니다.
어떤 GPU 사양이 디코드 속도를 결정하나요?
메모리 대역폭입니다. 공개된 대역폭을 메모리에 로드된 가중치 크기로 나누면 단일 스트림의 이론적 최대 속도가 나옵니다. 연산 성능은 더 높지만 대역폭이 동일한 카드는 스트리밍 속도가 더 빠르지 않습니다. 8비트 양자화를 적용하면 디코드 속도가 대략 2배 빨라지는 이유도 여기에 있습니다. 연산에는 영향을 주지 않으면서 토큰당 읽어야 할 바이트 수를 절반으로 줄이기 때문입니다.
사용자를 추가하면 처리량은 늘어나는데 왜 각 사용자는 더 느리게 느끼나요?
가중치를 한 번 읽는 것으로 배치 내 모든 시퀀스의 토큰을 생성할 수 있으므로, 배치 크기가 커질수록 초당 전체 토큰 처리량은 증가합니다. 하지만 이제 각 토큰은 공유된 단계를 기다려야 하므로, 사용자별 지연 시간은 동시에 상승합니다. 전체 처리량 수치보다는 p99 토큰 간 지연 시간을 모니터링하고, vllm:num_requests_waiting를 확인하여 요청이 실행되지 않고 대기 중인지 점검하십시오.