왜 KV 캐시가 문제인가

LLM 추론에서 각 요청은 지금까지 생성한 토큰의 Key/Value 텐서, 즉 KV 캐시를 GPU 메모리에 들고 있어야 한다. 문제는 요청마다 최종 길이를 미리 알 수 없다는 점이다. 기존 서빙 엔진은 max_seq_len 만큼의 연속된 메모리 블록을 요청당 통째로 예약했다. 실제로는 128토큰만 쓰는 요청도 2048토큰 분량을 붙잡고 있게 된다.

그 결과 두 가지 낭비가 겹친다. 첫째, 예약했지만 아직 안 쓴 내부 단편화(internal fragmentation). 둘째, 요청들이 서로 다른 크기로 들어오고 나가며 남는 자투리 공간이 재사용되지 못하는 외부 단편화. 실측상 이런 방식은 KV 캐시로 잡은 메모리의 20~40%만 실제 토큰에 쓰이는 경우가 흔하다.

PagedAttention의 핵심 아이디어

PagedAttention은 운영체제의 가상 메모리 페이징을 그대로 빌려온다. KV 캐시를 연속 블록이 아니라 고정 크기(예: 16토큰) 블록 단위로 쪼개 관리하고, 논리 블록과 물리 블록을 매핑하는 블록 테이블을 둔다. 요청은 실제로 토큰이 늘어날 때만 블록을 하나씩 할당받는다.

물리 블록이 메모리 어디에 흩어져 있어도 어텐션 커널이 블록 테이블을 참조해 계산하므로 연속성이 필요 없다. 덕분에 내부 단편화는 마지막 블록 하나(최대 15토큰)로 줄고, 외부 단편화는 사실상 사라진다. 남는 메모리는 곧바로 더 많은 동시 요청, 즉 더 큰 배치로 이어진다.

낭비 구조 비교

항목연속 예약 방식PagedAttention
할당 단위요청당 max_seq_len16토큰 블록
내부 단편화수백~수천 토큰최대 블록크기-1
외부 단편화발생거의 없음
프리픽스 공유불가블록 단위로 가능

vLLM으로 바로 적용하기

PagedAttention은 vLLM에 기본 내장되어 있다. 서버를 띄우는 것만으로 활성화된다.

pip install vllm

python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --gpu-memory-utilization 0.90 \
  --max-model-len 8192 \
  --block-size 16 \
  --enable-prefix-caching

핵심 파라미터는 gpu-memory-utilization이다. vLLM은 모델 가중치를 올린 뒤 남은 메모리에서 이 비율만큼을 KV 캐시 블록 풀로 잡는다. 0.90처럼 높이면 동시 처리량이 늘지만, 여유가 너무 적으면 활성화 텐서와 겹쳐 OOM이 난다.

배치 처리와 프리픽스 캐싱

블록 단위 관리의 부가 이득은 프리픽스 공유다. 같은 시스템 프롬프트를 쓰는 요청들이 동일한 물리 블록을 참조하므로, 중복 프리필 연산과 메모리를 함께 아낀다. 파이썬에서 오프라인 배치로 확인해 보자.

from vllm import LLM, SamplingParams

llm = LLM(
    model="meta-llama/Llama-3.1-8B-Instruct",
    gpu_memory_utilization=0.90,
    enable_prefix_caching=True,
    max_num_seqs=256,   # 동시 시퀀스 상한
)

system = "너는 간결한 기술 요약 도우미다.\n"
prompts = [system + q for q in ["A를 설명해라", "B를 설명해라"]]

out = llm.generate(prompts, SamplingParams(max_tokens=256))
for o in out:
    print(o.outputs[0].text)

max_num_seqs는 한 번에 스케줄링할 시퀀스 수의 상한이다. KV 블록 풀이 넉넉하면 이 값을 올려 GPU 활용률을 끌어올릴 수 있다.

실무에서 주의할 점

  • 블록 크기 튜닝은 신중히. 작으면 단편화가 줄지만 블록 테이블 관리 오버헤드와 커널 호출 비용이 커진다. 대부분 기본값(16)이 무난하며, 근거 없이 바꾸지 않는다.
  • 선점(preemption)을 모니터링하라. 메모리가 부족하면 vLLM은 진행 중인 시퀀스의 블록을 CPU로 스왑하거나 재계산(recompute)한다. 로그에 preemption이 잦으면 max_num_seqs를 낮추거나 max_model_len을 줄여야 한다.
  • utilization을 무작정 1.0에 붙이지 마라. CUDA graph 캡처와 피크 활성화 메모리를 위한 여유가 필요하다. 0.85~0.92 범위에서 실제 트래픽으로 검증한다.
  • 프리픽스 캐싱은 만능이 아니다. 프롬프트 앞부분이 매번 다르면 히트율이 낮아 이득이 없고, 캐시 관리 비용만 생긴다. 공유 프리픽스가 실제로 존재할 때만 켠다.

정리하면, PagedAttention은 KV 캐시의 단편화를 페이징으로 제거해 같은 GPU로 더 많은 동시 요청을 처리하게 해준다. 도입 자체는 vLLM 설정 몇 줄이면 끝나지만, 실제 이득은 utilization과 배치 상한, 선점 지표를 자신의 트래픽에 맞춰 조율할 때 나온다.