max_num_seqs를 512로 올려도 aggregate throughput은 거의 늘지 않는데 TPOT p99는 계속 치솟는 구간이 있다. 컨텍스트 길이 2048 토큰, Llama-3-8B 기준으로 그 구간은 배치 크기 60부터 시작하며, 이 숫자는 GPU 메모리 대역폭·모델 파라미터 바이트·컨텍스트 길이 세 값으로 정확히 예측된다.
Decode의 Arithmetic Intensity가 배치 크기에 갇히는 이유
Prefill과 Decode의 결정적 차이는 Arithmetic Intensity(AI: FLOP/byte)에 있다.
Prefill은 시퀀스 길이 S가 길수록 AI가 S에 비례해서 오른다. attention FLOP이 O(S²·d)인데 메모리 읽기는 O(S·d)에 머물기 때문이다. 충분히 긴 시퀀스에서 Prefill이 compute-bound로 전환되는 이유가 이것이다.
Decode에서는 배치 내 B개 요청이 각자의 KV 캐시를 독립적으로 읽는다. 레이어 하나의 한 decode step을 분해하면:
- 가중치 행렬 연산: FLOP ≈ 24Bd², 메모리 ≈ 24d² bytes (가중치는 배치가 공유)
- KV 캐시 attention: FLOP ≈ 4BSD, 메모리 ≈ 4BSD bytes (요청별 독립 읽기)
(d: hidden dim, S: 평균 컨텍스트 길이, B: 배치 크기, BF16 가정)
전체 AI = (24Bd² + 4BSD) / (24d² + 4BSD)
B가 늘면 분자와 분모의 KV 캐시 항이 같은 비율로 커진다. AI는 두 극단 사이에 고정된다:
- 컨텍스트가 짧고 B가 작을 때: AI ≈ B (가중치 읽기 지배)
- 컨텍스트가 길 때: AI ≈ 1 (KV 캐시 읽기 지배, B와 무관)
A100의 roofline ridge point는 약 156 FLOP/byte다(312 TFLOPs ÷ 2 TB/s). 컨텍스트 512 토큰, 배치 32에서 Llama-3-405B의 실측 AI가 41.56이고 DeepSeekV3가 89.83이라는 값이 있는데, 두 경우 모두 ridge point 훨씬 아래다. Decode는 어떤 배치 크기에서도 완전한 compute-bound에 도달하지 않는다.
FlashAttention-2는 attention 커널의 메모리 접근 패턴을 분석하면서 Decode 단계의 scattered KV cache read가 캐시 locality를 파괴한다고 지적한다. Prefill의 consolidated access와 근본적으로 다른 이유다.
GPU 메모리 대역폭으로 상한선 계산하기
한 decode step에서 GPU가 읽어야 하는 바이트 수는:
총 읽기 bytes = model_bytes + B × kv_bytes_per_seq
model_bytes = num_params × 2 (BF16)
kv_bytes_per_seq = num_layers × 2 × num_kv_heads × head_dim × 2 × ctx_len
(K와 V 각각, BF16)
이론적 aggregate throughput은 B × GPU_BW / 총_읽기_bytes다. B가 작을 때는 분모의 model_bytes가 지배해 throughput이 B에 거의 선형으로 오른다. B가 커질수록 B × kv_bytes_per_seq 항이 커져 증가율이 꺾인다. 두 항이 같아지는 지점이 B_knee다:
B_knee = model_bytes / kv_bytes_per_seq
Llama-3-8B(32 layers, num_kv_heads=8 GQA, head_dim=128, BF16 16GB)와 Llama-3-70B(80 layers, num_kv_heads=8 GQA, head_dim=128, BF16 140GB) 기준으로 계산하면:
| 컨텍스트 길이 | 8B kv/요청 | 8B B_knee | 70B kv/요청 | 70B B_knee |
|---|---|---|---|---|
| 512 tok | 67 MB | 238 | 168 MB | 833 |
| 2048 tok | 268 MB | 60 | 671 MB | 208 |
| 8192 tok | 1.07 GB | 15 | 2.68 GB | 52 |
70B의 B_knee가 8B보다 훨씬 높다. GQA로 KV head 수가 동일(8개)한 상황에서 모델 파라미터는 8.75배 크지만 KV 캐시는 2.5배만 크기 때문이다. H100 SXM(3.35 TB/s)과 A100(2 TB/s)에서 B_knee는 같다. BW가 바뀌어도 두 항의 교차점 자체는 달라지지 않는다. H100은 TPOT을 A100 대비 40% 낮추지만 B_knee는 동일한 위치에 있다.
배치 크기별 TPOT과 throughput: 이론값
Llama-3-8B를 A100(2 TB/s) 한 장에서 서빙할 때, roofline 모델 기반 이론값(실측과 10~20% 오차 내)은 다음과 같다.
| 배치 크기 | S=512 TPOT | S=512 tok/s | S=2048 TPOT | S=2048 tok/s | S=8192 TPOT | S=8192 tok/s |
|---|---|---|---|---|---|---|
| B=1 | 8.0 ms | 125 | 8.1 ms | 123 | 8.5 ms | 117 |
| B=8 | 8.3 ms | 968 | 9.1 ms | 880 | 12.3 ms | 651 |
| B=32 | 9.1 ms | 3,527 | 12.3 ms | 2,602 | 25.2 ms | 1,270 |
| B=64 | 10.1 ms | 6,304 | 16.6 ms | 3,857 | 42.4 ms | 1,509 |
| B=128 | 12.3 ms | 10,415 | 25.2 ms | 5,079 | 76.7 ms | 1,668 |
| B=256 | 16.6 ms | 15,422 | 42.4 ms | 6,040 | 145.5 ms | 1,759 |
S=512에서는 B=256까지도 throughput이 꾸준히 오르고 TPOT 악화가 완만하다. B_knee가 238이라 아직 한계에 닿지 않았기 때문이다.
S=2048에서는 B=32→64 구간에서 TPOT이 12.3 → 16.6 ms(35% 증가), tok/s는 2,602 → 3,857(48% 증가)이다. 여기까진 배치를 늘릴 이유가 있다. B=64→128 구간은 TPOT이 51% 더 오르고 tok/s는 32% 오르는 데 그친다. 한계 효율이 역전되는 지점이 이 구간이다.
S=8192가 가장 극적이다. B=8에서 이미 TPOT이 B=1 대비 1.45배고, B=32에서는 2.95배다. B_knee가 15이므로 배치 32는 이미 knee를 두 배 지난 위치다. B=32에서 B=256으로 배치를 8배 키워도 tok/s는 1,270 → 1,759로 38% 오르는 데 그친다.
이론값과 실측의 오차 주요 원인은 메모리 단편화(PagedAttention의 블록 단위 할당), 스케줄링 오버헤드, 그리고 attention 커널의 실제 HBM 접근 효율이다.
서빙 엔진의 대응 방식
vLLM의 --max-num-seqs가 decode 배치의 직접적인 상한이다. continuous batching은 새 prefill을 기존 decode iteration에 끼워 넣으면서 decode 배치가 이 값을 넘지 않도록 유지한다. 문제는 --max-num-seqs 기본값이 256인데, 컨텍스트 2048 토큰 환경에서 8B 모델의 B_knee는 60이라는 점이다. 기본값으로 운영하면 이미 4배를 넘어선 채로 서빙하는 셈이다.
TensorRT-LLM은 KvCacheConfig(max_tokens=N)으로 전체 KV 캐시 토큰 수에 하드 캡을 건다. 배치 크기 자체가 아닌 KV 캐시 총량을 제한하므로, 컨텍스트 길이가 달라도 대역폭 포화 수준이 일정하게 유지된다. kv_cache_free_gpu_mem_fraction(기본 0.9)과 함께 쓸 때 두 조건 중 작은 쪽이 실제 할당량이 된다.
SGLang의 RadixAttention은 prefix가 동일한 요청들의 KV 캐시를 공유한다. 시스템 프롬프트처럼 모든 요청이 같은 prefix를 쓰는 환경에서는 effective kv_bytes_per_seq가 줄어 B_knee가 올라간다. prefix 공유 비율이 60% 이상인 배포에서는 실질적으로 배치 여유가 2배 가까이 늘기도 한다.
Sarathi-Serve(OSDI 2024)는 chunked prefill로 prefill 청크와 decode를 같은 iteration에 섞어 처리한다. stall-free 스케줄링 덕에 새 요청을 받을 때 decode 배치를 멈추지 않아도 돼서, 배치 크기가 갑자기 치솟을 때 TPOT이 폭등하는 현상이 완화된다. A100 단일 GPU에서 Mistral-7B 기준 vLLM 대비 2.6배 throughput 개선을 보고했다.
세 엔진이 택하는 방식은 다르지만 풀려는 문제는 하나다: decode step에서 GPU가 읽어야 하는 총 바이트를 줄이거나, 그 총량을 제어하는 파라미터를 노출하는 것.
운영에서 쓰는 판단 기준
세 값만 있으면 B_knee를 추정할 수 있다.
# B_knee 추정
model_bytes = num_params * 2 # BF16
kv_bytes_per_seq = num_layers * 2 * num_kv_heads * head_dim * 2 * avg_ctx_len
B_knee = model_bytes / kv_bytes_per_seq
# max_num_seqs 권장 설정 범위: B_knee의 1.0~1.5배
Llama-3-8B, 평균 컨텍스트 2048 토큰: B_knee ≈ 60 → --max-num-seqs 64.
Llama-3-8B, 평균 컨텍스트 8192 토큰: B_knee ≈ 15 → --max-num-seqs 16.
진단 신호는 TPOT p99다. TTFT(Time To First Token) p99가 SLO를 먼저 위반하면 prefill 병목이고, TPOT p99가 먼저 튀기 시작하면 decode 배치가 B_knee를 넘었다는 신호다. nvidia-smi dmon으로 memory bandwidth utilization이 85% 이상에 고착돼 있을 때 배치 크기를 줄이면 TPOT이 즉시 회복된다.
자주 하는 착각 하나: 8B 모델이 70B보다 작으니 더 많은 배치를 감당할 것이라는 직관이다. GQA로 KV head 수가 같은 한, 작은 모델일수록 model_bytes 대비 kv_bytes_per_seq 비율이 높아져 B_knee가 낮다. Llama-3-8B의 B_knee(S=2048)는 60, 70B는 208이다.