개념
숫자 형식: 각 비트가 하는 일
모든 부동소수점 숫자는 세 부분으로 구성됩니다. 부호(sign), 지수(exponent), 가수(mantissa 또는 significand)입니다. 부호는 1비트입니다. 지수는 숫자가 얼마나 크거나 작을 수 있는지, 즉 표현 가능한 범위를 결정합니다. 가수는 정밀도, 즉 소수점 이하를 얼마나 세밀하게 표현할지 결정합니다.
FP32: [1 sign] [8 exponent] [23 mantissa] = 32 bits
FP16: [1 sign] [5 exponent] [10 mantissa] = 16 bits
BF16: [1 sign] [8 exponent] [7 mantissa] = 16 bits
FP8: [1 sign] [4 exponent] [3 mantissa] = 8 bits (E4M3)
FP8: [1 sign] [5 exponent] [2 mantissa] = 8 bits (E5M2)
INT8: [1 sign] [7 value] = 8 bits (uniform steps)
INT4: [1 sign] [3 value] = 4 bits (16 levels total)
FP32는 전체 정밀도(full precision)입니다. 가수 23비트로 약 7자리 십진 정밀도를 제공합니다. 범위는 대략 1.2 x 10^-38에서 3.4 x 10^38까지입니다. 예전에는 학습이 거의 FP32에서만 이루어졌습니다. 지금도 행렬곱 중의 누적합(running sum) 같은 누적 연산에는 여전히 FP32가 쓰입니다.
FP16은 비트를 절반으로 줄입니다. 가수 10비트로 약 3.3자리 십진 정밀도를 제공합니다. 지수는 5비트로 줄어 범위도 크게 좁아지고, 최대값은 약 65,504입니다. 0 근처에 몰리는 가중치에는 무리 없이 쓸 수 있지만, 학습 중 갑자기 커질 수 있는 활성값(activation)과 그래디언트(gradient)에는 위험합니다. FP16 학습에는 언더플로(underflow)를 막기 위한 손실 스케일링(loss scaling)이 필요합니다.
BF16(Brain Float 16)은 FP32의 지수 8비트를 그대로 유지하고 가수만 7비트로 줄입니다. FP32와 같은 범위를 갖지만 정밀도는 FP16보다 낮습니다. Google이 딥러닝용으로 설계한 형식이며, 신경망에는 정밀도보다 범위가 더 중요하다는 직관에 기반합니다. FP16에서 0으로 언더플로되는 10^-20 크기의 그래디언트가 BF16에서는 살아남고, 가중치 0.07342가 BF16에서 0.0734로 반올림되는 정도는 충분히 가깝습니다. 현대의 학습 실행은 대부분 BF16 또는 BF16/FP32 혼합 정밀도(mixed precision)를 사용합니다.
FP8에는 두 변형이 있습니다. E4M3(지수 4비트, 가수 3비트)는 추론에서 가중치와 활성값에 사용됩니다. E5M2(지수 5비트, 가수 2비트)는 정밀도보다 범위가 중요한 학습 중 그래디언트에 사용됩니다. H100 GPU에서 FP8 추론은 FP16 대비 30-50% 속도 향상을 내며 품질 손실은 거의 없습니다.
INT8은 정수 형식입니다. 지수도 가수도 없습니다. -128부터 127까지 균등 간격으로 배치된 256개 값만 있습니다. 부동소수점 가중치를 이 범위에 매핑하려면 스케일 팩터(scale factor)가 필요합니다. 정수 연산이 부동소수점 연산보다 빠르고 전력 효율도 높다는 점이 장점입니다. A100에서 INT8 행렬곱은 624 TOPS이며, FP16은 312 TFLOPS입니다.
INT4는 더 밀어붙입니다. 가능한 값은 16개뿐입니다. 그만큼 스케일 팩터가 훨씬 많은 일을 합니다. 품질은 스케일을 어떻게 고르고, 어떤 가중치를 양자화하는지에 달려 있습니다. 최신 INT4 기법인 GPTQ와 AWQ는 원본 모델 품질의 95% 이상을 유지합니다.
graph LR
subgraph Formats["숫자 형식 지형(Number Format Landscape)"]
direction TB
FP32["FP32\n32 bits\n4 bytes/param\nTraining gold standard"]
BF16["BF16\n16 bits\n2 bytes/param\nTraining default"]
FP16["FP16\n16 bits\n2 bytes/param\nInference baseline"]
FP8["FP8\n8 bits\n1 byte/param\n30-50% faster"]
INT8["INT8\n8 bits\n1 byte/param\n2x throughput"]
INT4["INT4\n4 bits\n0.5 bytes/param\n4x compression"]
end
FP32 -->|"training"| BF16
BF16 -->|"inference"| FP16
FP16 -->|"H100 native"| FP8
FP16 -->|"server deploy"| INT8
FP16 -->|"edge/laptop"| INT4
style FP32 fill:#1a1a2e,stroke:#0f3460,color:#fff
style BF16 fill:#1a1a2e,stroke:#0f3460,color:#fff
style FP16 fill:#1a1a2e,stroke:#ffa500,color:#fff
style FP8 fill:#1a1a2e,stroke:#51cf66,color:#fff
style INT8 fill:#1a1a2e,stroke:#51cf66,color:#fff
style INT4 fill:#1a1a2e,stroke:#e94560,color:#fff
양자화가 동작하는 방식
핵심 연산은 단순합니다. 부동소수점 값 텐서를 가져온 뒤 스케일 팩터를 구하고, 그 값으로 나눠 가장 가까운 정수로 반올림한 다음, 정수와 스케일 팩터를 함께 저장하는 것입니다.
양자화:
scale = max(abs(tensor)) / max_int_value
quantized = round(tensor / scale)
역양자화:
reconstructed = quantized * scale
대칭 범위 -127에서 127을 쓰는 INT8은 다음과 같습니다.
scale = max(abs(tensor)) / 127
quantized = clamp(round(tensor / scale), -128, 127)
오차는 반올림 오차(rounding error)에서 옵니다. 각 값은 최대 scale / 2만큼 어긋날 수 있습니다. 한 층 전체의 총 오차는 가중치 개수와, 모델이 그 가중치 교란(perturbation)에 얼마나 민감한지에 따라 달라집니다.
텐서 단위(per-tensor) 양자화와 채널 단위(per-channel) 양자화. 텐서 단위는 가중치 행렬 전체에 스케일 팩터 하나만 사용합니다. 단순하지만 손실이 큽니다. 어떤 열은 큰 값을 갖고 다른 열은 작은 값을 갖는다면, 작은 값들이 정밀도의 대부분을 잃기 때문입니다. 채널 단위는 출력 채널마다, 즉 가중치 행렬의 행 또는 열마다 스케일 팩터를 하나씩 둡니다. 스케일을 1개가 아니라 N개 저장해야 해서 오버헤드가 늘지만, 품질은 극적으로 좋아집니다. 모든 프로덕션 양자화 기법은 채널 단위, 혹은 그보다 더 세밀한 단위(granularity)를 사용합니다.
비대칭 양자화(asymmetric quantization)는 0점 오프셋(zero-point offset)을 추가합니다. 식으로는 quantized = round(tensor / scale) + zero_point입니다. 0을 중심으로 하지 않는 분포를 다룰 때 유용합니다. 예를 들어 ReLU 활성값은 항상 0 이상입니다. 대칭 양자화는 절대 나오지 않는 음수 영역에 정수 범위의 절반을 낭비합니다. 비대칭 양자화는 실제 범위 [min, max]를 정수 범위 전체에 매핑합니다.
민감도 계층
모델의 모든 부분이 양자화를 똑같이 견디지는 않습니다. 분명한 계층이 존재합니다.
가중치(weights, 가장 견고함). 모델 가중치는 학습 중 천천히 변하며, 0을 중심으로 한 가우시안(Gaussian)에 가까운 분포를 따릅니다. 그래서 양자화가 잘 됩니다. 채널 단위 스케일을 사용하는 INT8 가중치는 거의 손실 없는 결과를 냅니다. INT4는 더 정교한 기법이 필요하지만 충분히 가능합니다.
활성값(activations, 중간 민감도). 활성값은 추론 중 네트워크를 흐르는 중간 값입니다. 가중치보다 동적 범위가 넓고 이상치(outlier)를 포함합니다. 어떤 어텐션 헤드(attention head)는 평균보다 100배 큰 활성값을 만들 수 있습니다. 이런 이상치는 모델 품질에 결정적이라, 순진하게 양자화하면 정보가 파괴됩니다. 해결책은 이상치 채널을 더 높은 정밀도로 유지하거나(LLM.int8()) 토큰 단위(per-token) 혹은 채널 단위 활성값 스케일을 사용하는 것입니다.
KV 캐시(KV cache, 높은 민감도). key-value cache는 이전 모든 토큰의 어텐션 상태를 저장합니다. 긴 문맥(context)에서는 KV 캐시가 메모리를 지배합니다. 70B 모델에 32K 문맥을 쓰면 FP16 KV 캐시만으로 40GB입니다. KV 캐시를 FP8 또는 INT8로 양자화하면 메모리를 크게 절감할 수 있지만, 오차가 이후 모든 어텐션 계산에 누적됩니다. 품질에 미치는 영향은 시퀀스 길이가 길어질수록 커집니다.
어텐션 로짓(attention logits, 가장 민감함). 어텐션 안의 소프트맥스(softmax)는 입력의 작은 변화에 매우 민감합니다. 소프트맥스 이전의 로짓에서 0.01 정도의 양자화 오차만 생겨도 어텐션 분포가 의미 있게 바뀔 수 있습니다. 대부분의 양자화 방식은 다른 곳을 양자화하더라도 어텐션 계산만큼은 FP16 또는 BF16 같은 높은 정밀도로 유지합니다.
graph TD
subgraph Sensitivity["양자화 민감도(낮음에서 높음)"]
direction LR
W["Weights\nGaussian, near zero\nINT4 works well"]
A["Activations\nWider range, outliers\nINT8 with care"]
KV["KV Cache\nErrors compound\nFP8 or INT8"]
ATT["Attention Logits\nSoftmax amplifies error\nKeep in FP16"]
end
W -->|"safe"| A
A -->|"careful"| KV
KV -->|"dangerous"| ATT
style W fill:#1a1a2e,stroke:#51cf66,color:#fff
style A fill:#1a1a2e,stroke:#ffa500,color:#fff
style KV fill:#1a1a2e,stroke:#e94560,color:#fff
style ATT fill:#1a1a2e,stroke:#ff0000,color:#fff
PTQ와 QAT
학습 후 양자화(Post-Training Quantization; PTQ)는 이미 학습된 모델을 그대로 양자화합니다. 재학습은 없습니다. FP16 가중치를 가져와 스케일 팩터를 계산하고, 반올림한 뒤 배포합니다. 보통 몇 분에서 몇 시간이면 끝나고 비용도 낮습니다. INT8과 FP8에서는 잘 작동하지만, INT4에서는 순진한 PTQ가 반올림 오차 누적으로 크게 실패할 수 있습니다. GPTQ와 AWQ 같은 고급 PTQ 기법은 보정 데이터(calibration data)를 사용해 양자화 오차를 줄입니다.
양자화 인지 학습(Quantization-Aware Training; QAT)은 학습 중 순전파(forward pass)에 가짜 양자화(fake quantization)를 끼워 넣습니다. 모델은 반올림 오차가 작아지는 위치에 가중치를 두도록 학습합니다. 그래디언트는 직통 추정기(straight-through estimator; STE)를 통해 흐릅니다. 즉 반올림 연산의 그래디언트가 1인 것처럼 취급합니다. QAT는 PTQ보다 INT4와 INT2에서 더 좋은 품질을 만들어 내지만, 전체 학습 과정을 한 번 더 돌려야 합니다. Google은 Gemini의 효율적 서빙에 QAT를 사용했고, Meta도 일부 Llama 배포 대상에 QAT를 적용했습니다.
| 항목 | PTQ | QAT |
|---|
| 비용 | 몇 분에서 몇 시간 | 전체 학습 실행 |
| INT8 품질 | 매우 좋음(손실 < 0.1%) | 매우 좋음 |
| INT4 품질 | GPTQ/AWQ 사용 시 좋음(1-3% 손실) | 더 좋음(손실 < 1%) |
| INT2 품질 | 나쁨 | 일부 작업에서 사용 가능 |
| 보정 데이터(Calibration data) | 128-1024개 예제 | 전체 학습 데이터셋 |
| 사용 시점 | 배포, 빠른 반복 | 낮은 비트 폭(bit-width)에서 최대 품질이 필요할 때 |
GPTQ, AWQ, GGUF
GPTQ(GPT Quantization)는 단일 패스(one-shot) PTQ 기법입니다. 보통 128개 정도의 작은 보정 데이터셋을 사용해 헤시안(Hessian), 즉 각 가중치가 출력에 얼마나 민감한지에 대한 2차 정보를 측정하고, 가중치를 한 층씩 양자화합니다. 헤시안이 중요하다고 판단한 가중치는 더 조심스럽게 양자화합니다. GPTQ는 LLM에서 INT4 양자화를 실용 가능한 수준으로 끌어올린 첫 기법입니다. Hugging Face의 TheBloke는 수백 개 모델의 양자화 버전을 공개하며 GPTQ를 대중화했습니다.
AWQ(Activation-Aware Weight Quantization)는 소수의 가중치(약 1%)가 큰 활성값과 곱해지기 때문에 모델 품질에 불균형하게 큰 영향을 준다는 점에 주목합니다. AWQ는 보정 데이터를 사용해 이런 두드러진 가중치(salient weight)를 찾고, 양자화 전에 그 가중치들을 키워(scale up) 둡니다(대응되는 활성값은 그만큼 줄입니다). 이렇게 하면 중요한 가중치가 INT4 양자화가 정확한 범위 안에 머무릅니다. AWQ는 일반적으로 GPTQ와 비슷하거나 약간 더 나은 품질을 내며, 적용 속도도 1.5-2배 빠릅니다.
GGUF(GPT-Generated Unified Format)는 llama.cpp와 그 생태계가 사용하는 파일 형식입니다. 혼합 양자화(mixed quantization)를 지원해, 층마다 다른 비트 폭을 줄 수 있습니다. 첫 층과 마지막 층, 즉 임베딩(embedding)과 출력 헤드(output head)는 보통 더 높은 정밀도로 유지하고, 중간 층은 INT4 또는 INT3로 둡니다. GGUF 파일은 자기 완결적(self-contained)이라, 가중치, 토크나이저, 메타데이터가 한 파일 안에 들어 있습니다. 이 형식은 CPU 추론과 Apple Silicon에 맞춰 설계되었습니다. 모델 전체를 메모리에 올리고 CPU 또는 Metal GPU에서 행렬곱을 수행하는 경로가 표준입니다. Q4_K_M은 품질과 크기의 균형이 좋아 가장 인기 있는 GGUF 양자화 변형입니다.
graph TD
subgraph Methods["양자화 방법"]
direction TB
GPTQ_["GPTQ\nHessian-guided\nPer-layer optimization\nPopular on HuggingFace"]
AWQ_["AWQ\nActivation-aware\nSalient weight scaling\n1.5-2x faster than GPTQ"]
GGUF_["GGUF\nMixed precision\nCPU + Metal optimized\nllama.cpp ecosystem"]
end
subgraph Use["가장 적합한 용도"]
GPU["GPU inference\n(CUDA, ROCm)"]
EDGE["Edge / Laptop\n(CPU, Metal)"]
end
GPTQ_ --> GPU
AWQ_ --> GPU
GGUF_ --> EDGE
style GPTQ_ fill:#1a1a2e,stroke:#ffa500,color:#fff
style AWQ_ fill:#1a1a2e,stroke:#51cf66,color:#fff
style GGUF_ fill:#1a1a2e,stroke:#0f3460,color:#fff
품질 측정
양자화한 모델이 여전히 쓸 만한지 어떻게 알 수 있을까요?
퍼플렉서티(perplexity). 가장 흔히 쓰는 지표입니다. 낮을수록 좋습니다. 원본 모델과 양자화 모델 모두에 대해 별도의 평가용 데이터셋(보통 WikiText-2)에서 perplexity를 계산합니다. 두 값의 차이(delta)가 양자화가 얼마나 많은 정보를 파괴했는지 알려 줍니다. 경험칙은 다음과 같습니다. delta가 0.5 미만이면 훌륭, 0.5-1.0이면 좋음, 1.0-2.0이면 대부분의 작업에서 허용 가능, 2.0을 넘으면 무언가 잘못된 것입니다.
작업별 벤치마크(task-specific benchmarks). 양자화 모델을 MMLU, HumanEval, GSM8K 또는 직접 만든 평가 스위트에서 실행해 원본과 비교합니다. 양자화는 능력별로 영향이 다르게 나타납니다. 수학과 코드 작업은 일반 지식보다 정밀도 손실에 더 민감합니다.
출력 비교(output comparison). 같은 프롬프트에서 두 모델의 응답을 생성해 비교합니다. Lesson 10에서 다룬 LLM-as-judge 방식이 여기에 잘 맞습니다. 양자화 모델이 원본과 같거나 더 나은 응답을 낸 프롬프트의 비율, 즉 승률(win rate)을 계산합니다.
지연 시간과 처리량(latency and throughput). 양자화는 모델을 더 빠르고 저렴하게 만들기 위해 존재합니다. 초당 토큰 수(tokens/sec), 첫 토큰까지의 시간(time to first token), 메모리 사용량을 함께 측정합니다. 원본보다 느린 양자화 모델은 쓸모가 없는 정도가 아니라 더 나쁩니다.
| 모델 | 형식 | 크기 | Perplexity(WikiText-2) | MMLU | Tokens/sec(A100) |
|---|
| Llama 3 70B | FP16 | 140GB | 3.12 | 79.5% | 38 |
| Llama 3 70B | FP8 | 70GB | 3.14 | 79.3% | 55 |
| Llama 3 70B | GPTQ INT4 | 35GB | 4.32 | 77.8% | 72 |
| Llama 3 70B | AWQ INT4 | 35GB | 4.18 | 78.1% | 75 |
| Llama 3 70B | GGUF Q4_K_M | 40GB | 4.25 | 77.9% | 28(CPU) |
패턴은 이렇습니다. FP8은 사실상 공짜에 가깝습니다. INT4는 MMLU에서 1-2점을 잃는 비용이 들지만, 처리량은 두 배가 되고 메모리는 4분의 1이 됩니다. 거의 모든 배포 상황에서 이 절충은 가치가 있습니다.
실제 숫자
H100에서 FP16에서 FP8로: 추론 속도 30-50% 향상, 품질 손실 < 0.1%. 거의 고민이 필요 없는 양자화입니다. 모든 H100 배포는 FP8을 검토해야 합니다.
FP16에서 INT8로(LLM.int8()): 메모리 2배 절감, 품질 손실 < 0.5%. 혼합 정밀도 접근으로 이상치 특징(outlier feature)은 FP16에 두고 나머지를 INT8로 양자화합니다.
FP16에서 INT4로(GPTQ/AWQ): 메모리 4배 절감, 모델과 기법에 따라 1-3% 품질 손실. 70B 모델을 48GB GPU 한 장에 올릴 수 있게 합니다.
FP16에서 INT4로(GGUF Q4_K_M): 메모리 3.5배 절감, 품질 손실 1-2%. CPU 추론에 최적화되어 있습니다. Q4_K_M의 70B 모델은 약 40GB이며, 64GB 메모리를 갖춘 M3 Max에서 초당 10-15 토큰으로 실행됩니다.
FP16에서 INT2로: 메모리 8배 절감, 품질 손실 5-15%. 성능 저하를 감수할 수 있는 좁은 작업에서만 현실적입니다. 일반 용도의 프로덕션 단계라기보다 연구 프런티어에 가깝습니다.
직접 만들기
Step 1: 숫자 형식 표현
각 형식의 비트 수준 표현을 만들어 sign, exponent, mantissa가 정확히 어떤 역할을 하는지 확인합니다.
import numpy as np
def float_to_fp32_bits(value):
bits = np.float32(value).view(np.uint32)
sign = (bits >> 31) & 1
exponent = (bits >> 23) & 0xFF
mantissa = bits & 0x7FFFFF
return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
"exponent_bits": format(int(exponent), '08b'),
"mantissa_bits": format(int(mantissa), '023b'),
"value": float(value),
"actual_exponent": int(exponent) - 127}
def float_to_fp16_bits(value):
fp16 = np.float16(value)
bits = fp16.view(np.uint16)
sign = (bits >> 15) & 1
exponent = (bits >> 10) & 0x1F
mantissa = bits & 0x3FF
return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
"exponent_bits": format(int(exponent), '05b'),
"mantissa_bits": format(int(mantissa), '010b'),
"value": float(fp16),
"actual_exponent": int(exponent) - 15}
def float_to_bf16_bits(value):
fp32_bits = np.float32(value).view(np.uint32)
bf16_bits = (fp32_bits >> 16).astype(np.uint16)
sign = (bf16_bits >> 15) & 1
exponent = (bf16_bits >> 7) & 0xFF
mantissa = bf16_bits & 0x7F
reconstructed = np.uint32(bf16_bits.astype(np.uint32) << 16).view(np.float32)
return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
"exponent_bits": format(int(exponent), '08b'),
"mantissa_bits": format(int(mantissa), '07b'),
"value": float(reconstructed),
"actual_exponent": int(exponent) - 127}
def simulate_fp8_e4m3(value):
sign = 1 if value < 0 else 0
abs_val = abs(value)
max_val = 448.0
abs_val = min(abs_val, max_val)
if abs_val == 0:
return {"sign": sign, "exponent": 0, "mantissa": 0, "value": 0.0,
"exponent_bits": "0000", "mantissa_bits": "000"}
exp = int(np.floor(np.log2(abs_val)))
exp = max(-6, min(8, exp))
mantissa_val = abs_val / (2.0 ** exp) - 1.0
mantissa_quant = round(mantissa_val * 8) / 8
mantissa_quant = max(0, min(0.875, mantissa_quant))
reconstructed = (1.0 + mantissa_quant) * (2.0 ** exp)
if sign:
reconstructed = -reconstructed
mantissa_int = int(round(mantissa_quant * 8))
return {"sign": sign, "exponent": exp + 7, "mantissa": mantissa_int,
"exponent_bits": format(exp + 7, '04b'),
"mantissa_bits": format(mantissa_int, '03b'),
"value": float(reconstructed),
"actual_exponent": exp}
def display_format_comparison(value):
fp32 = float_to_fp32_bits(value)
fp16 = float_to_fp16_bits(value)
bf16 = float_to_bf16_bits(value)
fp8 = simulate_fp8_e4m3(value)
print(f"\n Value: {value}")
print(f" {'Format':<8} {'Stored Value':>14} {'Error':>12} {'Sign':>5} {'Exp Bits':>10} {'Man Bits':>25}")
print(f" {'-'*76}")
print(f" {'FP32':<8} {fp32['value']:>14.6f} {abs(fp32['value'] - value):>12.8f} {fp32['sign']:>5} {fp32['exponent_bits']:>10} {fp32['mantissa_bits']:>25}")
print(f" {'FP16':<8} {fp16['value']:>14.6f} {abs(fp16['value'] - value):>12.8f} {fp16['sign']:>5} {fp16['exponent_bits']:>10} {fp16['mantissa_bits']:>25}")
print(f" {'BF16':<8} {bf16['value']:>14.6f} {abs(bf16['value'] - value):>12.8f} {bf16['sign']:>5} {bf16['exponent_bits']:>10} {bf16['mantissa_bits']:>25}")
print(f" {'FP8e4m3':<8} {fp8['value']:>14.6f} {abs(fp8['value'] - value):>12.8f} {fp8['sign']:>5} {fp8['exponent_bits']:>10} {fp8['mantissa_bits']:>25}")
Step 2: 대칭 양자화(텐서 단위와 채널 단위)
기본 양자화 연산입니다. 텐서 단위는 행렬 전체에 스케일 하나를 사용합니다. 채널 단위는 행 또는 열마다 스케일 하나를 사용합니다.
def quantize_symmetric(tensor, num_bits=8):
qmin = -(2 ** (num_bits - 1))
qmax = 2 ** (num_bits - 1) - 1
abs_max = np.max(np.abs(tensor))
if abs_max == 0:
return np.zeros_like(tensor, dtype=np.int32), 1.0
scale = abs_max / qmax
quantized = np.clip(np.round(tensor / scale), qmin, qmax).astype(np.int32)
return quantized, float(scale)
def dequantize_symmetric(quantized, scale):
return quantized.astype(np.float64) * scale
def quantize_per_channel(tensor, num_bits=8, axis=0):
qmin = -(2 ** (num_bits - 1))
qmax = 2 ** (num_bits - 1) - 1
if axis == 0:
abs_max = np.max(np.abs(tensor), axis=1, keepdims=True)
else:
abs_max = np.max(np.abs(tensor), axis=0, keepdims=True)
abs_max = np.where(abs_max == 0, 1.0, abs_max)
scales = abs_max / qmax
quantized = np.clip(np.round(tensor / scales), qmin, qmax).astype(np.int32)
return quantized, scales.squeeze()
def dequantize_per_channel(quantized, scales, axis=0):
if axis == 0:
return quantized.astype(np.float64) * scales.reshape(-1, 1)
else:
return quantized.astype(np.float64) * scales.reshape(1, -1)
def quantize_asymmetric(tensor, num_bits=8):
qmin = 0
qmax = 2 ** num_bits - 1
t_min = np.min(tensor)
t_max = np.max(tensor)
if t_max == t_min:
return np.zeros_like(tensor, dtype=np.int32), 1.0, 0
scale = (t_max - t_min) / (qmax - qmin)
zero_point = int(np.round(qmin - t_min / scale))
zero_point = max(qmin, min(qmax, zero_point))
quantized = np.clip(np.round(tensor / scale + zero_point), qmin, qmax).astype(np.int32)
return quantized, float(scale), int(zero_point)
def dequantize_asymmetric(quantized, scale, zero_point):
return (quantized.astype(np.float64) - zero_point) * scale
Step 3: 품질 측정
양자화가 얼마나 많은 정보를 파괴하는지 측정합니다. 평균제곱오차(mean squared error; MSE), 신호 대 잡음비(signal-to-noise ratio; SNR), 그리고 원본과 복원 텐서 사이의 코사인 유사도(cosine similarity)를 함께 살펴봅니다.
def quantization_error(original, reconstructed):
diff = original - reconstructed
mse = float(np.mean(diff ** 2))
rmse = float(np.sqrt(mse))
max_error = float(np.max(np.abs(diff)))
signal_power = float(np.mean(original ** 2))
snr_db = 10 * np.log10(signal_power / max(mse, 1e-20))
orig_flat = original.flatten()
recon_flat = reconstructed.flatten()
norm_orig = np.linalg.norm(orig_flat)
norm_recon = np.linalg.norm(recon_flat)
if norm_orig == 0 or norm_recon == 0:
cosine_sim = 0.0
else:
cosine_sim = float(np.dot(orig_flat, recon_flat) / (norm_orig * norm_recon))
return {"mse": mse, "rmse": rmse, "max_error": max_error,
"snr_db": float(snr_db), "cosine_similarity": cosine_sim}
def compare_quantization_methods(tensor, num_bits=8):
q_pt, s_pt = quantize_symmetric(tensor, num_bits)
recon_pt = dequantize_symmetric(q_pt, s_pt)
err_pt = quantization_error(tensor, recon_pt)
q_pc, s_pc = quantize_per_channel(tensor, num_bits, axis=0)
recon_pc = dequantize_per_channel(q_pc, s_pc, axis=0)
err_pc = quantization_error(tensor, recon_pc)
q_asym, s_asym, zp = quantize_asymmetric(tensor, num_bits)
recon_asym = dequantize_asymmetric(q_asym, s_asym, zp)
err_asym = quantization_error(tensor, recon_asym)
print(f"\n Quantization Comparison ({num_bits}-bit, tensor shape {tensor.shape}):")
print(f" {'Method':<20} {'MSE':>12} {'SNR (dB)':>10} {'Cosine Sim':>12} {'Max Error':>12}")
print(f" {'-'*68}")
print(f" {'Per-tensor sym':<20} {err_pt['mse']:>12.8f} {err_pt['snr_db']:>10.2f} {err_pt['cosine_similarity']:>12.8f} {err_pt['max_error']:>12.8f}")
print(f" {'Per-channel sym':<20} {err_pc['mse']:>12.8f} {err_pc['snr_db']:>10.2f} {err_pc['cosine_similarity']:>12.8f} {err_pc['max_error']:>12.8f}")
print(f" {'Asymmetric':<20} {err_asym['mse']:>12.8f} {err_asym['snr_db']:>10.2f} {err_asym['cosine_similarity']:>12.8f} {err_asym['max_error']:>12.8f}")
return {"per_tensor": err_pt, "per_channel": err_pc, "asymmetric": err_asym}
Step 4: 비트 폭(bit-width) 스윕
같은 텐서를 2, 3, 4, 8, 16비트로 양자화하고 각 수준의 품질을 측정합니다. 품질 절벽(quality cliff)이 정확히 어디에서 나타나는지 보여 줍니다.
def bit_width_sweep(tensor):
print(f"\n Bit-Width Sweep (tensor shape {tensor.shape}):")
print(f" {'Bits':>6} {'Levels':>8} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12} {'Compression':>12}")
print(f" {'-'*64}")
results = []
for bits in [2, 3, 4, 8, 16]:
q, s = quantize_per_channel(tensor, bits, axis=0)
recon = dequantize_per_channel(q, s, axis=0)
err = quantization_error(tensor, recon)
levels = 2 ** bits
compression = 32.0 / bits
print(f" {bits:>6} {levels:>8} {err['mse']:>14.8f} {err['snr_db']:>10.2f} {err['cosine_similarity']:>12.8f} {compression:>11.1f}x")
results.append({"bits": bits, "levels": levels, "error": err, "compression": compression})
return results
Step 5: 민감도 실험
트랜스포머(transformer)의 서로 다른 부분을 양자화하는 상황을 시뮬레이션하고, 어떤 구성 요소가 가장 민감한지 측정합니다. 가중치 < 활성값 < KV 캐시 < 어텐션 순서의 민감도 계층을 그대로 보여 줍니다.
def simulate_transformer_layer(input_data, weights, kv_scale=1.0):
hidden = input_data @ weights["qkv"]
seq_len = hidden.shape[1]
d_model = weights["qkv"].shape[1] // 3
q, k, v = hidden[:, :, :d_model], hidden[:, :, d_model:2*d_model], hidden[:, :, 2*d_model:]
attn_scores = (q @ k.transpose(0, 2, 1)) / np.sqrt(d_model) * kv_scale
attn_max = np.max(attn_scores, axis=-1, keepdims=True)
attn_exp = np.exp(attn_scores - attn_max)
attn_weights = attn_exp / np.sum(attn_exp, axis=-1, keepdims=True)
attn_output = attn_weights @ v
output = attn_output @ weights["out"]
return output, {"q": q, "k": k, "v": v, "attn_scores": attn_scores,
"attn_weights": attn_weights, "attn_output": attn_output}
def sensitivity_experiment(batch_size=2, seq_len=16, d_model=64, num_bits=8):
np.random.seed(42)
input_data = np.random.randn(batch_size, seq_len, d_model) * 0.1
weights = {
"qkv": np.random.randn(d_model, 3 * d_model) * (2.0 / d_model) ** 0.5,
"out": np.random.randn(d_model, d_model) * (2.0 / d_model) ** 0.5,
}
baseline_output, baseline_internals = simulate_transformer_layer(input_data, weights)
experiments = {}
q_qkv, s_qkv = quantize_per_channel(weights["qkv"], num_bits, axis=0)
q_out, s_out = quantize_per_channel(weights["out"], num_bits, axis=0)
quantized_weights = {
"qkv": dequantize_per_channel(q_qkv, s_qkv, axis=0),
"out": dequantize_per_channel(q_out, s_out, axis=0),
}
weight_quant_output, _ = simulate_transformer_layer(input_data, quantized_weights)
experiments["Weights only"] = quantization_error(baseline_output, weight_quant_output)
_, fresh_internals = simulate_transformer_layer(input_data, weights)
q_act, s_act = quantize_per_channel(
fresh_internals["attn_output"].reshape(-1, d_model), num_bits, axis=0
)
quant_attn_out = dequantize_per_channel(q_act, s_act, axis=0).reshape(batch_size, seq_len, d_model)
act_quant_output = quant_attn_out @ weights["out"]
experiments["Activations only"] = quantization_error(baseline_output, act_quant_output)
q_k, s_k = quantize_per_channel(fresh_internals["k"].reshape(-1, d_model), num_bits, axis=0)
q_v, s_v = quantize_per_channel(fresh_internals["v"].reshape(-1, d_model), num_bits, axis=0)
quant_k = dequantize_per_channel(q_k, s_k, axis=0).reshape(batch_size, seq_len, d_model)
quant_v = dequantize_per_channel(q_v, s_v, axis=0).reshape(batch_size, seq_len, d_model)
attn_scores_kv = (fresh_internals["q"] @ quant_k.transpose(0, 2, 1)) / np.sqrt(d_model)
attn_max_kv = np.max(attn_scores_kv, axis=-1, keepdims=True)
attn_exp_kv = np.exp(attn_scores_kv - attn_max_kv)
attn_weights_kv = attn_exp_kv / np.sum(attn_exp_kv, axis=-1, keepdims=True)
kv_quant_output = (attn_weights_kv @ quant_v) @ weights["out"]
experiments["KV cache only"] = quantization_error(baseline_output, kv_quant_output)
noise_scale = np.std(fresh_internals["attn_scores"]) * 0.05
noisy_scores = fresh_internals["attn_scores"] + np.random.randn(*fresh_internals["attn_scores"].shape) * noise_scale
noisy_max = np.max(noisy_scores, axis=-1, keepdims=True)
noisy_exp = np.exp(noisy_scores - noisy_max)
noisy_weights = noisy_exp / np.sum(noisy_exp, axis=-1, keepdims=True)
attn_quant_output = (noisy_weights @ fresh_internals["v"]) @ weights["out"]
experiments["Attention logits (5% noise)"] = quantization_error(baseline_output, attn_quant_output)
print(f"\n Sensitivity Experiment ({num_bits}-bit quantization):")
print(f" {'Component':<30} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12}")
print(f" {'-'*68}")
for name, err in sorted(experiments.items(), key=lambda x: x[1]["mse"]):
print(f" {name:<30} {err['mse']:>14.8f} {err['snr_db']:>10.2f} {err['cosine_similarity']:>12.8f}")
return experiments
Step 6: GPTQ 시뮬레이션
GPTQ는 헤시안을 사용해 반올림 오차를 어떻게 분배할지 결정하면서 열을 하나씩 양자화합니다. 여기서는 핵심 아이디어만 잡은 단순화 버전을 만듭니다. 보정 데이터로 가중치 중요도를 측정한 뒤, 덜 중요한 가중치를 더 공격적으로 양자화한다는 발상입니다.
def simulated_gptq(weight_matrix, calibration_inputs, num_bits=4):
n_in, n_out = weight_matrix.shape
qmin = -(2 ** (num_bits - 1))
qmax = 2 ** (num_bits - 1) - 1
H = np.zeros((n_in, n_in))
for x in calibration_inputs:
x = x.reshape(-1, 1) if x.ndim == 1 else x
for row in range(x.shape[0]):
xi = x[row].reshape(-1, 1)
H += xi @ xi.T
H /= len(calibration_inputs)
H += np.eye(n_in) * 1e-4
weight_importance = np.diag(H)
quantized = np.zeros_like(weight_matrix, dtype=np.int32)
scales = np.zeros(n_out)
errors = np.zeros(n_out)
W = weight_matrix.copy()
for col in range(n_out):
w_col = W[:, col]
abs_max = np.max(np.abs(w_col))
if abs_max == 0:
scales[col] = 1.0
continue
scale = abs_max / qmax
scales[col] = scale
q_col = np.clip(np.round(w_col / scale), qmin, qmax).astype(np.int32)
quantized[:, col] = q_col
quant_error = w_col - q_col * scale
errors[col] = np.sqrt(np.mean(quant_error ** 2))
if col < n_out - 1:
importance_weights = weight_importance / (np.max(weight_importance) + 1e-10)
for next_col in range(col + 1, min(col + 4, n_out)):
compensation = quant_error * importance_weights * 0.1
W[:, next_col] += compensation
return quantized, scales, {"column_errors": errors,
"mean_error": float(np.mean(errors)),
"max_error": float(np.max(errors))}
def dequantize_gptq(quantized, scales):
result = np.zeros_like(quantized, dtype=np.float64)
for col in range(quantized.shape[1]):
result[:, col] = quantized[:, col] * scales[col]
return result
Step 7: AWQ 시뮬레이션
AWQ는 큰 활성값과 곱해지는 두드러진 가중치(salient weight)를 찾아, 양자화 전에 그 가중치들을 키워(scale up) 보호합니다.
def simulated_awq(weight_matrix, calibration_inputs, num_bits=4, salient_fraction=0.01):
n_in, n_out = weight_matrix.shape
qmin = -(2 ** (num_bits - 1))
qmax = 2 ** (num_bits - 1) - 1
activation_magnitudes = np.zeros(n_in)
for x in calibration_inputs:
if x.ndim == 1:
activation_magnitudes += np.abs(x)
else:
activation_magnitudes += np.mean(np.abs(x), axis=0)
activation_magnitudes /= len(calibration_inputs)
n_salient = max(1, int(n_in * salient_fraction))
salient_indices = np.argsort(activation_magnitudes)[-n_salient:]
scale_factors = np.ones(n_in)
for idx in salient_indices:
col_max = np.max(np.abs(weight_matrix[idx, :]))
if col_max > 0:
scale_factors[idx] = min(4.0, 1.0 / (col_max + 1e-8) * np.mean(np.abs(weight_matrix)))
scaled_weights = weight_matrix * scale_factors.reshape(-1, 1)
quantized, scales = quantize_per_channel(scaled_weights, num_bits, axis=0)
dequantized = dequantize_per_channel(quantized, scales, axis=0)
result = dequantized / scale_factors.reshape(-1, 1)
err = quantization_error(weight_matrix, result)
return result, {"salient_indices": salient_indices,
"scale_factors": scale_factors[salient_indices],
"error": err,
"n_salient": n_salient}
Step 8: 전체 파이프라인
모든 조각을 한데 연결합니다. 같은 가중치 행렬에서 순진한 양자화(naive), 채널 단위, GPTQ, AWQ를 비교합니다.
def full_quantization_comparison(d_in=256, d_out=512, num_bits=4, n_calibration=32):
np.random.seed(42)
weight = np.random.randn(d_in, d_out) * 0.02
outlier_rows = np.random.choice(d_in, size=5, replace=False)
weight[outlier_rows] *= 10
calibration = [np.random.randn(8, d_in) * 0.1 for _ in range(n_calibration)]
q_naive, s_naive = quantize_symmetric(weight, num_bits)
recon_naive = dequantize_symmetric(q_naive, s_naive)
err_naive = quantization_error(weight, recon_naive)
q_pc, s_pc = quantize_per_channel(weight, num_bits, axis=0)
recon_pc = dequantize_per_channel(q_pc, s_pc, axis=0)
err_pc = quantization_error(weight, recon_pc)
q_gptq, s_gptq, gptq_info = simulated_gptq(weight, calibration, num_bits)
recon_gptq = dequantize_gptq(q_gptq, s_gptq)
err_gptq = quantization_error(weight, recon_gptq)
recon_awq, awq_info = simulated_awq(weight, calibration, num_bits)
err_awq = awq_info["error"]
print(f"\n Full Quantization Comparison ({num_bits}-bit, {d_in}x{d_out} matrix)")
print(f" Matrix has {len(outlier_rows)} outlier rows (10x scale)")
print()
print(f" {'Method':<20} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12}")
print(f" {'-'*58}")
print(f" {'Naive per-tensor':<20} {err_naive['mse']:>14.8f} {err_naive['snr_db']:>10.2f} {err_naive['cosine_similarity']:>12.8f}")
print(f" {'Per-channel':<20} {err_pc['mse']:>14.8f} {err_pc['snr_db']:>10.2f} {err_pc['cosine_similarity']:>12.8f}")
print(f" {'Simulated GPTQ':<20} {err_gptq['mse']:>14.8f} {err_gptq['snr_db']:>10.2f} {err_gptq['cosine_similarity']:>12.8f}")
print(f" {'Simulated AWQ':<20} {err_awq['mse']:>14.8f} {err_awq['snr_db']:>10.2f} {err_awq['cosine_similarity']:>12.8f}")
test_input = np.random.randn(4, d_in) * 0.1
baseline = test_input @ weight
output_naive = test_input @ recon_naive
output_pc = test_input @ recon_pc
output_gptq = test_input @ recon_gptq
output_awq = test_input @ recon_awq
print(f"\n End-to-End Output Error (matmul with test input):")
print(f" {'Method':<20} {'Output MSE':>14} {'Output Cosine':>14}")
print(f" {'-'*50}")
for name, output in [("Naive", output_naive), ("Per-channel", output_pc),
("GPTQ", output_gptq), ("AWQ", output_awq)]:
out_err = quantization_error(baseline, output)
print(f" {name:<20} {out_err['mse']:>14.8f} {out_err['cosine_similarity']:>14.8f}")
return {"naive": err_naive, "per_channel": err_pc, "gptq": err_gptq, "awq": err_awq}
def memory_calculator(num_params_billions, bits_per_param):
bytes_per_param = bits_per_param / 8
total_bytes = num_params_billions * 1e9 * bytes_per_param
total_gb = total_bytes / (1024 ** 3)
return total_gb
def print_memory_table():
print("\n Memory Requirements by Model and Precision:")
print(f" {'Model':<15} {'FP32':>8} {'FP16':>8} {'FP8':>8} {'INT8':>8} {'INT4':>8} {'INT2':>8}")
print(f" {'-'*64}")
for name, params in [("7B", 7), ("13B", 13), ("34B", 34), ("70B", 70), ("405B", 405)]:
fp32 = memory_calculator(params, 32)
fp16 = memory_calculator(params, 16)
fp8 = memory_calculator(params, 8)
int8 = memory_calculator(params, 8)
int4 = memory_calculator(params, 4)
int2 = memory_calculator(params, 2)
print(f" {name:<15} {fp32:>7.1f}G {fp16:>7.1f}G {fp8:>7.1f}G {int8:>7.1f}G {int4:>7.1f}G {int2:>7.1f}G")
if __name__ == "__main__":
np.random.seed(42)
print("=" * 70)
print("QUANTIZATION: MAKING MODELS FIT")
print("=" * 70)
print("\nSTEP 1: Number Format Comparison")
print("-" * 50)
for val in [0.1, 3.14159, -0.00073, 42.5, 0.0000012]:
display_format_comparison(val)
print("\n\nSTEP 2: Memory Requirements")
print("-" * 50)
print_memory_table()
print("\n\nSTEP 3: Quantization Methods Comparison")
print("-" * 50)
weight_matrix = np.random.randn(128, 256) * 0.02
weight_matrix[0] *= 15
weight_matrix[42] *= 8
compare_quantization_methods(weight_matrix, num_bits=8)
compare_quantization_methods(weight_matrix, num_bits=4)
print("\n\nSTEP 4: Bit-Width Sweep")
print("-" * 50)
sweep_tensor = np.random.randn(64, 128) * 0.05
bit_width_sweep(sweep_tensor)
print("\n\nSTEP 5: Sensitivity Experiment")
print("-" * 50)
print("\n INT8:")
sensitivity_experiment(num_bits=8)
print("\n INT4:")
sensitivity_experiment(num_bits=4)
print("\n\nSTEP 6: GPTQ vs AWQ vs Naive (INT4)")
print("-" * 50)
full_quantization_comparison(d_in=256, d_out=512, num_bits=4)
print("\n\nSTEP 7: Distribution Analysis")
print("-" * 50)
np.random.seed(0)
simulated_weights = np.random.randn(1000) * 0.02
abs_vals = np.abs(simulated_weights)
pct_in_range = np.mean(abs_vals < 0.1) * 100
print(f"\n Simulated weight distribution (1000 params, std=0.02):")
print(f" Weights in [-0.1, 0.1]: {pct_in_range:.1f}%")
print(f" Weights in [-0.05, 0.05]: {np.mean(abs_vals < 0.05) * 100:.1f}%")
print(f" Weights in [-0.01, 0.01]: {np.mean(abs_vals < 0.01) * 100:.1f}%")
print(f" Max absolute value: {np.max(abs_vals):.6f}")
print(f" Mean absolute value: {np.mean(abs_vals):.6f}")
histogram = np.histogram(simulated_weights, bins=20)
print(f"\n Weight histogram:")
max_count = max(histogram[0])
for i in range(len(histogram[0])):
bar_len = int(histogram[0][i] / max_count * 40)
lo = histogram[1][i]
hi = histogram[1][i + 1]
print(f" [{lo:>7.4f}, {hi:>7.4f}] {'#' * bar_len} ({histogram[0][i]})")
print("\n\n" + "=" * 70)
print("DONE")
print("=" * 70)
사용해보기
AutoGPTQ로 양자화하기
AutoAWQ로 양자화하기
GGUF로 변환하기
vLLM으로 서빙하기
vLLM은 AWQ와 GPTQ 모델을 기본 지원합니다. 행렬곱 중의 역양자화(dequantization)를 처리하고, KV 캐시에는 페이지 어텐션(paged attention)을 사용합니다. H100에서 FP8을 사용하려면 --dtype float8_e4m3fn을 추가합니다.
산출물 만들기
이 레슨은 outputs/skill-quantization.md를 만듭니다. 이 스킬은 적절한 양자화 전략을 고르기 위한 의사결정 프레임워크입니다. 모델 크기, 대상 하드웨어, 품질 요구사항을 입력하면 어떤 형식, 기법, 검증 절차를 써야 하는지 알려 줍니다. 메모리 예산 계산, 구성 요소별 정밀도 권장값, vLLM, llama.cpp, TensorRT-LLM 배포 레시피를 함께 포함합니다.
연습문제
-
그룹 단위 양자화(group quantization)를 구현합니다. 채널마다 스케일 하나를 쓰는 대신, 채널 안의 128개 가중치 그룹마다 스케일 하나를 사용합니다. 실제 GPTQ와 AWQ가 이런 방식으로 동작합니다. 같은 가중치 행렬에서 그룹 크기 32, 64, 128, 256을 비교합니다. 그룹이 작을수록 품질은 좋아지지만 스케일 팩터 저장 오버헤드가 커집니다. (난이도: 쉬움)
-
혼합 정밀도 양자화기(mixed-precision quantizer)를 만듭니다. 다층 네트워크의 첫 층과 마지막 층은 INT8로, 중간 층은 INT4로 양자화합니다. 전체 출력 품질을 균일 INT4, 균일 INT8과 비교하고, 모든 층을 INT8로 두었을 때 대비 메모리 절감을 측정합니다. (난이도: 중간)
-
QAT용 직통 추정기(straight-through estimator; STE)를 구현합니다. 회귀 작업을 학습하는 단순한 2층 네트워크의 순전파에 가짜 양자화/역양자화 연산을 삽입합니다. 일반 학습 후 PTQ로 INT4까지 낮춘 모델과, 처음부터 QAT로 학습한 모델의 최종 손실을 비교합니다. (난이도: 어려움)
-
LLM.int8()에서 영감을 받은 이상치 인지 양자화기(outlier-aware quantizer)를 만듭니다. 활성값 크기가 평균의 6배를 넘는 채널을 감지하고, 해당 채널은 FP16으로 유지하고 나머지는 INT8로 양자화합니다. Step 5의 트랜스포머 층에서 이상치 임계값을 3배, 6배, 10배로 바꿔 가며 전구간 품질(end-to-end quality)을 측정합니다. (난이도: 중간)
-
양자화 품질 대시보드를 구현합니다. 가중치 행렬이 주어지면 다음을 계산해 표시합니다. 가중치 분포 히스토그램, 양자화 오차 분포, 채널별 스케일 팩터, 가장 나쁘게 양자화된 채널(복원 오차가 가장 큰 채널), 100개 무작위 입력에서 측정한 원본 출력과 양자화 출력 사이의 코사인 유사도입니다. 어떤 채널을 더 높은 정밀도로 유지해야 하는지 찾습니다. (난이도: 어려움)
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| FP16 | "절반 정밀도(half precision)" | 지수 5비트와 가수 10비트를 가진 16비트 float이며, 최대값은 65,504이고 표준 추론 형식이다. |
| BF16 | "Brain float" | 지수 8비트, 가수 7비트의 16비트 float이며, FP32와 같은 범위를 갖고 Google이 학습용으로 설계했다. |
| FP8 | "8비트 float" | E4M3(추론용, 정밀도가 더 큼)와 E5M2(학습용, 범위가 더 큼) 두 변형이 있으며 H100에서 네이티브로 지원된다. |
| INT8 | "8비트 정수" | -128부터 127까지 균일 간격의 256개 값을 갖고, float에서 매핑하려면 스케일 팩터가 필요하다. |
| INT4 | "4비트 정수" | 총 16개 수준만 있으며, 품질을 유지하려면 GPTQ, AWQ 같은 정교한 기법이 필요하다. |
| 채널 단위 양자화(per-channel quantization) | "행마다 스케일 하나" | 텐서 전체가 아니라 출력 채널마다 별도 스케일 팩터를 써서 오차를 크게 줄인다. |
| GPTQ | "헤시안(Hessian) 기법" | 2차 정보를 사용해 출력 오차를 최소화하는 학습 후 양자화 기법으로, 한 층씩 처리한다. |
| AWQ | "활성값 인지(activation-aware)" | 큰 활성값과 곱해지는 두드러진 가중치(salient weight)를 양자화 전에 스케일링해 보호한다. |
| GGUF | "llama.cpp 형식" | 혼합 정밀도 층을 담는 자기 완결적 모델 파일이며, CPU와 Apple Silicon 추론에 최적화되어 있다. |
| PTQ | "학습 후 양자화" | 학습된 모델의 가중치를 재학습 없이 낮은 정밀도로 변환한다. 빠르지만 극단적 압축에는 한계가 있다. |
| QAT | "학습 중 양자화" | 순전파에 가짜 양자화를 넣어 모델이 반올림을 견디도록 학습시킨다. INT4/INT2에서 더 좋다. |
| 보정 데이터(calibration data) | "128개 예제" | 스케일 팩터 설정을 위해 모델에 통과시키는 작은 데이터셋이다. |
| 스케일 팩터(scale factor) | "곱하는 값" | 부동소수점 범위와 정수 범위를 오갈 때 쓰며, float_val = int_val * scale이 성립한다. |
| Perplexity delta | "얼마나 나빠졌는가" | 원본 모델과 양자화 모델의 perplexity 차이이며, 0.5 미만은 훌륭하고 2.0 초과는 문제다. |
더 읽을거리