왜 배치 추론이 병목이 되는가
LLM 서빙에서 GPU는 대부분의 시간을 놀린다. 자기회귀(auto-regressive) 디코딩은 토큰을 하나씩 생성하므로, 요청 하나만 처리하면 행렬 곱은 작고 메모리 대역폭만 소모한다. 이때 GPU 연산 유닛의 실사용률은 흔히 10~30%에 그친다. 처리량(throughput)을 끌어올리는 핵심은 여러 요청을 묶어 한 번의 forward pass로 처리해 이 유휴 연산을 채우는 것이다.
문제는 요청마다 프롬프트 길이와 생성 길이가 제각각이라는 점이다. 순진하게 고정 크기 배치를 만들면, 짧은 요청이 긴 요청이 끝날 때까지 대기하며 자리를 차지한다. 배치가 클수록 대기 지연이 커지고, 작을수록 처리량이 낮아진다.
정적 배치의 한계와 continuous batching
전통적 정적 배치(static batching)는 배치 안 모든 요청이 끝나야 다음 배치를 시작한다. 생성 길이가 5토큰인 요청과 500토큰인 요청이 섞이면, 앞 요청은 495스텝 동안 슬롯만 점유한다.
continuous batching(iteration-level scheduling)은 스텝 단위로 스케줄링한다. 요청이 EOS에 도달하면 즉시 슬롯을 비우고, 대기 큐의 새 요청을 그 자리에 끼워 넣는다. vLLM, TGI 등이 이 방식을 쓴다. 동일 하드웨어에서 정적 배치 대비 처리량이 수 배 오르는 것은 대부분 이 스케줄링에서 나온다.
| 방식 | 슬롯 회수 | 처리량 | 지연 특성 |
|---|---|---|---|
| 정적 배치 | 배치 전체 종료 후 | 낮음 | tail에 묶임 |
| continuous batching | 요청별 EOS 즉시 | 높음 | 독립적 |
KV 캐시가 진짜 제약이다
배치 크기를 결정하는 것은 연산량이 아니라 KV 캐시 메모리다. 각 요청은 (레이어 수 × 2 × 시퀀스 길이 × 히든 차원)에 비례하는 KV를 저장하며, 이 값이 GPU VRAM을 빠르게 소진한다. PagedAttention은 KV를 고정 크기 블록으로 페이징해 단편화를 없애고, 같은 메모리로 더 많은 동시 요청을 담는다.
vLLM에서 동시성을 좌우하는 것은 gpu_memory_utilization과 max_num_seqs다. 너무 낮게 잡으면 배치가 작아지고, 너무 높게 잡으면 긴 프롬프트에서 OOM이 난다.
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
gpu_memory_utilization=0.90, # VRAM의 90%를 KV+가중치에 할당
max_num_seqs=256, # 동시 처리 시퀀스 상한
max_num_batched_tokens=8192, # 한 스텝당 토큰 예산
enable_prefix_caching=True, # 공통 프롬프트 접두어 재사용
)
params = SamplingParams(temperature=0.0, max_tokens=512)
# 프롬프트 리스트를 한 번에 넘기면 엔진이 알아서 배칭한다
outputs = llm.generate(prompts, params)
오프라인 대량 배치는 처리량에 몰빵
실시간 응답이 필요 없는 오프라인 작업(로그 분류, 임베딩 전처리, 대량 요약)이라면 지연을 포기하고 처리량에만 집중한다. max_num_seqs를 크게, max_num_batched_tokens를 넉넉히 잡고, 온라인 서버와 오프라인 잡을 같은 GPU에서 섞지 않는다.
입력을 프롬프트 길이순으로 정렬해 넣으면 비슷한 길이끼리 묶여 패딩 낭비와 스케줄링 오버헤드가 줄어든다.
# 길이 정렬로 배치 효율 개선 (오프라인 잡 한정)
records = sorted(records, key=lambda r: len(r["prompt"]))
# vLLM 서버 실행 예: 처리량 우선 설정
# python -m vllm.entrypoints.openai.api_server \
# --model meta-llama/Llama-3.1-8B-Instruct \
# --max-num-seqs 512 \
# --max-num-batched-tokens 16384 \
# --gpu-memory-utilization 0.92
양자화로 KV 여유 확보
FP16 대신 가중치를 INT8/FP8, AWQ 등으로 양자화하면 가중치가 차지하던 VRAM이 줄어 KV 캐시에 더 많은 공간이 돌아간다. 결과적으로 더 큰 배치가 가능해져 처리량이 오른다. 다만 정확도 저하를 반드시 대표 데이터셋으로 검증한다.
측정하고 튜닝하는 법
추측하지 말고 측정한다. 핵심 지표는 초당 출력 토큰 수(output tokens/s), 요청 대기 큐 길이, KV 캐시 사용률이다. 부하 생성기로 동시성을 단계적으로 올리며 처리량이 포화되는 지점을 찾는다.
# vLLM 내장 벤치마크로 처리량 측정
python benchmarks/benchmark_throughput.py \
--model meta-llama/Llama-3.1-8B-Instruct \
--input-len 512 --output-len 256 \
--num-prompts 1000
# Prometheus 지표 확인: vllm:num_requests_waiting,
# vllm:gpu_cache_usage_perc 가 튜닝의 나침반이 된다
주의점
- 처리량과 지연은 트레이드오프다. 온라인 API에서 배치를 무한정 키우면 p99 지연이 무너진다. SLO를 먼저 정하고 그 안에서 배치를 키운다.
- gpu_memory_utilization을 0.95 이상으로 올릴 때는 최대 프롬프트 길이를 함께 검증한다. 실측 부하가 아닌 짧은 테스트로 통과시키면 프로덕션에서 OOM이 난다.
- prefix caching는 공통 접두어(시스템 프롬프트, few-shot)가 있을 때만 이득이다. 매번 다른 입력에는 오버헤드만 남는다.
- 양자화·텐서 병렬 설정 변경 후에는 항상 정확도와 처리량을 다시 측정한다. 한 축을 개선하다 다른 축이 조용히 나빠지는 경우가 흔하다.