Skip to content

Split View: LLM 추론 VRAM 계산법 — 가중치보다 KV 캐시가 먼저 터집니다

✨ Learn with Quiz
|

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

들어가며 — "이 모델, 우리 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, MHA323212813.0 GiB512 KiB64 GiB
7B, GQA 832812813.0 GiB128 KiB16 GiB
13B, MHA404012824.2 GiB800 KiB100 GiB
70B, GQA 8808128130.4 GiB320 KiB40 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_headsnum_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을 그 값으로 나눈 총 토큰 수입니다. 이 두 숫자가 있으면 컨텍스트 상한과 동시성 상한 사이의 교환을 회의실에서 바로 계산할 수 있습니다. 그다음에야 양자화를 이야기하시면 됩니다.

LLM Inference VRAM Math — the KV Cache Blows Up Before the Weights Do

Introduction — "Will this model fit on our GPU"

Answering that question with "we will have to try it and see" leaves you with nothing to say in an infrastructure budget meeting. Fortunately, inference memory — unlike training memory — is almost entirely predictable with arithmetic. There are no optimizer states and no gradients.

The trouble is that most of these calculations stop halfway. "A 7B model is 14GB in fp16, so it fits comfortably on a 24GB card" is correct as far as it goes. Then you raise the batch size to 32 and the context to four thousand tokens, and the process dies out of memory. The missing term is the KV cache, and unlike the weights, that term grows with the shape of your user traffic.

This post splits inference VRAM into four chunks (weights, KV cache, activations, framework overhead) and shows how to compute each one. Every number is presented as a formula you can check yourself, so read it with your own model config file open.

Weight memory — the part that ends with one multiplication

Weights are honest. Multiply the parameter count by bytes per parameter and you are done.

BYTES_PER_PARAM = {
    "fp32": 4.0,
    "fp16": 2.0,   # same for bf16
    "int8": 1.0,
    "fp8": 1.0,
    "int4": 0.5,   # 4-bit; in practice scales/zero-points add a little more
}

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]

A word about units before we go further. Seven billion times 2 bytes is 14 billion bytes; divide by 10 to the ninth and you get 14GB, divide by 2 to the thirtieth and you get 13.0GiB. The "80GB" on a card spec sheet is usually decimal, while the number nvidia-smi shows you is binary, and that seven percent gap is why a calculation that looked tight sometimes fails to add up. Everything below is in GiB.

The 4-bit row is an idealized number. A real 4-bit checkpoint carries an fp16 scale and zero-point per group, so with a group size of 128 you are paying roughly an extra 0.5 bit per parameter. Budgeting about 0.53 bytes per parameter rather than 0.5 is the safer move. Many implementations also leave the embedding and output layers unquantized, so the gap widens as the vocabulary grows.

KV cache — where it actually blows up

Autoregressive decoding computes attention over every previous token each time it produces one more. Throwing away the keys and values you already computed would mean recomputing the whole sequence at every step, so you keep all of them. That is the KV cache.

Here is what a single token costs.

def kv_bytes_per_token(layers: int, kv_heads: int, head_dim: int,
                       bytes_per_elem: float = 2.0) -> float:
    # the 2 is because K and V are both stored
    return 2 * layers * kv_heads * head_dim * bytes_per_elem

# 7B-class MHA config: 32 layers, 32 KV heads, head dim 128
print(kv_bytes_per_token(32, 32, 128) / 1024, "KiB")   # 512.0 KiB
# 70B-class GQA config: 80 layers, 8 KV heads, head dim 128
print(kv_bytes_per_token(80, 8, 128) / 1024, "KiB")    # 320.0 KiB

512KiB per token. If that number does not land, look at it this way. On a 7B-class MHA model, one request with a 4096-token context spends 2GiB on KV cache. Thirty-two concurrent requests is 64GiB. Five times the 13GiB of weights.

Model configLayersKV headsHead dimWeights (fp16)KV per token4K context × 32 concurrent
7B, MHA323212813.0 GiB512 KiB64 GiB
7B, GQA 832812813.0 GiB128 KiB16 GiB
13B, MHA404012824.2 GiB800 KiB100 GiB
70B, GQA 8808128130.4 GiB320 KiB40 GiB

Read this table across, not down. The weights column is a constant and the last column is proportional to traffic. Stretch the context from 4K to 32K and only the last column changes, by a factor of eight. That is why services that advertise long context quietly run with far lower concurrency.

One caution. The KV cache is proportional to the total number of live tokens, not to the number of active sequences. A hundred requests of 500 tokens each cost the same memory as five requests of 10,000 tokens each. Capacity planning has to be done in concurrent tokens, not requests per second.

GQA and MQA — a structural change that only shrinks KV heads

The 7B MHA row and the 7B GQA row differ by exactly four times because the KV heads dropped from 32 to 8. There are still 32 query heads; they are grouped four at a time and share one KV head.

# query heads stay the same, only KV heads shrink
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배 절감)

The saving ratio equals the query head count divided by the KV head count, exactly. This is not an approximation; it follows from the definition.

On quality, GQA is generally regarded as a cheap trade. The original paper reported quality close to MHA with only a handful of KV heads, and many open models since have settled on around eight. MQA, with a single KV head, has drawn more reports of measurable degradation. That said, this is a pretraining-time architecture choice, not a knob you can turn on an already-published model. What you get to choose is "pick a model that uses GQA."

If num_key_value_heads is smaller than num_attention_heads in the model config.json, it is GQA. Confuse those two while calculating and your answer is off by four times.

Activations, overhead, and the full calculator

The two remaining chunks vary enough with circumstance that an exact formula is hard to give. Knowing what they scale with is enough.

Activation memory is barely an issue during decoding, since only one token per batch element flows through per step. Prefill is the problem. The whole prompt passes through at once, so intermediate tensors scale with batch times prompt length.

def prefill_activation_gib(batch, prompt_len, hidden, intermediate,
                           live_buffers=4, bytes_per_elem=2.0):
    """A crude lower bound on prefill peak activations. live_buffers varies by framework, so measure it."""
    per_token = (hidden + intermediate) * bytes_per_elem
    return batch * prompt_len * per_token * live_buffers / GIB

# 7B class: hidden 4096, intermediate 11008
print(round(prefill_activation_gib(8, 4096, 4096, 11008), 2), "GiB")  # 3.69 GiB

live_buffers=4 is a constant I made up. The number of buffers actually alive at once depends on how aggressively kernels are fused and how the memory planner is implemented, so this value has to be corrected by measurement. The only thing that is certain is that it scales with batch times prompt length — which is why chunked prefill (feeding a long prompt in several pieces) flattens this term to a constant.

Framework overhead is the CUDA context, cuBLAS workspaces, communication buffers and allocator slack added together. Budgeting around 1GiB per process is usually right. On top of that, multiplying the total by 10 to 15 percent for allocator fragmentation is the practical move.

Now put it all together into a calculator.

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-bit weights, GQA-8, 8K context, 16 concurrent
print(round(total_vram_gib(70, "int4", 80, 8, 128, 8192, 16), 1), "GiB")
# 84.6 GiB  → does not fit on a single 80GiB card

The weights are 32.6GiB and the KV cache is 40GiB. Squeeze the weights down to 4-bit and the KV cache is still larger. Miss that point and you get stuck in the "we quantized it, why does it still not fit" state.

The calculation people actually use in practice runs the other way. Given a fixed card, how many requests can you take at once?

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

Each of those three lines is an operational decision. Lowering the context ceiling from 8K to 2K quadruples throughput; storing the KV cache in fp8 doubles it. Both decisions affect quality, but at least you can see what you gain and what you are wagering.

PagedAttention — memory you cannot use even when the math is right

Everything so far assumed "we use exactly as much as we need." Early serving implementations did not. When a request arrived, they reserved a contiguous KV cache region large enough for the longest output that request could reach. If the ceiling was 4096 and the actual output was 200 tokens, the rest was simply thrown away.

The vLLM paper splits this waste into two kinds: internal fragmentation, reserved and never used, and external fragmentation, stranded between blocks where nobody can reach it. On the workloads the paper measured, the share of KV memory that was effectively used came out quite low, with the rest lost to those two. The specific figures are workload-dependent, so rather than quoting them, remember the structure: the wider the spread of output lengths, the bigger the waste.

PagedAttention solves this the way an operating system would. It splits the KV cache into fixed-size blocks (typically 16 tokens) and maps a logically contiguous sequence onto physically scattered blocks. Waste is then bounded by the empty tail of the last block per sequence — at most 15 tokens.

There is a side effect, and it is in fact the bigger one. Managing memory in blocks lets several sequences share the same block. A hundred requests using the same system prompt keep a single copy of the blocks for that prefix and share it by reference count. In services with long prefixes and high request volume, that prefix sharing saves more than the defragmentation does.

# the knobs you actually end up tuning in vLLM
vllm serve <모델경로> \
  --max-model-len 8192 \            # context ceiling; sets the KV cache ceiling directly
  --gpu-memory-utilization 0.90 \   # share of total VRAM to use; the rest is for overhead
  --kv-cache-dtype fp8 \            # KV cache only, in 8-bit; halves the capacity cost
  --max-num-seqs 64 \               # concurrent sequence ceiling
  --enable-prefix-caching           # reuse shared prefix blocks

# check these lines in the startup log. They should agree with the math above.
#   "GPU KV cache size: 129,024 tokens"
#   "Maximum concurrency for 8192 tokens per request: 15.75x"

If the KV cache token count in the startup log differs sharply from your max_concurrent calculation, one of the two is wrong. Usually it is the KV head count being mistaken for the query head count.

Quantization is not free

Drop the weights to 4-bit and memory falls to a quarter. That is true. The problem is that the sentence usually stops there.

First, quality loss hides from average metrics. A change summarized as "wikitext perplexity rose by 0.1" often shows up far larger in the tails — the back half of long outputs, code generation, multilingual work, format compliance. Perplexity is average token prediction difficulty, not task success rate. When deciding whether to quantize, you have to measure on an evaluation set for your own task. How to build that set is covered separately in LLM evaluation without the vibes.

Second, 4-bit is not always faster. Weight-only quantization stores in 4-bit but computes in fp16, so every matrix multiply carries a dequantization step. When the batch is small and memory bandwidth is the bottleneck, reading a quarter as many bytes is a clear win. When the batch is large and compute becomes the bottleneck, all that is left is the dequantization overhead, and it can end up slower than fp16. "Quantization makes it faster" is a batch-size-1 benchmark statement.

Third, KV cache quantization is a separate decision from weight quantization, and it is generally safer. There are many reports that fp8 KV cache costs little, while 4-bit KV has been reported to degrade at long context. As the math above showed, memory in a long-context service is dominated by the KV side, so flipping the order — take the KV cache down to fp8 first and leave the weights at fp16 or 8-bit — is frequently the better combination.

So the order of choices is this. Look at the KV cache dtype first, the context ceiling second, and weight quantization last. Most teams approach it in exactly the opposite order and lose time.

Closing — weights are a constant, the KV cache is a variable

There is one line to remember. Weight memory is a constant fixed the moment you pick a model, and the KV cache is a variable determined by the shape of the traffic you agreed to take. Capacity shortfalls almost always come from the variable.

So two numbers are enough to compute before deployment: KV bytes per token (2 times layers times KV heads times head dim times bytes per element) and the total token count you get by dividing your remaining VRAM by that value. With those two, you can work out the trade between context ceiling and concurrency ceiling live in the meeting room. Only after that is it time to talk about quantization.