상태: 초안 (2026-10-13), 일부 실측. 장난감 모델 측정(3절)은 필자가 노트북 CPU 에서 직접 잰 값입니다. GPU 서버 측정(4·6절)은 같은 사이트의 「토스 ML/AI Infra 준비」 시리즈에서 8GB급 GPU 한 장으로 잰 값을 다시 인용했습니다. 모델 구성은 공식 config 와 논문 표에서 확인했고(2026-10-13 열람), KV 크기는 그 값으로 계산했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.
목차
- 1. 한 토큰씩 만드는 모델
- 2. 캐시가 없으면 걸음마다 처음부터
- 3. 직접 해 보기: numpy 장난감 트랜스포머
- 4. 캐시의 값: 메모리
- 5. 크기를 줄이는 방법: MHA → GQA → MQA → MLA
- 6. 프리필과 디코드: 성격이 다른 두 단계
- 7. 정리: 이 시리즈가 따라갈 질문
- 참고
1. 한 토큰씩 만드는 모델
챗봇의 답은 한 번에 나오지 않습니다. 모델은 지금까지의 글(질문 + 지금까지 만든 답)을 보고 다음 토큰 하나를 고르고, 그 토큰을 글 끝에 붙여 다시 다음 토큰을 고릅니다. 이것을 자기회귀 디코딩이라고 합니다.
토큰 하나를 고를 때 트랜스포머의 각 층은 어텐션을 계산합니다. 토큰마다 벡터 세 개를 만듭니다.
- Q(쿼리): 「내가 지금 무엇을 찾는가」
- K(키): 「나는 이런 내용이다」라는 색인
- V(값): 실제로 넘겨줄 내용
새 토큰의 Q 를 앞 토큰들의 K 와 하나씩 견주어 점수를 내고(얼마나 관련 있나), 그 점수로 V 들을 섞습니다. 식으로 쓰면 softmax(q·Kᵀ/√d)·V 입니다.
여기서 중요한 성질이 하나 있습니다. 언어 모델의 어텐션은 뒤를 보지 못하게 가려져(causal mask) 있습니다. 3번째 토큰의 K·V 는 1~3번째 토큰만 보고 만들어지므로, 뒤에 토큰이 100개 붙어도 바뀌지 않습니다. 그렇다면 한 번 만든 K·V 를 버리고 다시 만들 이유가 없습니다. 저장해 두고 다음 걸음에서 꺼내 쓰면 됩니다. 이것이 KV 캐시입니다.
Q 는 저장하지 않습니다. 앞 토큰의 Q 는 그 토큰을 만들 때 한 번 쓰고 나면 다시 쓸 일이 없기 때문입니다. 새 걸음에 필요한 Q 는 새 토큰의 것 하나뿐입니다.
2. 캐시가 없으면 걸음마다 처음부터
캐시가 없으면 t번째 걸음에서 t개 토큰 전부의 K·V 를 다시 만듭니다. 층마다 하는 선형 투영(Q·K·V·출력)과 MLP 는 토큰 수에 비례하므로, 한 걸음의 비용이 t 에 비례하고 n 걸음을 모으면 1+2+…+n ≈ n²/2 입니다. 캐시가 있으면 걸음마다 새 토큰 하나만 투영하고 MLP 에 넣으므로 n 걸음에 n 입니다.
어텐션 점수 계산은 두 방식 모두 남습니다. 캐시가 있어도 새 토큰의 Q 는 앞의 모든 K 와 견주어야 하므로 한 걸음에 t 만큼 듭니다(n 걸음 합 n²/2). 캐시가 없으면 이것마저 걸음마다 t² 이라 합이 n³/3 으로 커집니다. 문맥이 아주 길지 않은 동안은 투영과 MLP 가 계산의 대부분이라, 실제로 체감하는 차이는 「걸음마다 t 대 1」입니다.
3. 직접 해 보기: numpy 장난감 트랜스포머
말로는 쉬운데 체감이 어려워서, 가중치를 난수로 채운 작은 트랜스포머를 numpy 로 만들어 두 방식을 재 보았습니다. 학습한 모델이 아니므로 출력 토큰에 뜻은 없지만, 계산의 모양(투영·어텐션·MLP·정규화)은 진짜와 같습니다.
- 모델: 2층, 모델 차원 128, 헤드 8개(헤드 차원 16), MLP 512, 어휘 512, float32
- 환경: 노트북 CPU, Python 3.9, numpy 2.0.2, 단일 프로세스
- 방법: 난수 프롬프트 32토큰에서 탐욕 디코딩으로 끝까지 생성. 같은 조건을 세 번 돌려 중앙값. 행렬 곱의 연산량(곱셈·덧셈을 2로 셈)은 코드가 직접 셌습니다.
- 코드와 원자료: 저장소
docs/experiments/2026-10-13-numpy-kv-cache-toy/(toy_kv_cache.py,result-20261013.txt)
캐시가 있는 쪽의 핵심은 몇 줄입니다. 이번 토큰의 K·V 를 미리 잡아 둔 배열 끝에 써 넣고, 어텐션은 지금까지 쌓인 전부를 봅니다(헤드별로 나누는 reshape 는 줄였습니다).
k = mm(h, W["k"]) # 새 K
v = mm(h, W["v"]) # 새 V
n = cache["n"]
cache["k"][:, n:n+T] = k # 끝에 붙임
cache["v"][:, n:n+T] = v
K = cache["k"][:, :n+T] # 지난 것 전부
V = cache["v"][:, :n+T]
a = attend(q, K, V)
결과 1. 출력은 같고, 연산량과 시간은 크게 다르다
연산량(GFLOP, 코드가 센 값):
| 전체 길이 | 캐시 없음 | 캐시 있음 | 배율 |
|---|---|---|---|
| 128 | 6.71 | 0.121 | 55배 |
| 256 | 31.0 | 0.264 | 118배 |
| 512 | 148.2 | 0.599 | 247배 |
시간(초, 세 번 중앙값):
| 전체 길이 | 캐시 없음 | 캐시 있음 | 배율 |
|---|---|---|---|
| 128 | 0.205 | 0.084 | 2.5배 |
| 256 | 1.16 | 0.131 | 8.9배 |
| 512 | 8.01 | 0.485 | 16.5배 |
- 세 길이 모두 두 방식의 토큰이 끝까지 같았고, 로짓의 최대 차이는 1.5×10⁻⁶ 으로 float32 반올림 수준이었습니다. 캐시는 근사가 아니라 같은 계산을 다시 하지 않는 것입니다.
- 길이를 두 배로 늘릴 때마다 캐시 없는 쪽의 연산량은 약 4.6~4.8배가 되었고(n² 에 어텐션의 n³ 이 조금 섞임), 캐시 있는 쪽은 약 2.2배였습니다.
- 시간 배율이 연산량 배율보다 작은 것은 캐시 쪽 한 걸음이 아주 작은 행렬 곱이라 파이썬 호출 비용이 크게 섞이기 때문입니다. 길이 128 은 전체가 0.1초 안이라 흔들림도 큽니다(짧은 확인 실행에서는 4.5배였습니다).
결과 2. 캐시가 없으면 한 걸음이 문맥에 비례해 느려진다. 512토큰 실행에서 64토큰 구간마다 한 걸음 시간의 중앙값을 보면, 캐시 없는 쪽은 1.25ms 에서 35.1ms 로 늘었고 캐시 있는 쪽은 0.43ms 에서 0.60ms 로 조금만 늘었습니다. 캐시가 있어도 조금씩 느는 것은 2절에서 말한 어텐션 점수 계산(한 걸음에 t) 때문입니다.
결과 3. 캐시 배열의 크기는 공식과 바이트까지 같다. 1,024토큰을 프리필한 뒤 캐시 배열의 nbytes 를 더했습니다.
쿼리 헤드는 셋 다 8개이고, 공식 값은 2 × 층(2) × KV 헤드 × 헤드 차원(16) × 토큰(1,024) × 4바이트입니다.
| 방식 | KV 헤드 | 실제 바이트 | 공식 값 |
|---|---|---|---|
| MHA | 8 | 2,097,152 | 2,097,152 |
| GQA | 2 | 524,288 | 524,288 |
| MQA | 1 | 262,144 | 262,144 |
세 방식 모두 캐시를 쓴 마지막 로짓이 캐시 없이 처음부터 계산한 값과 같았습니다(허용 오차 10⁻³).
4. 캐시의 값: 메모리
캐시는 계산을 메모리로 바꾼 것입니다. 토큰 하나가 남기는 KV 는 층마다 K 와 V 한 벌씩이므로 다음과 같습니다.
KV 바이트/토큰 = 2 × 층 수 × KV 헤드 수 × 헤드 차원 × 자료형 바이트
이 식이 실제 서버와 맞는지는 다른 시리즈에서 확인했습니다. Qwen3-1.7B(28층, KV 헤드 8, 헤드 차원 128, bf16)는 식으로 토큰당 114,688바이트이고, vLLM 서버 로그의 KV 캐시 메모리(2.36GiB)를 토큰 수(22,048)로 나눈 값이 약 114,900바이트로 0.2% 안에서 맞았습니다(8GB급 GPU, vLLM 0.31.0, 실측). KV 를 8비트(fp8)로 두면 같은 메모리에 41,920토큰이 들어가 정확히 두 배 가까이 되었습니다.
잘 알려진 공개 모델에 넣어 보면 다음과 같습니다. 층·헤드 수는 공식 자료에서 확인했고, 크기는 KV 를 bf16(2바이트)으로 둔다고 보고 계산했습니다. 아래 모델은 헤드 차원이 모두 128입니다. 「32K」는 32,768토큰 요청 하나의 KV 입니다.
| 모델 | 층 × KV 헤드 | 토큰당 KV | 32K |
|---|---|---|---|
| Qwen2.5-1.5B | 28 × 2 | 28KiB | 0.88GiB |
| Qwen2.5-7B | 28 × 4 | 56KiB | 1.75GiB |
| Llama 3.1 8B | 32 × 8 | 128KiB | 4GiB |
| Mistral-7B | 32 × 8 | 128KiB | 4GiB |
| Qwen3-8B | 36 × 8 | 144KiB | 4.5GiB |
| Llama 3.1 70B | 80 × 8 | 320KiB | 10GiB |
| Llama 3.1 405B | 126 × 8 | 504KiB | 15.75GiB |
| 70B 가 MHA 였다면(가정) | 80 × 64 | 2.5MiB | 80GiB |
| DeepSeek-V2(MLA) | 60층, 잠재 576 | 67.5KiB | 2.11GiB |
쿼리 헤드 수는 Qwen2.5-1.5B 12, Qwen2.5-7B 28, Llama 3.1 8B·Mistral-7B·Qwen3-8B 32, 70B 64, 405B 128 입니다. KV 헤드가 쿼리 헤드보다 적은 것이 5절의 GQA 입니다.
- Llama 3.1 은 Llama 3 논문의 모델 구성 표(8B·70B·405B 모두 KV 헤드 8)와 Meta 공식 저장소의 구성 값으로 확인했습니다. Hugging Face 의 Llama config 는 로그인이 필요해 열지 못했고, 헤드 차원 128 은 모델 차원 ÷ 헤드 수로 계산했습니다. Qwen·Mistral 은 공개
config.json에서 확인했습니다. - Llama 3.1 은 128K 문맥을 지원합니다. 70B 에 128K 토큰을 다 채우면 KV 만 40GiB 입니다(계산). 가중치가 bf16 으로 약 140GB 인 모델 옆에서, 긴 요청 몇 개가 GPU 메모리를 금방 차지합니다.
- DeepSeek-V2 는 K·V 대신 압축한 잠재 벡터(층마다 512원소)와 위치용 키(64원소)를 저장합니다. 논문은 토큰당 KV 를 원소 수 (512 + 64) × 60층 = 34,560 으로 셉니다. 67.5KiB 는 이것을 bf16 으로 두었다고 보고 곱한 값입니다(계산). 같은 헤드 구성(헤드 128개, 헤드 차원 128)을 MHA 로 두었다면 토큰당 1,966,080원소이니 약 57분의 1입니다(계산).
5. 크기를 줄이는 방법: MHA → GQA → MQA → MLA
식에서 줄일 수 있는 것은 KV 헤드 수(또는 그것을 대신하는 무엇)와 바이트입니다. 층 수와 헤드 차원은 모델의 능력과 직결되어 함부로 줄이지 못합니다.
- MHA(멀티헤드): 쿼리 헤드마다 K·V 를 따로 둡니다. 원조 트랜스포머(2017)의 방식입니다.
- MQA(멀티쿼리, Shazeer 2019): 모든 쿼리 헤드가 K·V 한 벌을 나눠 씁니다. 논문의 번역 모델 실험(파라미터 약 2억, TPUv2)에서 출력 토큰당 디코더 시간이 46µs 에서 3.8µs 로 줄었고, BLEU 는 27.7 에서 27.5(탐욕)로 조금 떨어졌습니다.
- GQA(그룹 쿼리, Ainslie 외 2023): 둘의 중간입니다. 쿼리 헤드를 몇 그룹으로 묶고 그룹마다 K·V 한 벌을 둡니다. 이미 MHA 로 학습한 모델을 원래 학습량의 5% 만 더 학습해 바꿀 수 있다고 보였고, T5-XXL 실험에서 GQA-8 은 샘플당 0.28초(MHA 1.51초, MQA 0.24초)에 평균 점수 47.1(MHA 47.2, MQA 46.6)이었습니다. Llama 3 계열이 쓰는 방식이 이것입니다(KV 헤드 8).
- MLA(DeepSeek-V2, 2024): 헤드를 줄이는 대신 K·V 를 작은 잠재 벡터로 압축해 저장하고, 쓸 때 다시 펼칩니다. 논문은 DeepSeek 67B 와 비교해 KV 캐시를 93.3% 줄이고 최대 생성 처리량을 5.76배로 올렸다고 보고합니다(H800 8장 한 노드, 배포 때 가중치 FP8·KV 약 6비트 양자화 포함한 조건). 93.3% 의 정확한 계산 기준은 본문에서 찾지 못했습니다(미확인).
- 바이트를 줄이기: KV 를 8비트로 두면 절반입니다. 위 vLLM 실측에서 fp8 KV 는 토큰 수를 1.9배로 늘렸습니다. 품질에 주는 영향은 모델과 과제마다 따로 재야 합니다.
왜 이렇게까지 줄이려 할까요. 6절이 그 답입니다.
6. 프리필과 디코드: 성격이 다른 두 단계
요청 하나는 두 단계를 지납니다.
- 프리필(prefill): 프롬프트 전체를 한 번에 넣어 모든 토큰의 K·V 를 만들고 캐시에 씁니다. 첫 토큰이 여기서 나옵니다. 첫 토큰까지의 시간(TTFT)을 정합니다.
- 디코드(decode): 그 뒤로 한 토큰씩 만듭니다. 토큰 사이 간격(ITL·TPOT)을 정합니다.
둘은 같은 모델을 쓰지만 하드웨어에서 보이는 모습이 다릅니다.
- 프리필은 계산이 병목입니다. 가중치를 한 번 읽어 와서 프롬프트 토큰 수천 개에 한꺼번에 씁니다. 읽은 바이트당 하는 계산이 많아 GPU 의 계산기가 바쁩니다.
- 디코드는 메모리 대역폭이 병목입니다. 토큰 하나를 만들려고 가중치 전체와 그 요청의 KV 전부를 메모리에서 읽어 와야 하는데, 그것으로 하는 계산은 토큰 하나 분량뿐입니다. bf16 가중치는 파라미터당 2바이트이고 토큰 하나에 파라미터당 약 2 FLOP 을 하므로, 요청 하나만 디코드하면 읽은 바이트당 약 1 FLOP 입니다(계산). 요즘 데이터센터 GPU 는 바이트를 하나 읽는 동안 수백 FLOP 을 할 수 있으니 계산기는 대부분 놉니다.
장난감 모델에서도 차이가 보였습니다(캐시 있음, 세 번 중앙값).
| 프롬프트 | 프리필 토큰당 | 디코드 한 걸음 | 배율 |
|---|---|---|---|
| 64 | 0.018ms | 0.445ms | 24배 |
| 256 | 0.034ms | 0.506ms | 15배 |
| 1,024 | 0.144ms | 0.852ms | 6배 |
프리필 전체 시간은 64토큰 1.16ms, 256토큰 8.76ms, 1,024토큰 146.9ms 였습니다.
토큰 하나를 디코드하는 데 프리필의 토큰당 시간보다 6~24배가 들었습니다. CPU 의 numpy 라 파이썬 호출 비용이 섞여 있고, 1,024토큰 프리필은 어텐션의 제곱 항이 커져 토큰당 비용이 올라갑니다. 그래서 배율 자체보다 「한 번에 몰아 하는 쪽이 토큰당 훨씬 싸다」는 방향을 보시면 됩니다.
GPU 에서는 이 차이가 더 큽니다. 다른 시리즈의 실측(8GB급 GPU, Qwen3-1.7B, vLLM)에서 입력 처리 속도는 약 8,200토큰/초였고, 요청 하나를 디코드할 때는 토큰 간격이 15ms(약 66토큰/초)였습니다. 토큰당 120배 넘게 차이가 납니다.
디코드가 메모리에 묶여 있다면 처리량을 올리는 길은 하나입니다. 가중치를 한 번 읽을 때 여러 요청을 같이 디코드하는 것, 즉 배치를 키우는 것입니다. 같은 실측에서 동시 요청을 1에서 8로 늘리자 처리량이 66에서 383토큰/초(5.8배), 32에서 831토큰/초로 늘었습니다. 그런데 배치에 든 요청마다 자기 KV 가 GPU 메모리에 있어야 합니다. 입력 512 + 출력 128 토큰이면 요청 하나가 640토큰이고, 그 GPU 의 KV 자리(22,048토큰)에는 약 34개가 들어갑니다. 동시 64개를 보내자 처리량은 늘지 않고 첫 토큰 시간이 5.7초로 뛰었습니다. 처리량의 상한을 KV 메모리가 정한 것입니다.
7. 정리: 이 시리즈가 따라갈 질문
| 사실 | 그래서 생기는 질문 | 다루는 편 |
|---|---|---|
| KV 는 바뀌지 않으니 저장해 두고 쓴다 | — | 1편 |
| 디코드는 배치가 클수록 싸다 | KV 메모리를 낭비 없이 써서 배치를 키우려면? | 2편(PagedAttention, 연속 배칭) |
| 같은 앞부분의 KV 는 같다 | 다른 요청의 KV 를 재사용하려면? | 2편(프리픽스 캐시), 3편 |
| GPU 메모리는 작다 | KV 를 CPU·디스크·다른 서버에 두고 불러오면? | 3편(LMCache) |
| 프리필과 디코드는 성격이 다르다 | 둘을 다른 GPU 에 나누면? 이미 KV 가 있는 곳으로 보내면? | 4편(Dynamo) |
티티 한마디. 탁구 경기에서 점수를 매번 처음 서브부터 다시 세는 심판은 없어요. 점수판에 적어 두고 한 점씩 더하죠. KV 캐시가 바로 그 점수판이에요. 다만 경기장이 수백 개면, 점수판 놓을 자리부터 모자라기 시작해요.
참고
- Vaswani 외, Attention Is All You Need: https://arxiv.org/abs/1706.03762
- Shazeer, Fast Transformer Decoding: One Write-Head is All You Need (MQA): https://arxiv.org/abs/1911.02150 (표 1·2)
- Ainslie 외, GQA: https://arxiv.org/abs/2305.13245 (v3, 3절·표 1)
- DeepSeek-AI, DeepSeek-V2: https://arxiv.org/abs/2405.04434 (v5, 초록, 2.1절 표 1, 3.2.3절)
- Llama 3 논문 모델 구성 표: https://arxiv.org/abs/2407.21783 (3.2절 표 3), Meta 구성 값: https://github.com/meta-llama/llama-models (
models/sku_list.py) - 공개 config: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/raw/main/config.json, https://huggingface.co/Qwen/Qwen3-8B/raw/main/config.json, https://huggingface.co/mistralai/Mistral-7B-v0.1/raw/main/config.json, https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/raw/main/config.json (모두 2026-10-13 열람)
- 장난감 모델 코드와 원자료: 저장소
docs/experiments/2026-10-13-numpy-kv-cache-toy/ - GPU 실측 인용: 같은 사이트 「토스 ML/AI Infra 준비」 14편(vLLM 부하·KV 용량·접두사 캐시·청크)