모델 운영

MLOPS / 22번째 글

KV 캐시 — 추론 메모리가 처리량을 정하는 자리

LLM 추론에서 GPU 메모리를 가장 많이 먹는 것은 가중치가 아니라 KV 캐시다. 캐시가 왜 생기는지, 크기를 어떻게 세는지, GQA·FP8·PagedAttention·프리픽스 재사용이 그 숫자를 어디까지 끌어내리는지를 숫자로 따라간다.

PALDYN Team34 MIN READ

지난 글에서 TGI의 아키텍처와 배포를 살펴보며 Continuous Batching이나 Flash Attention 같은 옵션을 켜고 껐다. 이 글은 그 옵션들이 전부 한 자원을 놓고 다투고 있었다는 사실에서 시작한다. 바로 KV 캐시다.

LLM 서빙에서 GPU 메모리를 가장 많이 잡아먹는 것은 모델 가중치가 아니다. Llama-3.1-8B를 BF16으로 올리면 가중치는 16 GB지만, 같은 모델을 배치 32·시퀀스 8192로 돌리면 KV 캐시만 34 GB다. 가중치의 두 배가 넘는다. 그리고 이 숫자는 고정값이 아니라 동시에 받는 요청 수와 문맥 길이에 정비례해서 자란다. 그래서 「이 GPU 한 장으로 몇 명을 동시에 받을 수 있는가」라는 질문의 답은 거의 전부 KV 캐시가 정한다. 추론 엔진의 튜닝 옵션이 하나같이 메모리 이야기인 것도 그 때문이다.

KV 캐시의 원리

프리필과 디코드

LLM 추론은 성질이 전혀 다른 두 단계로 나뉜다. 이 구분이 뒤에 나오는 모든 이야기의 출발점이다.

프리필(prefill)은 입력 프롬프트 전체를 한 번의 Forward Pass로 처리하는 단계다. Transformer는 시퀀스를 병렬로 처리할 수 있으므로 1,000 토큰짜리 프롬프트도 한 번에 밀어 넣고, 그 과정에서 각 레이어의 Key와 Value를 계산한다. 토큰이 많을수록 곱셈이 많아지므로 연산량 바운드 구간이다. GPU의 연산 유닛이 놀지 않고 돌아간다.

디코드(decode)는 토큰을 하나씩 만들어 내는 단계다. 한 스텝에서 새로 계산할 것은 토큰 하나 분량의 Q·K·V뿐이라 연산량이 아주 적다. 대신 그 토큰이 앞의 모든 토큰을 참조해야 하므로 저장해 둔 K·V 전부를 메모리에서 읽어야 한다. 계산은 적고 읽을 것은 많으니 메모리 대역폭 바운드 구간이다.

같은 모델의 같은 요청 안에서 병목이 두 번 바뀐다는 뜻이다. 프리필은 연산 유닛이, 디코드는 메모리 대역폭이 한계를 정한다. 두 단계를 아예 다른 기계로 갈라 놓는 프리필·디코드 분리 같은 구성이 나오는 이유도 여기에 있다.

KV 캐시 구조: Attention과 메모리 관리

재계산 제거

Self-Attention은 각 토큰에 대해 Query·Key·Value 세 벡터를 만들고 softmax(QK⊤/d) V\mathrm{softmax}(QK^\top / \sqrt{d})\,V 로 문맥을 섞는다. 여기서 중요한 성질이 하나 있다. 어떤 토큰의 K와 V는 그 토큰이 정해진 순간 확정되고, 뒤에 무엇이 오든 다시는 바뀌지 않는다. 자기회귀 모델은 앞을 보지 못하므로 5번 토큰의 Key는 6번 토큰이 무엇이 되든 그대로다.

바뀌지 않는 값을 매 스텝 다시 계산하는 것은 낭비다. KV 캐시는 한 번 계산한 K·V를 GPU 메모리에 남겨 두고 다음 스텝에서 그대로 읽어 쓰는 장치다. 캐시가 없으면 새 토큰 하나를 만들 때마다 프롬프트 전체를 다시 밀어 넣어야 한다.

낭비의 크기를 세어 보면 이렇다. 512 토큰을 생성하는데 캐시가 없다면 tt 번째 스텝에서 tt 개 토큰의 K·V를 계산하므로 전체는 1+2+⋯+512≈131,0001 + 2 + \cdots + 512 \approx 131{,}000 회다. 캐시가 있으면 각 토큰의 K·V를 정확히 한 번씩만 계산하므로 512회다. 256배 차이다. 시퀀스가 길어지면 이 배수도 함께 커진다.

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-1B")
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B")

inputs = tok("한국의 수도는", return_tensors="pt")
with torch.no_grad():
    out = model(**inputs, use_cache=True)   # 프리필 — 여기서 캐시가 만들어진다
    past = out.past_key_values

    for _ in range(50):                     # 디코드 — 토큰 하나씩, 캐시는 계속 자란다
        nxt = out.logits[:, -1, :].argmax(dim=-1, keepdim=True)
        out = model(nxt, past_key_values=past, use_cache=True)
        past = out.past_key_values

디코드 루프에서 모델에 넘기는 입력이 토큰 하나뿐이라는 점을 보면 된다. 나머지 문맥은 전부 past_key_values 안에 들어 있고, 스텝마다 한 토큰 분량씩 뒤에 붙는다.

병목의 이동

캐시는 연산을 지웠지만 그 대가로 메모리를 먹는다. 그리고 이 교환이 서빙 성능의 성격을 통째로 바꿔 놓는다.

디코드 한 스텝에서 GPU가 메모리에서 읽어야 하는 것은 두 덩이다. 모델 가중치 16 GB, 그리고 배치에 들어 있는 모든 요청의 KV 캐시다. HBM 대역폭이 초당 2 TB 남짓인 GPU를 가정하고 계산해 보자. 배치가 1이고 문맥이 8192 토큰이면 읽을 것은 가중치 16 GB에 캐시 약 1 GB를 더한 17 GB이고, 한 스텝에 8.5 ms가 걸린다. 초당 117 토큰이 상한이다.

배치를 32로 올리면 가중치는 여전히 한 번만 읽지만 캐시는 32벌이라 34 GB가 된다. 합이 50 GB, 한 스텝에 25 ms다. 스텝 하나에 토큰 32개가 나오니 초당 1,280 토큰이다. 배치를 32배로 키웠는데 처리량은 11배만 늘었다. 나머지는 캐시를 읽는 데 쓰였다.

연산 시간을 무시한 대략의 상한이지만, 이 계산이 말하는 방향은 실제와 같다. 배치를 키울 때 처리량이 선형으로 따라오지 않는 지점이 있고 그 지점을 정하는 것이 캐시 크기라는 것이다. 그래서 캐시를 줄이는 일은 메모리를 아끼는 일인 동시에 처리량을 올리는 일이다. 뒤에 나오는 최적화들이 전부 「메모리 절감」과 「배치 확대」를 같은 문장에서 말하는 이유가 이것이다.

캐시 크기 공식

공식의 인자

캐시 크기는 추정할 필요가 없다. 정확히 곱셈 하나로 나온다.

KV bytes=2×L×Hkv×dhead×S×B×b\text{KV bytes} = 2 \times L \times H_{kv} \times d_{head} \times S \times B \times b

앞의 2는 K와 V 두 벌이라는 뜻이고, LL 은 레이어 수, HkvH_{kv} 는 KV 헤드 수, dheadd_{head} 는 헤드 하나의 차원, SS 는 시퀀스 길이, BB 는 배치 크기, bb 는 원소 하나의 바이트 수다. 주목할 것은 여기 없는 값이다. 파라미터 수도, Query 헤드 수도, 은닉 차원도 직접 들어오지 않는다. 그래서 「7B 모델이니까 캐시도 그만큼」이라는 감이 잘 안 맞는다.

def kv_bytes_per_token(n_layers, n_kv_heads, head_dim, dtype_bytes=2):
    return 2 * n_layers * n_kv_heads * head_dim * dtype_bytes

# Llama-3.1-8B: 32 레이어, KV 헤드 8, 헤드 차원 128, FP16
per_token = kv_bytes_per_token(32, 8, 128, 2)
print(per_token / 1024, "KiB/token")            # 128.0 KiB
print(per_token * 8192 / 1024**3, "GiB/seq")    # 1.0 GiB
print(per_token * 8192 * 32 / 1024**3, "GiB")   # 32.0 GiB

토큰당 128 KiB

Llama-3.1-8B의 값을 넣으면 2×32×8×128×2=131,0722 \times 32 \times 8 \times 128 \times 2 = 131{,}072 바이트, 곧 토큰 하나에 128 KiB다. 이 숫자 하나만 외워 두면 나머지는 암산으로 나온다. 8,192 토큰짜리 요청 하나가 정확히 1 GiB이고, 그런 요청 32개면 32 GiB다. 서두에 적은 34 GB가 이 값이다 — 1024로 나누면 32 GiB, 1000으로 나누면 34 GB이고 둘은 같은 양이다. 벤더 문서와 모니터링 도구가 이 두 단위를 섞어 쓰므로, 6% 어긋난 숫자를 만나면 대개 여기다.

토큰당 크기가 배치와 무관하다는 점이 실무에서 편하다. 서비스가 감당할 문맥의 총량을 토큰 수로 잡아 두면 필요한 메모리가 바로 나온다. 「평균 2,000 토큰 대화를 200개 동시에」라면 2,000×200×128 KiB=48.8 GiB2{,}000 \times 200 \times 128\ \text{KiB} = 48.8\ \text{GiB} 다. 이 계산에는 요청이 언제 끝나는지도, 응답이 얼마나 긴지도 필요 없다.

KV 캐시 크기와 최적화

메모리 예산

GPU 한 장의 메모리는 세 곳으로 갈린다. 80 GB 카드에 8B 모델을 올린 경우를 표로 적으면 이렇다.

자리 크기 성질
모델 가중치 (BF16) 16 GB 고정
활성화 텐서·프레임워크 여유 4 GB 안팎 배치·시퀀스에 따라 조금
KV 캐시 남는 전부 요청이 채운다

vLLM의 gpu_memory_utilization을 0.92로 두면 73.6 GB를 쓰겠다는 뜻이고, 위의 20 GB를 빼면 KV 캐시에 54 GB쯤이 남는다. 이 값이 곧 동시 처리 능력이다. 128 KiB/token으로 나누면 약 41만 토큰이니, 8,192 토큰짜리 긴 요청이라면 50개, 1,000 토큰짜리 짧은 대화라면 410개를 동시에 물 수 있다.

이 숫자를 손에 쥐고 있으면 용량 산정이 감이 아니라 나눗셈이 된다. 남은 절들이 그 나눗셈의 분자와 분모를 각각 어떻게 건드리는지에 대한 이야기다.

토큰당 크기 절감

캐시를 줄이는 길은 크게 둘이다. 토큰 하나가 차지하는 바이트를 줄이거나, 이미 잡아 둔 자리를 덜 낭비하거나. 이 절은 앞쪽이고, 공식에서 곱해지는 값을 직접 내리는 방법이다.

KV 캐시 4대 최적화 전략

GQA와 MQA

공식의 HkvH_{kv} 를 건드리는 것이 GQA(Grouped Query Attention)다. 전통적인 MHA(Multi-Head Attention)는 Query 헤드 하나마다 자기 K·V 헤드를 하나씩 갖는다. Llama-3.1-8B의 Query 헤드가 32개이니 MHA였다면 KV 헤드도 32개다. GQA는 여러 Query 헤드가 K·V 헤드 하나를 함께 쓰게 만든다. 8B는 32개 Query를 4개씩 묶어 KV 헤드 8개에 붙였고, 그만큼 캐시가 4분의 1이 됐다.

극단까지 밀면 KV 헤드가 하나만 남는 MQA(Multi-Query Attention)가 된다. 캐시는 가장 작지만 표현력 손실이 커서, 요즘 모델은 대개 그 사이 어딘가를 고른다. 셋의 차이는 결국 한 값에 있다.

방식 KV 헤드 수 8B 기준 토큰당 캐시
MHA 32 (Query와 같음) 512 KiB
GQA 8 (4개가 하나를 공유) 128 KiB
MQA 1 16 KiB

여기에는 손잡이가 없다는 점을 분명히 해 둬야 한다. GQA는 사전학습 때 정해지는 구조라 서빙 설정으로 켜고 끌 수 없다. 모델을 고르는 순간 함께 정해지고, 우리가 할 수 있는 일은 쓰려는 모델의 num_key_value_heads를 확인해 용량 계산에 넣는 것뿐이다. 같은 파라미터 규모라도 이 값이 다르면 한 GPU에 태울 수 있는 동시 요청 수가 네 배씩 갈린다.

FP8 양자화

공식의 bb 를 건드리는 것이 KV 캐시 양자화다. K·V를 FP16 대신 FP8로 저장하면 원소 하나가 2바이트에서 1바이트가 되고 캐시가 정확히 절반이 된다. 128 KiB/token이 64 KiB/token이 되고, 앞의 54 GB 예산으로 물 수 있는 8,192 토큰 요청이 50개에서 100개로 늘어난다.

vLLM에서는 kv_cache_dtype="fp8" 한 줄이다. 가중치 양자화와 달리 별도의 캘리브레이션 과정 없이 켤 수 있고, 대부분의 태스크에서 품질 저하가 눈에 띄지 않는다는 것이 통상의 보고다. 다만 「대부분」이라는 말에 기대지 말고 자기 데이터로 한 번은 확인하는 편이 낫다. 긴 문맥에서 앞쪽 토큰을 정확히 짚어 와야 하는 작업이 특히 민감하다. K와 V 중 어느 쪽이 양자화에 더 약한지, 어디까지 줄여도 되는지는 KV 캐시 양자화에서 따로 다뤘다.

절감의 곱셈

두 손잡이가 공식의 서로 다른 자리를 잡고 있으므로 효과는 더해지지 않고 곱해진다. MHA·FP16을 기준으로 놓으면 GQA가 4분의 1로, FP8이 다시 절반으로 줄이니 합쳐서 8분의 1이다. 토큰당 512 KiB가 64 KiB가 되고, 배치 32·시퀀스 8192 기준 캐시는 137 GB에서 17 GB로 내려간다.

이것이 「같은 GPU에서 동시 요청 수를 여덟 배」라는 말의 산수다. 그리고 두 손잡이의 성격이 정반대라는 점을 기억해 두는 편이 좋다. GQA는 모델을 고를 때 이미 끝난 결정이고, FP8은 배포할 때 언제든 켜고 끌 수 있는 설정이다. 용량이 모자랄 때 오늘 당장 돌릴 수 있는 손잡이는 뒤쪽 하나뿐이다.

블록 할당과 재사용

두 번째 길은 토큰당 크기는 그대로 두고 잡아 둔 자리를 덜 버리는 쪽이다. 앞 절이 분자를 줄였다면 이 절은 낭비를 줄인다.

PagedAttention

전통적인 구현은 요청 하나가 들어올 때 최대 시퀀스 길이만큼 연속된 메모리를 미리 잡았다. max_model_len=8192면 요청마다 1 GiB를 선점한다. 문제는 대부분의 요청이 그 길이까지 가지 않는다는 것이다. 실제로 500 토큰만 쓰고 끝나는 요청이 잡아 둔 1 GiB 중 실제로 쓰는 것은 62 MiB이고, 나머지 962 MiB는 요청이 끝날 때까지 아무도 못 쓴다.

PagedAttention은 운영체제의 가상 메모리 페이징을 KV 캐시에 그대로 옮긴 방식이다. 캐시를 고정 크기 블록(vLLM 기본은 16 토큰)으로 쪼개고, 요청마다 논리 슬롯이 어떤 물리 블록을 가리키는지 적은 테이블을 둔다. 요청은 자기가 실제로 쓴 만큼만 블록을 받고, 토큰이 늘면 블록을 하나 더 받는다. 연속된 자리일 필요가 없으니 단편화도 사라진다.

낭비의 크기가 어떻게 바뀌는지 보면 차이가 분명하다. 블록 방식에서 한 요청이 버리는 것은 마지막 블록의 빈칸, 최대 15 토큰뿐이다. 앞의 962 MiB가 1.9 MiB가 된다. 54 GB 예산에 평균 500 토큰짜리 요청을 담으면 선점 방식은 50개에서 멈추지만 블록 방식은 800개를 넘긴다. 실측에서 흔히 인용되는 배치 2~4배 증가는 요청 길이가 최대 길이에 가까울 때의 보수적인 수치이고, 짧은 요청이 섞일수록 격차가 벌어진다. 블록 테이블의 구조와 튜닝 지점은 PagedAttention에서 더 파고들었다.

프리픽스 재사용

블록으로 쪼개 놓으면 공짜로 따라오는 것이 하나 있다. 여러 요청이 같은 물리 블록을 가리켜도 된다는 것이다.

RAG나 에이전트는 수천 토큰짜리 시스템 프롬프트를 모든 요청 앞에 똑같이 붙인다. 이 앞부분의 K·V는 요청마다 같은 값인데도, 캐시를 요청별로 따로 두면 매번 다시 계산하고 따로 저장한다. Prefix Caching은 블록 단위로 내용의 해시를 떠 두고, 같은 해시가 들어오면 이미 있는 블록을 그대로 가리키게 한다. 저장은 한 벌이고 계산은 한 번이다.

효과가 두 군데에 나타난다. 메모리 쪽에서는 요청 수만큼 곱해지던 프리픽스 캐시가 한 벌로 줄고, 지연 쪽에서는 프리필 자체가 사라진다. 3,000 토큰 프리픽스에 200 토큰 질문이 붙는 형태라면 프리필 할 일의 94%가 없어지므로 첫 토큰 지연이 크게 떨어진다 — 흔히 인용되는 TTFT 80% 감소는 이렇게 프리픽스가 프롬프트의 대부분을 차지하는 부하에서 나온 값이다. 반대로 프롬프트가 매번 완전히 다른 서비스에서는 해시가 맞을 일이 없어 이득이 0이다. 켜기 전에 자기 트래픽의 프리픽스 공유율을 먼저 봐야 한다.

vLLM은 이것을 블록 해시 테이블로 구현하고, SGLang은 접두 트리를 써서 부분적으로 겹치는 프리픽스까지 잡아낸다. 트리 방식이 무엇을 더 건지는지는 SGLang 쪽에서 다뤘다.

프롬프트 조립 순서

프리픽스 재사용은 옵션 하나로 끝나지 않는다. 프롬프트를 어떻게 조립하느냐가 히트율을 정한다. 해시는 앞에서부터 순서대로 뜨므로 앞부분이 한 글자라도 다르면 그 뒤는 전부 miss다.

가장 흔한 사고가 프롬프트 맨 앞에 변하는 값을 넣는 것이다. 현재 시각, 사용자 이름, 요청 ID 같은 것을 시스템 프롬프트 첫 줄에 넣어 두면 매 요청 해시가 달라져 뒤에 오는 3,000 토큰이 통째로 재계산된다. 옵션은 켜져 있는데 히트율이 0인 상태다. 고치는 법은 간단하다 — 고정된 것을 앞으로, 요청마다 달라지는 것을 뒤로 몰면 된다.

블록 경계도 알아 둘 값이다. 재사용은 블록 단위이므로 공유 프리픽스가 3,000 토큰이어도 블록 크기가 16이면 3,000=16×187+83{,}000 = 16 \times 187 + 8 에서 앞의 2,992 토큰까지만 블록으로 맞아떨어지고 남는 8 토큰은 다음 요청의 내용과 섞인 블록이 되어 재사용에서 빠진다. 프리픽스가 길면 무시할 만한 자투리지만, 짧은 프리픽스를 여러 개 두는 설계라면 자투리 비율이 커진다.

배치 전략

캐시를 아무리 줄여도 그 자리를 쓸 요청이 없으면 처리량은 늘지 않는다. 절약한 메모리를 실제 동시 처리로 바꾸는 것이 배치 전략이다.

정적 배치

전통적인 배치는 요청 여러 개를 한 묶음으로 모아 동시에 시작하고 동시에 끝낸다. 여기서 두 가지가 새어 나간다.

첫째는 패딩(padding)이다. 길이가 다른 요청을 한 텐서에 담으려면 짧은 쪽을 빈 토큰으로 채워야 하고, 그 자리에도 캐시가 잡힌다. 둘째가 더 아프다. 배치 안의 한 요청이 20 토큰 만에 끝나고 다른 요청이 500 토큰까지 가면, 먼저 끝난 요청의 슬롯은 480 스텝 동안 비어 있는 채로 함께 돈다. GPU는 그 슬롯 몫의 계산을 계속하고 캐시도 계속 붙들고 있는데 나오는 것은 아무것도 없다. 대기 큐에 요청이 쌓여 있어도 배치 전체가 끝날 때까지 넣을 수 없다.

LLM 응답 길이는 요청마다 제각각이고 미리 알 수도 없으므로, 이 낭비는 예외가 아니라 기본값이다.

정적 배치 vs Continuous Batching

Continuous Batching

Continuous Batching(Yu et al., 2022)은 배치를 요청 묶음이 아니라 한 번의 Forward Pass 단위로 다시 짠다. 한 스텝이 끝날 때마다 EOS를 냈거나 max_tokens에 닿은 요청을 배치에서 빼고, 빈 슬롯에 대기 큐의 새 요청을 넣고, 다음 스텝을 돈다. 슬롯이 비는 시간이 최대 한 스텝이다.

KV 캐시 쪽에서 보면 이것이 왜 자연스러운지 보인다. 요청이 끝나는 즉시 그 요청의 블록이 반납되고 새 요청이 그 블록을 받는다. 블록 단위 할당과 스텝 단위 스케줄링은 사실상 한 몸이다 — 미리 잡아 두는 방식으로는 스텝 하나 만에 슬롯을 갈아 끼울 수가 없다. 그래서 max_num_seqs를 올려도 캐시가 모자라면 엔진이 요청을 대기시키거나 이미 실행 중인 요청을 밀어낸다. 동시 요청 수의 진짜 상한은 이 설정값이 아니라 캐시 예산이다. 스케줄러가 프리필과 디코드를 한 배치에 섞을 때 벌어지는 일은 연속 배칭에서 자세히 다뤘다.

처리량과 지연

배치를 키우면 처리량은 오르고 개별 요청의 지연은 나빠진다. 한 스텝에 읽어야 할 캐시가 늘어 스텝 자체가 길어지기 때문이다. 앞에서 배치 1의 스텝이 8.5 ms, 배치 32의 스텝이 25 ms였던 그 차이다. 손잡이마다 어느 쪽으로 기우는지 정리하면 이렇다.

설정 처리량 지연(P99) 맞는 자리
배치 크기 ↑ 오름 나빠짐 오프라인 일괄 처리
배치 크기 ↓ 내림 좋아짐 실시간 챗봇
응답 길이 상한 ↑ 오름 나빠짐 긴 문서 생성
Prefix Caching 오름 좋아짐 (히트 시) RAG·에이전트
KV FP8 오름 거의 그대로 대부분의 경우

아래 두 줄이 다른 줄과 성격이 다르다는 점을 눈여겨볼 만하다. 배치 크기는 둘 중 하나를 골라야 하는 맞바꿈이지만, 캐시를 줄이거나 재사용하는 최적화는 양쪽을 동시에 좋게 만든다. 튜닝을 시작할 때 배치 크기부터 만지는 것보다 캐시 쪽을 먼저 손보는 편이 나은 이유다. 실제로 어느 쪽이 얼마나 움직였는지는 부하를 걸어 재야 하고, 그 측정을 어떻게 해야 재현되는 숫자가 나오는지는 서빙 벤치마크에서 다뤘다.

다중 GPU 구성

텐서 병렬

GPU 한 장에 안 들어가는 모델은 여러 장에 쪼갠다. Tensor Parallelism은 레이어 안의 행렬을 헤드 단위로 갈라 각 GPU에 나눠 주는 방식이고, KV 캐시도 함께 갈린다. KV 헤드가 8개인 모델을 2장에 나누면 장당 4개를 맡으니 장당 캐시가 절반이다.

from vllm import LLM

llm = LLM(
    model="meta-llama/Llama-3.1-70B-Instruct",
    tensor_parallel_size=4,          # KV 헤드 8개를 장당 2개씩
    gpu_memory_utilization=0.90,
    kv_cache_dtype="fp8",
    enable_prefix_caching=True,
)

여기서 걸리는 자리가 둘이다. 하나는 KV 헤드 수보다 GPU가 많아지면 더 나눌 것이 없다는 것이다. 헤드 8개를 16장에 나눌 수는 없으므로 엔진은 헤드를 복제하고, 그때부터는 GPU를 늘려도 장당 캐시가 줄지 않는다. GQA로 헤드를 줄인 모델일수록 이 벽에 일찍 닿는다.

다른 하나는 가중치가 먼저 자리를 차지한다는 점이다. 70B를 BF16으로 올리면 가중치만 140 GB이므로 80 GB 카드 2장에 나누면 장당 70 GB, 남는 자리가 사실상 없다. 위 코드가 2가 아니라 4를 쓴 이유가 그것이다 — 4장이면 장당 가중치가 35 GB로 내려가 캐시에 35 GB 안팎이 남는다. GPU 수를 정할 때는 가중치가 들어가는지가 아니라 캐시가 얼마나 남는지를 봐야 한다. 참고로 70B는 레이어가 80장이고 KV 헤드는 8B와 같은 8개라, 토큰당 캐시가 8B의 2.5배다.

텐서 병렬과 KV 헤드

Chunked Prefill

여러 장에 나눠도 프리필과 디코드가 서로를 방해하는 문제는 남는다. 8,000 토큰짜리 프롬프트가 하나 들어오면 그 프리필 한 번이 스텝 하나를 통째로 길게 잡아먹고, 그동안 디코드 중이던 요청 수십 개는 다음 토큰을 못 받는다. 사용자 입장에서는 잘 나오던 글자가 갑자기 멎는다.

Chunked Prefill은 긴 프리필을 여러 스텝에 나눠 넣어 이 멈춤을 없앤다. 한 스텝에 처리할 토큰 총량을 max_num_batched_tokens로 정해 두면 그 예산 안에서 프리필 조각과 디코드 요청이 함께 실린다. 프리필 하나의 완료는 조금 늦어지지만 전체 지연 분포는 훨씬 평평해진다. vLLM V1 엔진은 가능한 경우 이것을 기본으로 켜므로, 요즘은 켜고 끄는 것보다 예산을 얼마로 둘지가 실제 손잡이다. 예산을 키우면 프리필이 한 스텝에 더 많이 들어가 첫 토큰이 빨라지고, 줄이면 디코드를 밀어내는 프리필 조각이 작아져 토큰 사이 간격이 고르게 된다.

설정 순서

지금까지의 손잡이를 프로덕션 설정 한 벌로 모으면 이렇게 된다.

from vllm import LLM

llm = LLM(
    model="meta-llama/Llama-3.1-8B-Instruct",
    kv_cache_dtype="fp8",            # 토큰당 캐시 절반
    enable_prefix_caching=True,      # 공통 프리픽스 재사용 — V1 기본값
    gpu_memory_utilization=0.92,     # 캐시로 갈 예산
    max_model_len=8192,              # 문맥 상한 — 캐시 상한이기도 하다
    max_num_seqs=512,                # 동시 시퀀스 상한
    enable_chunked_prefill=True,     # 긴 프리필을 여러 스텝으로 — V1 기본값
    max_num_batched_tokens=32768,    # 한 스텝의 토큰 예산
)

순서를 정리해 두면 이렇다. GQA는 모델을 고르는 순간 끝났고, PagedAttention과 Prefix Caching은 vLLM V1에서 이미 기본으로 켜져 있다 — 캐시 설정의 enable_prefix_caching 기본값이 True다. 위 코드가 그 둘을 적은 것은 무엇이 켜져 있는지 설정 파일만 보고도 알게 하려는 것이지 새로 켜는 것이 아니다. 그러니 실제로 켜는 손잡이는 FP8 하나이고, Prefix Caching 쪽에 남는 일은 켜는 것이 아니라 앞 절처럼 히트가 나도록 프롬프트를 짜는 것이다. 그다음이 gpu_memory_utilization을 올려 캐시 예산을 늘리는 것인데, 너무 올리면 활성화 텐서가 자리를 못 찾아 OOM이 난다. 0.90~0.95 사이에서 실제 부하로 확인하며 정한다. max_model_len을 실제 필요보다 크게 잡지 않는 것도 잊기 쉬운 손잡이다 — 이 값이 요청 하나가 최대로 잡을 수 있는 캐시를 정한다.

여기까지가 GPU 안쪽 이야기다. 캐시를 줄이고 배치를 채워 한 장에서 뽑을 수 있는 처리량을 끌어올렸다면, 그다음은 그 성능을 바깥으로 내보내는 문제가 남는다. 클라이언트가 붙을 인터페이스를 무엇으로 할지, 토큰을 생성되는 대로 흘려보내려면 응답을 어떻게 열어 둬야 할지, 그리고 애써 확보한 캐시 예산을 한 사용자가 다 먹지 않게 어떻게 막을지다. 다음 글에서 그 세 가지를 다룬다.


읽어주셔서 감사합니다. 😊

LATEST

모델 운영의 최신 글

모델 운영2026.09.04

KV 캐시 양자화 — 가중치보다 이쪽이 먼저 넘친다

긴 문맥에서 GPU 메모리를 실제로 잡아먹는 것은 가중치가 아니라 KV 캐시입니다. 캐시 크기를 계산하는 법, K와 V를 다르게 다뤄야 하는 이유, 어디까지 줄여도 되는지를 정리합니다.

16 MIN
모델 운영2026.09.04

양자화 보정 — 데이터 128개가 모델 품질을 정한다

양자화에서 스케일을 정하는 절차가 보정입니다. 무엇을 재는지, 데이터를 어디서 몇 개 뽑아야 하는지, 자르는 지점을 어떻게 고르는지, 그리고 잘못된 보정이 어떤 모양으로 드러나는지를 정리합니다.

21 MIN
모델 운영2026.09.03

과제 특화 증류 — 큰 모델의 답을 작은 모델에 옮긴다

범용 성능이 아니라 우리 과제 하나만 잘하는 작은 모델을 만드는 방법입니다. 교사에게 무엇을 받아야 하는지, 데이터를 어떻게 모으고 거르는지, 손익 분기가 어디인지를 정리합니다.

15 MIN