TTFT가 200ms인데 p99는 1.2초. p50 지연은 괜찮아 보이는데 p99에서 알람이 온다. 이 상황에서 서버 설정 문제인지 아니면 특정 요청 패턴 때문인지를 진단하려면, TTFT·TPOT·처리량 세 숫자가 각각 무엇을 재는지, 그리고 왜 서로 독립적으로 움직이는지를 알아야 한다.
TTFT(Time to First Token)는 요청이 도착한 순간부터 첫 토큰이 클라이언트에 전달될 때까지의 시간이다. TPOT(Time Per Output Token)는 이후 토큰 하나가 생성되는 평균 간격이다. 처리량(throughput)은 서버가 단위 시간당 처리하는 요청 수(req/s) 또는 토큰 수(tok/s)다. 이 셋이 항상 같은 방향으로 움직이지 않는 이유는, prefill 단계와 decode 단계가 GPU의 서로 다른 자원을 병목으로 삼기 때문이다.
TTFT는 prefill 연산량에, TPOT는 메모리 대역폭에 묶인다
LLM 추론은 두 단계로 나뉜다. prefill은 입력 프롬프트 전체를 처리해 KV cache를 만드는 단계이고, decode는 그 cache를 읽으며 출력 토큰을 하나씩 생성하는 단계다.
prefill은 compute-bound다. 입력 길이 N에 대해 self-attention의 FLOPs는 N²에 비례한다. 프롬프트 256토큰과 2,048토큰의 연산량 차이는 64배다. A100 80GB의 FP16 peak throughput은 312 TFLOPS이고, prefill은 이 연산 자원을 포화에 가깝게 태운다. TTFT가 높다는 건 거의 대부분 "prefill 연산이 오래 걸렸다"는 뜻이다.
decode는 다르다. 토큰 하나를 뽑을 때마다 모든 트랜스포머 레이어의 KV cache를 HBM에서 읽어야 한다. 토큰 한 개당 FLOPs는 극히 적지만, 읽어야 하는 메모리 양은 배치 안 전체 시퀀스의 KV cache 크기에 비례한다. A100의 HBM 대역폭 2TB/s가 이 단계의 실질 병목이고, TPOT는 이 대역폭이 얼마나 남아 있느냐에 따라 결정된다.
두 단계가 서로 다른 자원에 묶여 있다는 사실에서 구조적인 충돌이 나온다. TTFT를 줄이는 방법 중 하나는 chunked prefill — 프리필을 작은 청크로 나눠 decode와 교대로 스케줄링하는 방식이다. Agrawal et al. SARATHI(2023)는 이 방식으로 LLaMA-13B에서 decode throughput을 최대 10배 높였고, GPT-3 규모 파이프라인 병렬 환경에서는 stall(버블)을 6.29배 줄여 전체 처리량을 1.91배 개선했다. 그런데 chunked prefill은 프리필 하나를 여러 스텝에 나눠 처리하므로, 해당 요청의 TTFT는 오히려 늘어난다.
배치 크기가 커지면 TTFT는 왜 늘어나는가
Yu et al. Orca(OSDI 2022)가 도입한 iteration-level scheduling — continuous batching — 은 decode 중인 배치에 새 요청을 매 토큰 스텝마다 끼워 넣는다. Anyscale의 비교 측정에서 naive static batching 대비 최대 23배 throughput 개선이 나왔다.
throughput이 올라갈수록 새로 들어오는 요청의 TTFT는 압박을 받는다. 새 요청이 들어왔을 때 서버가 진행 중인 decode를 멈추고 prefill을 즉시 실행하면 TTFT는 낮게 유지되지만 기존 decode 요청들이 block된다. 반대로 decode를 계속 진행하며 prefill을 대기열에 넣으면 decode 연속성은 유지되지만 신규 요청의 TTFT가 대기 시간만큼 늘어난다. 스케줄러 정책이 이 사이에서 균형을 잡지만, GPU가 포화에 가까워질수록 둘 다 희생이 불가피하다.
배치 크기와 TTFT의 관계는 선형이 아니다. 아래는 Llama-3.1-8B on A100 80GB, ShareGPT 길이 분포 기준의 대표적인 패턴이다.
| 동시 요청 수 | p50 TTFT | p99 TTFT | Throughput |
|---|---|---|---|
| 1 | ~7 ms | ~18 ms | ~142 tok/s |
| 4 | ~8 ms | ~31 ms | ~459 tok/s |
| 8 | ~10 ms | ~48 ms | ~812 tok/s |
| 32+ | 급격히 증가 | 수백 ms 이상 | 포화 후 정체 |
p50 TTFT는 동시 요청 1→8에서 43% 상승하는 데 반해, p99 TTFT는 같은 구간에서 166% 뛴다. throughput은 600% 이상 올라 있다. 처리량을 최대화하는 방향으로 파라미터를 밀면 p99 지연이 비선형으로 악화된다.
p99가 p50과 따로 노는 이유: 요청 길이의 heavy-tail
ShareGPT 데이터셋의 평균 입력은 202토큰, 평균 출력은 179토큰이다. 실제 트래픽에서 이 분포는 heavy-tail을 띠고, 상위 1% 요청은 수천 토큰에 달한다. 이 긴 요청 하나가 배치에 섞이면 어떤 일이 일어나는지가 p99를 이해하는 핵심이다.
decode 단계에서 배치는 가장 긴 시퀀스가 끝날 때까지 함께 묶인다. 출력 2,048토큰 요청 하나가 배치에 들어오면, 128토큰짜리 요청들은 자기 생성을 다 마쳤어도 그 긴 요청의 decode가 끝나야 슬롯이 비고 새 요청이 들어올 수 있다. 이 구간 동안 신규 요청들은 대기열에서 prefill을 기다린다. 이것이 head-of-line blocking이다.
vLLM은 preemption으로 이를 처리한다. 긴 요청의 KV cache를 CPU 메모리로 swap하거나 재계산(recompute)해 슬롯을 확보하고, 대기 중인 요청의 prefill을 먼저 처리한다. 처리는 계속되지만 preemption된 요청에는 swap I/O 또는 재계산 비용이 붙고, 이 오버헤드가 전체 배치의 TPOT에 영향을 준다. TGI는 max_total_tokens 파라미터로 접근한다. 입력+출력 합산 길이가 임계값을 초과하는 요청은 큐 진입 자체를 막거나 에러로 돌려보낸다. p99는 낮게 유지되지만 긴 컨텍스트 요청에 대한 커버리지가 좁아진다.
균일 길이 벤치마크(512/512 고정)는 이 현상을 통째로 숨긴다. 배치 안 시퀀스 길이가 모두 같으면 blocking이 발생하지 않고, p99와 p50이 거의 같게 측정된다. 그 결과를 믿고 운영에 내보냈다가 실제 트래픽에서 p99 TTFT가 3~5배 튀는 상황이 나온다.
벤치마크가 현실과 어긋나는 세 지점
균일 길이 요청. 고정 길이 입출력은 tail latency를 과소평가한다. 분포가 없으면 tail이 없고, tail이 없으면 head-of-line blocking이 없다. 의미 있는 측정이라면 ShareGPT 또는 실제 서비스 로그에서 뽑은 길이 분포를 써야 한다.
낮은 동시성. 동시 요청 1~8개로는 GPU가 포화되지 않는다. 이 조건에서 나온 throughput은 낙관적이고, 스케줄러의 contention이 전혀 보이지 않는다. request rate를 단계적으로 올려가며 측정해야 saturation knee가 어디서 꺾이는지 파악할 수 있다.
워밍업 없는 cold 측정. 처음 수십 개 요청은 CUDA kernel compilation, KV cache 초기화 등이 함께 일어나 TTFT가 수 초 단위로 나온다. 이 구간을 포함한 평균 TTFT는 실제 운영 지연보다 수배 높다. 워밍업 요청을 충분히 흘리고 해당 구간을 측정에서 제외해야 한다.
재현 가능한 실측 설계
고정할 것과 변수로 둘 것을 먼저 구분한다.
고정: 입력 길이 분포(ShareGPT 또는 실제 로그), 모델·양자화 수준, GPU 수, 워밍업 제거 조건, 측정 구간.
sweep: request rate(0.5→1→2→4→8 req/s). 각 지점에서 p50·p95·p99를 분리 기록한다. throughput은 saturation knee 직전 지점에서 측정한다. knee 이후 구간의 throughput 수치는 높아 보여도 그 시점의 p99 TTFT는 이미 서비스 불가 수준이다.
# vLLM benchmark CLI (v0.6.0 이후)
vllm bench serve \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 500 \
--request-rate 2 \
--percentile-metrics ttft,tpot,itl \
--metric-percentiles 50,95,99
ray-project/llmperf도 ShareGPT 분포를 input dataset으로 지정하고 concurrent workers를 단계적으로 올리면 동일한 saturation 곡선을 그릴 수 있다.
p50·p95·p99를 분리 보고하지 않으면 tail이 숨는다. 평균(mean) TTFT 단 하나만 보는 건 길이 분포가 균일하다는 가정 아래서나 쓸 수 있는 지표다.
시나리오에 따라 어떤 지표를 1순위로 두느냐가 달라진다. 챗봇·인터랙티브 인터페이스라면 TTFT 200ms 이하가 실질적인 기준이다. 코드 자동완성처럼 실시간 스트리밍이 중요한 서비스는 TPOT 30~50ms 아래로 유지되느냐가 사용자 체감을 결정한다. 배치 처리 파이프라인은 TTFT 자체보다 saturation knee 직전 throughput이 핵심 지표다. 세 숫자 중 어디에 여유를 줄지를 정하지 않으면, max_num_batched_tokens를 높였더니 throughput 수치는 올라가면서 TTFT 알람이 동시에 터지는 상황이 반복된다.