- Published on
LLM 추론 VRAM 계산법 — 가중치보다 KV 캐시가 먼저 터집니다
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — "이 모델, 우리 GPU에 올라갑니까"
이 질문에 "돌려 봐야 압니다"라고 답하면 인프라 예산 회의에서 할 말이 없어집니다. 다행히 추론 메모리는 학습과 달리 거의 전부 산수로 예측됩니다. 옵티마이저 상태도, 그래디언트도 없기 때문입니다.
그런데 대부분의 계산은 절반에서 멈춥니다. "7B 모델은 fp16으로 14GB니까 24GB 카드에 여유롭게 들어간다"까지는 맞습니다. 그다음 배치를 32로 올리고 컨텍스트를 4천 토큰으로 늘리는 순간 메모리 부족으로 죽습니다. 빠진 항이 KV 캐시이고, 이 항은 가중치와 달리 사용자 트래픽의 모양에 따라 자랍니다.
이 글은 추론 VRAM을 네 덩어리(가중치, KV 캐시, 활성화, 프레임워크 오버헤드)로 나누고 각각을 계산하는 방법을 정리합니다. 숫자는 전부 직접 검산할 수 있게 식으로 제시하니, 본인 모델의 config 파일을 열어 두고 읽으시면 됩니다.
가중치 메모리 — 곱셈 한 번으로 끝나는 부분
가중치는 정직합니다. 파라미터 수에 파라미터당 바이트를 곱하면 끝입니다.
BYTES_PER_PARAM = {
"fp32": 4.0,
"fp16": 2.0, # bf16도 동일
"int8": 1.0,
"fp8": 1.0,
"int4": 0.5, # 4비트, 실제로는 스케일/제로포인트가 조금 더 붙음
}
GIB = 1024 ** 3
def weight_gib(params_billions: float, dtype: str = "fp16") -> float:
return params_billions * 1e9 * BYTES_PER_PARAM[dtype] / GIB
for n in (7, 13, 70):
print(n, [round(weight_gib(n, d), 1) for d in ("fp16", "int8", "int4")])
# 7 [13.0, 6.5, 3.3]
# 13 [24.2, 12.1, 6.1]
# 70 [130.4, 65.2, 32.6]
여기서 단위를 한 번 짚고 갑니다. 70억 곱하기 2바이트는 140억 바이트이고, 이를 10의 9승으로 나누면 14GB, 2의 30승으로 나누면 13.0GiB입니다. 카드 사양서의 "80GB"는 대개 10진 표기이고 nvidia-smi가 보여 주는 숫자는 2진 표기라, 이 7퍼센트 차이 때문에 계산이 아슬아슬하게 안 맞는 경우가 생깁니다. 아래 계산은 전부 GiB 기준으로 통일합니다.
4비트 항목의 숫자는 이상적인 값입니다. 실제 4비트 체크포인트는 그룹마다 스케일과 제로포인트를 fp16으로 들고 있어서, 그룹 크기가 128이면 파라미터당 대략 0.5비트가 추가로 붙습니다. 파라미터당 0.5바이트가 아니라 0.53바이트 정도로 잡는 편이 안전합니다. 그리고 임베딩과 출력 레이어는 양자화하지 않고 두는 구현이 많아, 어휘 크기가 큰 모델일수록 이 차이가 커집니다.
KV 캐시 — 실제로 터지는 곳
자기회귀 디코딩은 토큰을 하나 만들 때마다 이전 토큰 전부에 대한 어텐션을 계산합니다. 이미 계산한 키와 값을 버리면 매 스텝마다 전체 시퀀스를 다시 계산해야 하므로, 전부 들고 있습니다. 그게 KV 캐시입니다.
토큰 하나가 차지하는 바이트는 다음과 같습니다.
def kv_bytes_per_token(layers: int, kv_heads: int, head_dim: int,
bytes_per_elem: float = 2.0) -> float:
# 2는 K와 V 두 개를 저장하기 때문
return 2 * layers * kv_heads * head_dim * bytes_per_elem
# 7B급 MHA 구성: 32 레이어, 32 KV 헤드, 헤드 차원 128
print(kv_bytes_per_token(32, 32, 128) / 1024, "KiB") # 512.0 KiB
# 70B급 GQA 구성: 80 레이어, 8 KV 헤드, 헤드 차원 128
print(kv_bytes_per_token(80, 8, 128) / 1024, "KiB") # 320.0 KiB
토큰 하나에 512KiB. 이 숫자가 감이 안 오시면 이렇게 보시면 됩니다. 7B급 MHA 모델에서 컨텍스트 4096 토큰짜리 요청 하나는 KV 캐시로 2GiB를 씁니다. 동시 요청 32개면 64GiB입니다. 가중치 13GiB의 다섯 배입니다.
| 모델 구성 | 레이어 | KV 헤드 | 헤드 차원 | 가중치(fp16) | KV 토큰당 | 4K 컨텍스트 × 32 동시 |
|---|---|---|---|---|---|---|
| 7B, MHA | 32 | 32 | 128 | 13.0 GiB | 512 KiB | 64 GiB |
| 7B, GQA 8 | 32 | 8 | 128 | 13.0 GiB | 128 KiB | 16 GiB |
| 13B, MHA | 40 | 40 | 128 | 24.2 GiB | 800 KiB | 100 GiB |
| 70B, GQA 8 | 80 | 8 | 128 | 130.4 GiB | 320 KiB | 40 GiB |
표에서 읽어야 할 것은 세로가 아니라 가로입니다. 가중치는 고정값이고 마지막 열은 트래픽에 비례합니다. 컨텍스트를 4K에서 32K로 늘리면 마지막 열만 8배가 됩니다. 긴 컨텍스트를 지원한다고 홍보하는 서비스가 실제로는 동시 처리량을 크게 낮춰서 운영하는 이유가 이것입니다.
주의할 점이 하나 있습니다. KV 캐시는 활성 시퀀스 수가 아니라 살아 있는 토큰 총량에 비례합니다. 요청 100개가 각각 500 토큰이면 요청 5개가 각각 10,000 토큰인 것과 같은 메모리를 씁니다. 용량 계획은 초당 요청 수가 아니라 동시 토큰 수로 세워야 합니다.
GQA와 MQA — KV 헤드만 줄이는 구조 변경
위 표에서 7B의 MHA 행과 GQA 행의 차이가 정확히 4배인 이유는 KV 헤드가 32개에서 8개로 줄었기 때문입니다. 질의 헤드는 여전히 32개이고, 4개씩 묶여 하나의 KV 헤드를 공유합니다.
# 질의 헤드는 그대로, KV 헤드만 줄인다
n_heads, head_dim, layers = 32, 128, 32
for kv_heads, name in [(32, "MHA"), (8, "GQA-8"), (1, "MQA")]:
per_token = kv_bytes_per_token(layers, kv_heads, head_dim)
ratio = n_heads // kv_heads
print(f"{name:6} kv_heads={kv_heads:2} {per_token/1024:6.1f} KiB/token ({ratio}배 절감)")
# MHA kv_heads=32 512.0 KiB/token (1배 절감)
# GQA-8 kv_heads= 8 128.0 KiB/token (4배 절감)
# MQA kv_heads= 1 16.0 KiB/token (32배 절감)
절감 비율은 질의 헤드 수를 KV 헤드 수로 나눈 값과 정확히 같습니다. 이건 근사가 아니라 정의상 그렇습니다.
품질 측면에서 GQA는 대체로 저렴한 거래로 평가됩니다. 원 논문은 소수의 KV 헤드로도 MHA에 근접한 품질을 유지한다고 보고했고, 이후 다수의 공개 모델이 8개 안팎의 KV 헤드를 채택했습니다. 반면 MQA는 KV 헤드가 하나뿐이라 품질 저하가 관측된다는 보고가 더 많습니다. 다만 이건 사전학습 단계의 아키텍처 선택이라 이미 배포된 모델을 두고 바꿀 수 있는 값이 아닙니다. 여러분이 고를 수 있는 건 "GQA를 쓰는 모델을 고른다"까지입니다.
모델의 config.json에서 num_key_value_heads가 num_attention_heads보다 작으면 GQA입니다. 계산할 때 이 둘을 헷갈리면 결과가 4배 틀립니다.
활성화, 오버헤드, 그리고 전체 계산기
남은 두 덩어리는 크기가 상황에 따라 달라 정확한 공식을 주기 어렵습니다. 대신 어떤 변수에 비례하는지를 알면 충분합니다.
활성화 메모리는 디코딩 단계에서는 거의 문제가 되지 않습니다. 스텝마다 배치당 토큰 하나씩만 흐르기 때문입니다. 문제는 프리필입니다. 프롬프트 전체가 한 번에 통과하므로 중간 텐서가 배치 곱하기 프롬프트 길이에 비례합니다.
def prefill_activation_gib(batch, prompt_len, hidden, intermediate,
live_buffers=4, bytes_per_elem=2.0):
"""프리필 피크 활성화의 거친 하한. live_buffers는 프레임워크마다 다르니 측정 필요."""
per_token = (hidden + intermediate) * bytes_per_elem
return batch * prompt_len * per_token * live_buffers / GIB
# 7B급: hidden 4096, intermediate 11008
print(round(prefill_activation_gib(8, 4096, 4096, 11008), 2), "GiB") # 3.69 GiB
live_buffers=4는 제가 임의로 잡은 상수입니다. 실제로 동시에 살아 있는 버퍼 수는 커널 퓨전 정도와 메모리 플래너 구현에 따라 달라지므로, 이 값은 반드시 측정으로 교정해야 합니다. 확실한 것은 배치와 프롬프트 길이의 곱에 비례한다는 사실뿐이고, 그래서 청크 단위 프리필(긴 프롬프트를 여러 조각으로 나눠 넣는 방식)이 이 항을 상수로 눌러 줍니다.
프레임워크 오버헤드는 CUDA 컨텍스트, cuBLAS 워크스페이스, 통신 버퍼, 얼로케이터 여유분을 합친 것입니다. 프로세스당 1GiB 안팎을 잡아 두면 대개 맞습니다. 여기에 얼로케이터 단편화를 감안해 전체에 10~15퍼센트 여유를 곱하는 것이 실무적입니다.
이제 전부 합쳐 계산기를 만듭니다.
def total_vram_gib(params_b, weight_dtype, layers, kv_heads, head_dim,
seq_len, batch, kv_bytes=2.0, overhead=1.15, fixed_gib=1.0):
w = weight_gib(params_b, weight_dtype)
kv = kv_bytes_per_token(layers, kv_heads, head_dim, kv_bytes) * seq_len * batch / GIB
return (w + kv) * overhead + fixed_gib
# 70B, 4비트 가중치, GQA-8, 8K 컨텍스트, 동시 16
print(round(total_vram_gib(70, "int4", 80, 8, 128, 8192, 16), 1), "GiB")
# 84.6 GiB → 80GiB 카드 한 장에 안 들어감
가중치는 32.6GiB인데 KV 캐시가 40GiB입니다. 가중치를 4비트로 눌러 봤자 KV 캐시가 더 큽니다. 이 지점을 놓치면 "양자화했는데 왜 여전히 안 되지"라는 상태에 갇힙니다.
실무에서 더 자주 쓰는 건 역방향 계산입니다. 카드가 정해져 있을 때 동시에 몇 개를 받을 수 있는지 묻는 쪽입니다.
def max_concurrent(vram_gib, params_b, weight_dtype, layers, kv_heads, head_dim,
seq_len, util=0.9, kv_bytes=2.0, fixed_gib=1.0):
usable = vram_gib * util - fixed_gib - weight_gib(params_b, weight_dtype)
if usable <= 0:
return 0
per_seq_gib = kv_bytes_per_token(layers, kv_heads, head_dim, kv_bytes) * seq_len / GIB
return int(usable / per_seq_gib)
print(max_concurrent(80, 70, "int4", 80, 8, 128, seq_len=8192)) # 15
print(max_concurrent(80, 70, "int4", 80, 8, 128, seq_len=2048)) # 61
print(max_concurrent(80, 70, "int4", 80, 8, 128, seq_len=8192, kv_bytes=1.0)) # 30
세 줄이 각각 하나의 운영 결정입니다. 컨텍스트 상한을 8K에서 2K로 낮추면 처리량이 4배가 되고, KV 캐시를 fp8로 저장하면 2배가 됩니다. 두 결정 모두 품질에 영향을 주지만, 최소한 얼마를 얻고 얼마를 거는지가 눈에 보입니다.
PagedAttention — 계산이 맞아도 못 쓰는 메모리
지금까지의 계산은 "필요한 만큼만 정확히 쓴다"를 가정했습니다. 초기 서빙 구현들은 그러지 못했습니다. 요청이 들어오면 그 요청이 도달할 수 있는 최대 길이만큼 KV 캐시를 연속된 공간에 미리 잡아 두었기 때문입니다. 최대 길이가 4096인데 실제 출력이 200 토큰이면 나머지는 그냥 버려집니다.
vLLM 논문은 이 낭비를 두 종류로 나눠 설명합니다. 예약해 두고 안 쓰는 내부 단편화와, 블록 사이에 남아 아무도 못 쓰는 외부 단편화입니다. 논문이 측정한 워크로드에서 실제로 유효하게 쓰인 KV 메모리 비율은 상당히 낮았고, 나머지는 이 두 단편화로 사라졌습니다. 구체적 수치는 워크로드 의존적이므로 그대로 인용하기보다 "출력 길이 분산이 클수록 낭비가 커진다"는 구조만 기억하시면 됩니다.
PagedAttention은 이 문제를 운영체제 방식으로 풉니다. KV 캐시를 고정 크기 블록(보통 16 토큰)으로 쪼개고, 논리적으로 연속인 시퀀스를 물리적으로 흩어진 블록에 매핑합니다. 결과적으로 낭비는 시퀀스당 마지막 블록의 빈 자리, 즉 최대 15 토큰으로 제한됩니다.
부수 효과가 하나 더 있는데, 이게 실은 더 큽니다. 블록 단위로 관리하면 여러 시퀀스가 같은 블록을 공유할 수 있습니다. 동일한 시스템 프롬프트를 쓰는 요청 100개는 그 접두사에 해당하는 블록을 한 벌만 두고 참조 카운트로 공유합니다. 접두사가 길고 요청 수가 많은 서비스에서는 이 접두사 공유가 단편화 제거보다 큰 절감을 냅니다.
# vLLM에서 실제로 조정하게 되는 값들
vllm serve <모델경로> \
--max-model-len 8192 \ # 컨텍스트 상한. KV 캐시 상한을 직접 결정
--gpu-memory-utilization 0.90 \ # 전체 VRAM 중 사용 비율. 나머지는 오버헤드용
--kv-cache-dtype fp8 \ # KV 캐시만 8비트로. 용량 절반
--max-num-seqs 64 \ # 동시 시퀀스 상한
--enable-prefix-caching # 공통 접두사 블록 재사용
# 기동 로그에서 이 줄을 확인하세요. 위 계산과 맞아야 합니다.
# "GPU KV cache size: 129,024 tokens"
# "Maximum concurrency for 8192 tokens per request: 15.75x"
기동 로그에 찍히는 KV 캐시 토큰 수가 앞의 max_concurrent 계산과 크게 다르면, 둘 중 하나가 틀린 것입니다. 대개는 KV 헤드 수를 질의 헤드 수로 착각한 경우입니다.
양자화는 공짜가 아닙니다
가중치를 4비트로 줄이면 메모리가 4분의 1이 됩니다. 이건 사실입니다. 문제는 이 문장이 대개 여기서 끝난다는 점입니다.
첫째, 품질 손실은 평균 지표에 잘 안 잡힙니다. 위키텍스트 퍼플렉시티가 0.1 오르는 것으로 요약되는 변화가, 실제로는 긴 출력의 후반부, 코드 생성, 다국어, 형식 준수 같은 꼬리 영역에서 훨씬 크게 나타나는 경우가 흔합니다. 퍼플렉시티는 평균 토큰 예측 난이도이지 작업 성공률이 아닙니다. 양자화 여부를 결정할 때는 반드시 본인 작업의 평가셋으로 재야 합니다. 그 평가셋을 어떻게 만드는지는 감으로 하지 않는 LLM 평가 편에서 따로 다뤘습니다.
둘째, 4비트가 항상 빠른 것은 아닙니다. 가중치 전용 양자화는 저장만 4비트이고 연산은 fp16으로 하므로, 매 행렬곱마다 역양자화가 들어갑니다. 배치가 작아 메모리 대역폭이 병목일 때는 읽을 바이트가 4분의 1이니 확실히 빨라집니다. 반대로 배치가 커서 연산이 병목이 되면 역양자화 오버헤드만 남아 fp16보다 느려질 수 있습니다. "양자화하면 빨라진다"는 배치 1 벤치마크의 이야기입니다.
셋째, KV 캐시 양자화는 가중치 양자화와 별개의 결정이고 대체로 더 안전합니다. fp8 KV 캐시는 손실이 작다는 보고가 많은 반면, 4비트 KV는 긴 컨텍스트에서 열화가 관측된다는 보고가 있습니다. 앞의 계산에서 봤듯 긴 컨텍스트 서비스에서 메모리를 지배하는 건 KV 쪽이므로, 순서를 뒤집어 KV 캐시부터 fp8로 내리고 가중치는 fp16이나 8비트로 두는 조합이 더 나은 경우가 자주 있습니다.
정리하면 선택 순서는 이렇습니다. KV 캐시 dtype을 먼저 보고, 컨텍스트 상한을 다음으로 보고, 가중치 양자화는 마지막에 봅니다. 대부분의 팀이 정반대 순서로 접근하다가 시간을 씁니다.
마치며 — 가중치는 상수, KV 캐시는 변수
기억할 것은 한 줄입니다. 가중치 메모리는 모델을 고른 순간 확정되는 상수이고, KV 캐시는 여러분이 받기로 한 트래픽의 모양이 결정하는 변수입니다. 용량 부족은 거의 항상 변수 쪽에서 옵니다.
그래서 배포 전에 계산할 값은 두 개면 충분합니다. 토큰당 KV 바이트(2 곱하기 레이어 수 곱하기 KV 헤드 수 곱하기 헤드 차원 곱하기 요소 바이트)와, 남은 VRAM을 그 값으로 나눈 총 토큰 수입니다. 이 두 숫자가 있으면 컨텍스트 상한과 동시성 상한 사이의 교환을 회의실에서 바로 계산할 수 있습니다. 그다음에야 양자화를 이야기하시면 됩니다.