검색
← 모듈 로드맵

직접 해 보기: 양자화 오차와 KV 캐시 메모리 계산하기

강의 · 준전문가 · KAITABASE 정리

원제 · Hands-on: Quantization Error and KV-Cache Memory

넘파이로 INT8 · INT4 양자화 출력 오차를 재고 가중치 · 학습 · KV 캐시 메모리 계산기를 만드는 실습

먼저 알아야 할 것

absmax 양자화KV 캐시 공식학습 메모리 16바이트 규칙파이썬 · 넘파이 기초

다루는 내용

가짜 가중치 행렬을 INT8 · INT4로 양자화해 층 출력 오차를 재고, 이상치 몇 개가 전체 스케일을 망치며 64개 묶음 스케일이 이를 회복하는 것을 확인합니다. 모델 크기 · 비트 · 문맥 길이로 가중치 · 학습 상태 · KV 캐시 메모리를 계산하고, 디코드 지연 하한과 추측 디코딩 기대 토큰 수를 표로 뽑습니다. 코드는 실제로 실행해 출력을 실었습니다.

해설

직접 해 보기: 양자화 오차와 KV 캐시 메모리 계산하기

앞 수업에서 손으로 했던 계산을 파이썬 하나로 반복합니다. ① 가중치 행렬을 INT8 · INT4로 양자화해 출력이 얼마나 틀어지는지 재고, 이상치와 묶음(그룹) 스케일의 효과를 확인합니다. ② 모델 크기 · 비트 수 · 문맥 길이를 넣으면 가중치 · 학습 상태 · KV 캐시 메모리 를 계산하는 작은 계산기를 만듭니다. ③ 디코드 지연의 이론 하한과 추측 디코딩의 기대 토큰 수를 표로 뽑습니다. 넘파이만 있으면 노트북에서 1초 안에 끝납니다.

항목내용
준비물파이썬 3 · 넘파이(pip install numpy). GPU 필요 없음
트랙 · 수준스케일링·효율 · 중급 · 실습
걸리는 시간30~40분(코드 읽기 · 실행 · 바꿔 보기)
실행 확인2026-10-22, 파이썬 3.14 · 넘파이 2.5.1(Windows)에서 아래 코드를 그대로 실행해 아래 출력을 얻었습니다

들어가기 전에

한눈에 보기

  • 양자화 오차를 '가중치 오차'가 아니라 출력 오차 로 재는 방법을 익힙니다.
  • 이상치 몇 개가 행렬 전체 스케일을 망치고, 묶음 스케일이 이를 살린다는 것을 숫자로 봅니다.
  • 메모리 계산기를 손에 쥐고, 내 GPU에 어떤 모델이 들어가는지 스스로 판단할 수 있게 됩니다.

1. 실험 설계

무엇을 재나. 가중치 WW 를 양자화한 WqW_q 로 바꿨을 때, 같은 입력 XX 에 대한 출력의 상대 오차를 잽니다.

출력 오차=∥XW−XWq∥∥XW∥\text{출력 오차}=\frac{\lVert XW - XW_q\rVert}{\lVert XW\rVert}
기호뜻
WW원래 가중치(256×256, 평균 0 · 표준편차 0.02 정규분포로 만든 가짜 가중치)
WqW_q양자화했다가 되돌린 가중치
XX입력 8개(256차원, 표준 정규분포)
∥⋅∥\lVert\cdot\rVert행렬 원소 제곱합의 제곱근(프로베니우스 노름)

왜 출력 오차인가. 실제로 중요한 것은 가중치가 조금 틀린 것 자체가 아니라, 그 때문에 층의 출력이 얼마나 바뀌는가 입니다. GPTQ 같은 방법도 층 출력 오차를 줄이는 것을 목표로 삼습니다.

비교 조건 4가지 × 2종 가중치.

  1. INT8 / INT4
  2. 스케일을 행렬 전체에 1개 / 64개씩 묶음마다 1개
  3. 보통 가중치 / 값 1.0짜리 이상치 4개를 심은 가중치(보통 값의 약 50배)

2. 코드 전체

아래를 quant_kv_lab.py 로 저장하고 python quant_kv_lab.py 로 실행합니다. Windows 터미널에서 한글이 깨지거나 인코딩 오류가 나면 python -X utf8 quant_kv_lab.py 로 실행하세요.

python
import numpy as np

# ---------- 1부: 양자화 오차 실험 ----------
def quantize_absmax(w, bits, group=None):
    """대칭(absmax) 양자화 후 바로 되돌린 값을 돌려준다. group=None이면 행렬 전체에 스케일 1개."""
    qmax = 2 ** (bits - 1) - 1              # 8비트 127, 4비트 7
    flat = w.reshape(-1) if group is None else w.reshape(-1, group)
    amax = np.abs(flat).max(axis=-1, keepdims=True)
    scale = qmax / np.maximum(amax, 1e-12)
    q = np.clip(np.round(flat * scale), -qmax, qmax)
    return (q / scale).reshape(w.shape)

def rel_err(a, b):
    return np.linalg.norm(a - b) / np.linalg.norm(a)

rng = np.random.default_rng(0)
W = rng.normal(0, 0.02, size=(256, 256))     # 보통 가중치
X = rng.normal(0, 1.0, size=(8, 256))        # 입력 8개
W_out = W.copy()
W_out[rng.integers(0, 256, 4), rng.integers(0, 256, 4)] = 1.0   # 이상치 4개 심기

print("== 1부: 양자화 오차 (출력 상대 오차 = ||XW - XWq|| / ||XW||) ==")
for name, M in [("보통 가중치", W), ("이상치 4개", W_out)]:
    ref = X @ M
    for bits in (8, 4):
        for group in (None, 64):
            Mq = quantize_absmax(M, bits, group)
            tag = "전체 1개 스케일" if group is None else f"{group}개 묶음 스케일"
            print(f"{name:7s} | INT{bits} | {tag:12s} | 출력 오차 {rel_err(ref, X @ Mq):.4f}")

# ---------- 2부: 메모리 계산기 ----------
GB = 1e9
def weight_mem(n_params, bits):           return n_params * bits / 8 / GB
def train_state_mem(n_params, bytes_per=16):  return n_params * bytes_per / GB
def kv_cache_mem(n_layer, n_kv, d_head, seq, batch, bytes_per=2):
    return 2 * n_layer * n_kv * d_head * seq * batch * bytes_per / GB

print("\n== 2부: 메모리 계산기 ==")
for n in (7e9, 13e9, 70e9):
    print(f"{n/1e9:>4.0f}B | FP16 {weight_mem(n,16):6.1f} GB | INT8 {weight_mem(n,8):6.1f} GB"
          f" | INT4 {weight_mem(n,4):6.1f} GB | 학습 상태(16B/파라미터) {train_state_mem(n):7.1f} GB")

# LLaMA 65B 구성(층 80, 헤드 64, 헤드 차원 128)으로 KV 캐시
for n_kv, label in ((64, "MHA"), (8, "GQA 8그룹(가정)"), (1, "MQA")):
    print(f"KV 캐시 {label:14s} | 길이 2048 · 배치 1 : {kv_cache_mem(80, n_kv, 128, 2048, 1):6.2f} GB"
          f" | 배치 32 : {kv_cache_mem(80, n_kv, 128, 2048, 32):7.2f} GB")

# ---------- 3부: 디코드 지연 하한 · 추측 디코딩 ----------
print("\n== 3부: 디코드 지연 하한과 추측 디코딩 ==")
bw = 2.039e12   # A100 80GB SXM 메모리 대역폭(바이트/초)
for n, bits in ((7e9, 16), (7e9, 4), (70e9, 16)):
    print(f"{n/1e9:.0f}B · {bits}비트 가중치 1회 읽기 = 토큰당 최소 {n*bits/8/bw*1e3:6.2f} ms")
for a in (0.6, 0.8, 0.9):
    row = " ".join(f"γ={g}:{(1-a**(g+1))/(1-a):.2f}" for g in (2, 4, 8))
    print(f"수락률 α={a} → 기대 토큰 수 {row}")

코드 읽기 요점.

  • qmax = 2**(bits-1) - 1: 부호 있는 정수에서 대칭으로 쓰는 최댓값(8비트 127, 4비트 7).
  • reshape(-1, group): 가중치를 64개씩 한 줄로 묶어, 줄마다 최댓값 → 스케일을 따로 구합니다. 이것이 '묶음(그룹) 양자화'입니다.
  • np.clip: 반올림 결과가 범위를 넘지 않게 자릅니다.
  • 2부 함수 세 개는 각각 197 · 196 · 199 수업의 공식을 그대로 옮긴 것입니다.

3. 실행 결과 (실제 출력)

text
== 1부: 양자화 오차 (출력 상대 오차 = ||XW - XWq|| / ||XW||) ==
보통 가중치  | INT8 | 전체 1개 스케일    | 출력 오차 0.0107
보통 가중치  | INT8 | 64개 묶음 스케일   | 출력 오차 0.0059
보통 가중치  | INT4 | 전체 1개 스케일    | 출력 오차 0.1940
보통 가중치  | INT4 | 64개 묶음 스케일   | 출력 오차 0.1022
이상치 4개  | INT8 | 전체 1개 스케일    | 출력 오차 0.1015
이상치 4개  | INT8 | 64개 묶음 스케일   | 출력 오차 0.0089
이상치 4개  | INT4 | 전체 1개 스케일    | 출력 오차 0.9162
이상치 4개  | INT4 | 64개 묶음 스케일   | 출력 오차 0.1091

== 2부: 메모리 계산기 ==
   7B | FP16   14.0 GB | INT8    7.0 GB | INT4    3.5 GB | 학습 상태(16B/파라미터)   112.0 GB
  13B | FP16   26.0 GB | INT8   13.0 GB | INT4    6.5 GB | 학습 상태(16B/파라미터)   208.0 GB
  70B | FP16  140.0 GB | INT8   70.0 GB | INT4   35.0 GB | 학습 상태(16B/파라미터)  1120.0 GB
KV 캐시 MHA            | 길이 2048 · 배치 1 :   5.37 GB | 배치 32 :  171.80 GB
KV 캐시 GQA 8그룹(가정)    | 길이 2048 · 배치 1 :   0.67 GB | 배치 32 :   21.47 GB
KV 캐시 MQA            | 길이 2048 · 배치 1 :   0.08 GB | 배치 32 :    2.68 GB

== 3부: 디코드 지연 하한과 추측 디코딩 ==
7B · 16비트 가중치 1회 읽기 = 토큰당 최소   6.87 ms
7B · 4비트 가중치 1회 읽기 = 토큰당 최소   1.72 ms
70B · 16비트 가중치 1회 읽기 = 토큰당 최소  68.66 ms
수락률 α=0.6 → 기대 토큰 수 γ=2:1.96 γ=4:2.31 γ=8:2.47
수락률 α=0.8 → 기대 토큰 수 γ=2:2.44 γ=4:3.36 γ=8:4.33
수락률 α=0.9 → 기대 토큰 수 γ=2:2.71 γ=4:4.10 γ=8:6.13

난수 씨앗을 default_rng(0) 으로 고정했으므로 같은 넘파이 버전이면 같은 숫자가 나옵니다. 버전이 다르면 소수점 끝자리가 조금 다를 수 있습니다.

4. 결과 읽기

① 비트 수. 보통 가중치에서 INT8 출력 오차는 약 1%, INT4는 약 19%(전체 스케일)입니다. 눈금이 255칸에서 15칸으로 줄면 오차가 크게 늘어납니다. 그래서 INT4는 거의 항상 묶음 스케일이나 GPTQ · AWQ 같은 보정 기법과 함께 씁니다.

② 묶음 스케일. 같은 INT4라도 64개 묶음마다 스케일을 두면 오차가 0.194 → 0.102로 거의 절반이 됩니다. 묶음마다 자기 최댓값에 맞춰 눈금을 촘촘하게 쓰기 때문입니다.

③ 이상치. 이상치 4개(전체 65,536개 중 0.006%)만으로 INT8 전체 스케일 오차가 0.011 → 0.10으로 약 10배, INT4는 0.92로 사실상 쓸 수 없는 수준이 됩니다. 묶음 스케일로 바꾸면 이상치가 든 몇 개 묶음만 피해를 보아 각각 0.009, 0.109로 돌아옵니다. LLM.int8() 이 이상치 차원을 따로 빼서 16비트로 계산한 이유가 바로 이것입니다.

④ 메모리. 7B 모델은 INT4로 3.5GB라 노트북 GPU에도 들어가지만, 학습 상태는 112GB라 혼자서는 학습할 수 없습니다. 미세조정에 QLoRA 같은 방법이 필요한 이유입니다.

⑤ KV 캐시. 65B 구성으로 동시 요청 32개 × 2,048토큰이면 MHA 기준 약 172GB로 가중치보다 큽니다. GQA 8그룹(해설용 가정)이면 약 21GB로 8분의 1입니다.

⑥ 지연과 추측 디코딩. 70B FP16은 대역폭만으로도 토큰당 최소 약 69ms(초당 약 15토큰)입니다. 추측 디코딩은 수락률이 높고(α=0.9) 초안 길이가 길면(γ=8) 큰 모델 한 번에 평균 6개 넘게 만들 수 있지만, 수락률이 0.6이면 γ를 8로 늘려도 2.5개에 그칩니다. 초안 모델이 목표 모델과 얼마나 닮았느냐가 핵심입니다.

mermaid
flowchart LR
  A[가짜 가중치 W · 입력 X 만들기] --> B[이상치 심은 W_out]
  A --> C[absmax 양자화<br/>INT8 · INT4 × 전체 · 64묶음]
  B --> C
  C --> D[출력 오차 비교]
  E[모델 크기 · 비트 · 길이 입력] --> F[메모리 계산기<br/>가중치 · 학습 상태 · KV 캐시]
  F --> G[내 GPU에 들어가나 판단]
  H[대역폭 · 수락률] --> I[지연 하한 · 기대 토큰 수]

5. 바꿔 보기 과제

  1. 묶음 크기를 32, 128, 256으로 바꿔 INT4 출력 오차가 어떻게 변하는지 표로 만들어 보세요.
    • 힌트 · 예상: 묶음이 작을수록 오차는 줄지만 스케일 저장분이 늘어납니다. FP16 스케일이면 64개 묶음은 원소당 16/64=0.2516/64=0.25 비트가 더 듭니다.
  2. quantize_absmax 를 비대칭(영점) 양자화로 바꿔, ReLU를 지난 것처럼 음수가 없는 가중치(np.abs(W))에서 absmax와 비교해 보세요.
    • 힌트: 스케일 =(2b−1)/(max⁡−min⁡)=(2^{b}-1)/(\max-\min), 영점 =round(−min⁡×스케일)=\mathrm{round}(-\min\times\text{스케일}).
  3. 여러분이 쓰는 모델의 설정 파일(config.json)에서 층 수 · KV 헤드 수 · 헤드 차원을 찾아 kv_cache_mem 에 넣고, 원하는 문맥 길이에서 GPU 하나에 동시 요청을 몇 개 받을 수 있는지 계산해 보세요.
    • 힌트: (GPU 메모리 − 가중치 메모리 − 여유분) ÷ 요청 하나의 KV 캐시.
  4. bw 를 여러분 GPU의 공식 메모리 대역폭으로 바꿔 토큰당 지연 하한을 구하고, 실제 측정 속도와 비교해 보세요.

연습 문제

  1. INT4 묶음 64개, 스케일을 FP16으로 저장하면 원소당 실제 비트 수는?
    • 풀이: 4+16/64=4.254+16/64=4.25 비트입니다.
  2. 13B 모델을 INT4(위 방식, 4.25비트)로 저장하면?
    • 풀이: 13×109×4.25/8≈6.913\times10^9\times4.25/8\approx6.9 GB입니다.
  3. 출력 결과에서 이상치가 있을 때 INT8 '전체 스케일'과 '64개 묶음'의 오차 비는?
    • 풀이: 0.1015/0.0089≈110.1015/0.0089\approx11 배입니다.

스스로 점검

  1. 왜 이 실습은 가중치 오차가 아니라 출력 오차를 쟀나요?
    • 답: 모델 품질에 영향을 주는 것은 층 출력의 변화이고, 같은 가중치 오차라도 입력과 곱해진 뒤의 영향은 다를 수 있기 때문입니다.
  2. 묶음 스케일이 이상치에 강한 이유는?
    • 답: 이상치가 속한 묶음의 스케일만 커지고, 나머지 묶음은 자기 값 범위에 맞는 촘촘한 눈금을 유지하기 때문입니다.
  3. 디코드 지연 하한 계산에서 무엇을 무시했나요?
    • 답: KV 캐시 읽기, 실제 커널 효율, 통신 등입니다. 그래서 실제 지연은 이 값보다 깁니다.

용어 정리

용어쉬운 뜻
출력 상대 오차양자화 전후 층 출력 차이를 원래 출력 크기로 나눈 값
묶음(그룹) 양자화가중치를 일정 개수씩 묶어 묶음마다 스케일을 두는 양자화
이상치다른 값보다 훨씬 큰 소수의 값
프로베니우스 노름행렬 모든 원소 제곱합의 제곱근
난수 씨앗(seed)같은 '무작위' 숫자를 다시 만들기 위해 고정하는 값
메모리 대역폭메모리에서 초당 읽을 수 있는 바이트 수

다음에 볼 것

라이선스

본문과 코드는 KAITABASE가 이 수업을 위해 직접 작성했습니다. 코드는 자유롭게 고쳐 써도 됩니다. A100 대역폭 값은 NVIDIA 제품 페이지, LLaMA 65B 구성은 LLaMA 논문에서 가져왔고, GQA 8그룹은 해설용 가정입니다. 가중치 · 입력은 무작위로 만든 가짜 값이라 실제 LLM의 양자화 오차 수치와는 다릅니다.

출처

  • NVIDIA A100 제품 페이지(메모리 대역폭): https://www.nvidia.com/en-us/data-center/a100/
  • Touvron 외, LLaMA (2023): https://arxiv.org/abs/2302.13971
  • Dettmers 외, LLM.int8() (2022): https://arxiv.org/abs/2208.07339
  • Leviathan 외, Fast Inference from Transformers via Speculative Decoding (2022): https://arxiv.org/abs/2211.17192
  • Rajbhandari 외, ZeRO (2019): https://arxiv.org/abs/1910.02054

AI 원리부터 이해하기 · 스케일링·효율 모듈

스케일링·효율 모듈 학습

이 글은 스케일링·효율 모듈의 학습 자료입니다. 전체 19개 자료 중 14번째입니다.

AI 기초 과정은 현재 진도 저장을 지원하지 않습니다.

출처·원문

원문 보기

요약만

본문 · 코드 자체 작성