AI 공부

7. LLM 양자화(Quantization): 원리부터 코드까지 - GPU 메모리는 왜 4배 줄어들까?

kdb1248 2026. 3. 22. 17:23

목차 

1. LLM 파라미터는 왜 “숫자”일까?
2. 양자화(Quantization)
3. 양자화 유형
4. 코드로 보는 양자화

5. 배운 점/느낀 점

 


Day 14 - 양자화 (Quantization) 

1. LLM 파라미터는 왜 “숫자”일까?

1) 핵심 요약

  • LLM의 모든 파라미터(weight)는 실수(float)
  • 보통:
    • 학습: float32 / bfloat16
    • 추론: float16 / int8 / int4
  • 이유:
    • 정밀도 vs 메모리/속도 트레이드오프

 

2) LLM 파라미터는 왜 실수(float)인가?

예: (Llama 3 70B → 파라미터 700억 개)

LLM 내부에는 여러 개의 파라미터(가중치)가 있다.

모든 파라미터는 실수로 저장된다:

예시:
W = 0.12837
b = -0.05392
...

 

 

✔ 신경망은 모든 계산이 “행렬 곱 + 비선형 변환”이기 때문

→ 행렬의 원소가 파라미터, 즉 실수값.

 

 

3) float32 vs float16 차이

[실수에는 “정밀도”가 필요하다]

컴퓨터는 실수를 저장하기 위해 IEEE 754 규격을 사용한다.

  • float32 → 32비트
  • float16 → 16비트
  • bfloat16 → 16비트(표현 범위는 커지고 정밀도는 작음)

=> 비트 수가 많을수록 더 정밀하게 수를 표현할 수 있음.


IEEE754 규격?

: 부호(sign) + 지수(exponent) + 가수(fraction/mantissa) 조합으로 표현되는 이진수

 

Float32 = 32비트 구성

[ 1비트(sign) ][ 8비트(exponent) ][ 23비트(fraction) ]

 Float16 = 16비트 구성

[ 1비트(sign) ][ 5비트(exponent) ][ 10비트(fraction) ]

 


 

예시: float32에서 표현 가능하지만 float16에서 손실되는 경우

실수 값:

0.123456789

float32 저장:

00111101111111101101111100101011

float16 저장(정밀 손실 발생):

0011110001111100

  • float32는 7자리 정밀도 → 거의 정확 저장
  • float16은 3자리 정밀도 → “0.1234” 정도로 반올림됨
    → LLM 추론에서 부동소수점 오차가 발생하는 이유

 

[메모리 요구량이 다름]

float32가 4 byte인 이유?

=> IEEE 754 단정도(single precision) 부동소수점 규격이 32비트로 딱 정해져 있기 때문.

구성 요소 비트 수
부호(sign) 1 bit
지수(exponent) 8 bit
가수(mantissa) 23 bit

합계 = 1 + 8 + 23 = 32 bit = 4 byte

 

 

예를 들어 70B 모델을 float32로 저장하면:

70,000,000,000 parameters × 4 bytes = 280GB

float16으로 바꾸면:

70,000,000,000 parameters × 2 bytes = 140GB

무려 140GB 절약.

 

그래서 훈련은 float32 or bfloat16(정밀 중요)

추론은 float16, float8 등(속도 중요)으로 compressed 하는 것

 

 

 

4) 실제론 어떤 정밀도가 쓰일까?

Training(훈련)

  • float32 또는 bfloat16
  • 혼합정밀도(Mixed precision):
    • 연산은 float16으로 빠르게
    • 중요한 weight 업데이트는 float32로 안정성 확보

Inference(추론)

  • float16
  • NF4, INT8, INT4 등 더 작은 정밀도로도 가능 (quantization)

 

[유형별 차이]

유형 byte 특징
FP32 4 byte 학습 시 안정적
FP16 / BF16 2 byte 학습·추론 모두 널리 사용
INT8 1 byte 추론용
NF4 / INT4 0.5 byte LLM 추론 최적화
INT2/1 실험적 극한의 경량화

 

[FP8/INT8 차이]

항목 FP8 INT8
데이터 형태 실수(float) 정수(integer)
연산 방식 부동소수점 연산 정수 연산
표현력 훨씬 풍부 상대적으로 낮음
학습 가능 (H100 등에서 적극 지원) 어려움 (분포 clipping 필요)
목적 학습 & 추론 거의 추론용
정밀도 손실 적음

=> FP8은 ‘float를 그대로 줄인 버전’이라 학습·추론 품질이 좋다.

=> INT8은 정수라 모델 품질 손실 가능성이 더 크다.

 


2. 양자화(Quantization)

1) 요약

- 양자화(Quantization)는 LLM 파라미터를 더 작은 비트 수(예: 16bit→8bit→4bit)로 줄여서

- 모델의 메모리 사용량을 대폭 줄이고, 추론 속도를 빠르게 만드는 기술

- 정밀도가 조금 줄어들지만, 추론(inference)에서는 대부분 큰 문제없이 훨씬 효율적으로 모델을 돌릴 수 있음

2)  직관적 비유 (by GPT)


LLM의 파라미터는 "초정밀 레시피" 같은 것

  • float32 → 재료를 mg 단위까지 정밀하게 측정하는 셰프
  • float16 → 0.1g 단위로 측정 (조금 덜 정밀하지만 문제 없음)
  • int8 → 1g 단위
  • int4 → 5g 단위 (대략적인 느낌)

훈련: 맛을 완벽히 조절해야 하므로 float32/float16

추론: 레시피는 이미 완성됐으므로 int8/int4가 충분

 

 

3) 양자화 사용 이유?

① 메모리 절감
=> 70B 모델도 int4면 35GB → 1장 GPU로 로딩 가능
(양자화 잘하면 큰 모델도 온디바이스로도 돌릴 수 있음)

② 추론 속도 증가
정수 연산은 실수 연산보다 훨씬 빠름

→ TPS 상승, 비용 절감

 

 

 

4) 양자화 작동방식

예를 들어 파라미터가 이렇게 있다고 하자:

W = [ 0.12,  -0.45,  1.03,  0.22 ]

float32로 저장:

→ 값 그대로 32비트 실수 저장

→ 파라미터 하나당 4 bytes 사용


✔ Step 1: 범위를 찾는다

W의 최소/최대:

min = -0.45
max = 1.03


✔ Step 2: 더 작게 저장할 수 있는 구간으로 나눈다

예: **int8 양자화(8비트)**라면 표현 가능한 정수는:

-128 ~ +127   (256개 값)


✔ Step 3: 실수를 정수로 매핑한다

각 실수 w를 다음 식으로 정수 q에 매핑한다:

q = round( (w - min) / (max - min) * 255 )

예를 들어 0.12는 대략:

q ≈ 86


✔ Step 4: 저장할 때는 정수만 저장

이제 파라미터는 이렇게 저장됨:

[86,  0,  255, 103]

이게 바로 양자화된 weight.


✔ Step 5: 추론 시 역양자화(dequantize)

모델이 계산할 때 정수를 실수로 복원:

w_est = min + (q / 255) * (max - min)

 


5) 학습과 추론(infernece)에서 서로 다른 비트수 진행이 가능한 이유?
(어떻게 FP32 → INT8/INT4 추론이 가능한가?)

✔ 1단계. 학습(Training)은 Float 정밀도가 필요

학습에서는 아래를 반복한다:

Weight += learning_rate * gradient

여기서 gradient는 매우 작은 값(1e-7, 1e-9) 일 때도 많음

그래서 정밀한 실수(float32 or BF16)가 아니면 업데이트 자체가 망가진다.

그래서:

  • GPT·Llama·Mistral 학습 → FP32/BF16
  • LoRA 학습 → BF16

만약 INT8로 학습하면?

→ 소금 3g 단위로만 조절하는 셰프 = 학습 자체 불가능.


✔ 2단계. 학습이 끝나면 "최종 weight"는 고정된다

학습이 끝나면 더 이상 weight를 업데이트하지 않는다.

이제 하는 일은:

y = W @ x

행렬 곱 계산만 하면 됨.

업데이트가 없으므로 FP32 정밀도가 필요 없다.


✔ 3단계. 추론용 weight를 변환(양자화)한다

이제 float weight를 정수로 매핑한다:

예:

W_float = [0.12, -0.45, 1.03]
W_int8  = [  86,   -47,  255 ]    # 정규화 후 8비트로 저장
scale, zero_point도 함께 저장

이건 모델 구조를 바꾸는 게 아니라

메모리에 저장되는 weight만 정수형으로 바꾼 것이다.


✔ 4단계. 추론 시 계산은 어떻게 될까? 

방식 1: 역양자화(dequantize) 후 계산

정수 → float로 다시 복구하고

FP16으로 계산한다.

방식 2: 정수 연산만으로 계산 (faster)

예:

W_int8 @ x_int8  → (내부는 INT8 연산)

마지막 출력 단계에서만 float로 변환.

이 방식은 TensorRT-LLM, llama.cpp, Triton kernels, vLLM INT4,

모두 이걸 사용한다.

즉, 학습한 float weight는 그대로 두고,

추론할 때는 정수 버전을 로딩해서 연산하는 것.

 

단계 정밀도 이유
Training FP32/BF16 Gradient 업데이트에 고정밀 필요
Save model FP32/BF16 원본 모델 저장
Quantize INT8/INT4 메모리·속도 최적화
Inference INT8/INT4 weight 업데이트 없음 → 정수 계산 가능

3. 양자화 유형

1) PTQ (Post-Training Quantization)

핵심 요약

  • 이미 학습된 모델을 추론(inference)용으로 나중에 양자화하는 방식.
  • 훈련을 다시 할 필요 없음 → 가장 쉬움 & 비용 거의 없음.

특징

  • 학습 끝난 모델을 INT8/INT4로 변환
  • gradient 업데이트 없음
  • 성능 약간 떨어질 수 있음 (특히 4-bit 이하일 때)
  • LLM에서 제일 널리 쓰임

대표 기법

  • GPTQ
  • AWQ
  • GGUF 양자화 (llama.cpp, MLC-LLM 등)

 2) QAT (Quantization-Aware Training)

핵심 요약

  • 학습 자체를 “양자화될 것을 알고” 훈련하는 방식.
  • 훈련 중에 weight/activation을 INT8/INT4처럼 흉내 내며 배움 → 성능 손실 최소.

 특징

  • 가장 높은 정확도
  • 하지만 훈련 비용이 매우 큼
  • 대형 LLM에는 거의 사용되지 않음 (학습비용이 너무 커서)

언제 쓰나?

  • MobileNet, Vision 모델 (엣지 AI 모델)
  • 임베디드/칩 기반 모델

 

3) QLoRA (Quantized LoRA Fine-tuning)

핵심 요약

  • 기존의 거대한 LLM은 4bit로 압축(int4)해서 로드
  • 미세튜닝은 LoRA(=추가된 작은 랭크 행렬)만 BF16/FP16으로 학습
  • 원본 weight는 절대 건드리지 않음

이 덕분에 70B 모델도 단일 GPU(48GB/80GB VRAM)에서 파인튜닝 가능.

왜 QLoRA가 중요한가?

int4(양자화된 base 모델) ↔ bf16(LoRA 어댑터)

 

[저장은 4bit, 계산은 FP16로 하는 이유?]

이유 1) GPU는 matmul을 FP16/BF16/FP32로 최적화

Tensor Core는 FP16 연산에 최적화되어 있다.

4bit 연산은 보통 지원되지만 속도/정확도 면에서 이점이 적다.

그래서 저장은 4bit, 계산은 FP16이 가장 효율적.

 

이유 2) 정확도 유지

4bit weight 자체로 곱셈하면 precision loss가 너무 크다.

→ FP16으로 복원하고 연산하면

→ 거의 full-precision 모델과 유사한 성능을 얻는다.

QLoRA가 성능을 유지하는 이유가 바로 이것.

이유 3) 메모리 절감

저장은 4bit로 해서 메모리 절감, 작은 GPU로도 큰 모델 이용 가능

GPU VRAM 용량이 모델 크기를 결정한다
70B float16 = 140GB → H100 여러 장 필요 (1장에 80GB)
70B int4 = 약 35GB → 한 장으로도 가능


4. 코드로 보는 양자화

1) 모델 4비트(NF4) 로딩 설정

# Quantization Config - this allows us to load the model into memory and use less memory

quant_config = BitsAndBytesConfig(
    load_in_4bit=True,  # 4비트 로드 (모델 가중치를 4비트 형태(int4-ish)로 메모리에 로드할지 여부)
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_quant_type="nf4"
)

① load_in_4bit=True

모델 가중치를 4비트 형태(int4-ish)로 메모리에 로드할지 여부.

  • FP16: 2 byte
  • FP32: 4 byte
  • 4bit: 0.5 byte

즉 메모리 사용량이 FP16 대비 4배 줄어든다.

 

② bnb_4bit_use_double_quant=True

더블 양자화(Double Quantization) 사용 여부

쉽게 말해:

  • 1차 양자화로 4bit로 줄이고
  • 2차 양자화로 scale 값까지 압축해 메모리를 더 줄임

효과:

  • 메모리를 조금 더 줄임 (5~10% 정도 추가 절감)
  • 정확도가 크게 떨어지지 않음

이 옵션은 대부분 “켜는 게 기본값”으로 여겨짐.

 

왜 scale을 또 양자화해야 하나?

4bit 양자화는 이렇게 동작함:

실수 weight들 → (scale, zero_point, 4bit 값들)

즉, 가중치 W는 다음처럼 저장됨:

 

W ≈ scale × (q - zero_point)

 

여기서 중요한 점:

  • 4bit q 값은 압축됨 (0~15니까 4bit)
  • scale은 float16 / float32로 저장됨 → 여기가 아직 무거움

그러면 실제 메모리 구조는:

항목 크기

q (4bit 값들) 아주 작음
scale FP16/Fp32라 커서 메모리 차지 큼
zero_point 작음

문제: scale이 너무 커서 전체 최적화된 건 아님

그래서 나온 것이 Double Quantization


Double Quantization 아이디어

✔ 핵심 아이디어

 

“scale 값도 다시 압축하면 더 압축되잖아?”

 

기존:

weight block → scale(float16), q(4bit values)

더블 양자화:

weight block → scale_q(4bit or 8bit), scale_scale(float16)

즉,

  • 원래 weight들을 4bit로 양자화하기 위해 쓰였던 scale 값들을 또 양자화
  • scale 값도 “블록(block)” 단위로 묶어서 더 작은 수(예: 4bit~8bit)로 변환
  • 그 scale 값들을 복원하기 위한 상위 scale 하나만 float 형태로 유지

그래서 “양자화된 scale"이 생기는 것.

scale을 위한 scale

그래서 이름이 Double Quantization.

 

 

③ bnb_4bit_compute_dtype=torch.bfloat16

연산은 bfloat16으로 수행하겠다는 뜻.

 

-> 왜 이렇게 하냐?

4bit로 로드된 가중치라도

추론 중 연산을 4bit로 하면 정확도가 너무 떨어짐.

그래서 연산은 bfloat16(FP16보다 안정적)으로 함:

  • 저장(storage): 4bit
  • 연산(compute): bfloat16

④ bnb_4bit_quant_type="nf4"

NF4(“Normal Float 4”) 양자화 방식 사용.

NF4가 왜 특별한가?

➡ Meta(LLaMA 개발 팀)가 연구한 4비트 중 가장 LLM에 적합한 양자화 방식

➡ 일반 int4보다 훨씬 정확도 손실이 적음

NF4는 데이터 분포를 normal distribution(정규분포)에 맞춰 매핑하기 때문에

LLM의 weight 통계적 특성과 잘 맞아서 높은 품질을 낸다

 

2)  토크나이징 및 텐서 형태로 GPU에 올리는 과정

# Tokenizer

tokenizer = AutoTokenizer.from_pretrained(LLAMA)
tokenizer.pad_token = tokenizer.eos_token
inputs = tokenizer.apply_chat_template(messages, return_tensors="pt").to("cuda")

 

① tokenizer = AutoTokenizer.from_pretrained(LLAMA)

  • 역할: 사전에 학습된 LLAMA 모델에 맞는 토크나이저를 불러옵니다.

②  tokenizer.pad_token = tokenizer.eos_token

  • 역할: 패딩 토큰(pad_token)이 없는 모델(예: Llama 계열)을 위한 설정
  • eos_token은 문장의 끝을 의미하는 End Of Sentence 토큰이에요.
  • Llama는 원래 pad 토큰이 없어서, 패딩이 필요한 상황(배치 입력 등)에 eos_token을 대신 사용하게끔 설정한 것.

③  inputs = tokenizer.apply_chat_template(messages, return_tensors="pt").to("cuda")

  • 역할: 대화형(chat) 모델용 입력 포맷을 만들어 PyTorch 텐서로 변환한 후 GPU로 이동

apply_chat_template(messages, ...)

  • messages는 아래처럼 대화 기록이 들어있는 리스트
  • messages = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "안녕!"}, ]
  • 이 함수는 모델이 학습될 때 사용된 채팅 포맷(template)을 그대로 적용해서 텍스트를 하나의 prompt로 합칩니다. 
  • <|system|> You are a helpful assistant. <|user|> 안녕! <|assistant|>

return_tensors="pt"

  • 결과를 PyTorch 텐서(torch.Tensor)로 반환합니다.
  • 모델 입력 시 model(**inputs) 형태로 바로 넣을 수 있습니다.

 .to("cuda")

  • 생성된 텐서를 GPU 메모리로 전송합니다.
  • 즉, 이후 model.generate() 등을 GPU에서 바로 수행할 수 있게 합니다.

 

3) 모델 로딩 + 양자화 적용 + GPU 자동 배치 + 실제 메모리 사용량 출력을 한 번에 수행

 

 AutoModelForCausalLM.from_pretrained()

  • Hugging Face 모델을 불러와서 언어모델(Causal LM) 형태로 로딩.
  • 즉, 프롬프트 → 다음 토큰 생성 같은 “생성형 모델”로 동작하는 구조.

  LLAMA

  • 모델 이름 문자열 (예: "meta-llama/Llama-3.1-8B-Instruct").
  • 이 문자열을 기반으로 Hugging Face에서 모델 weight, config 등을 다운로드함.

  device_map="auto"

  • 모델을 가능한 디바이스로 자동 배치하는 옵션.
  • 실행 환경이 GPU라면 GPU/CPU 메모리를 보고 자동으로 분배함.

예)

  • GPU 1개 → GPU에 모델 전체 배치
  • GPU 메모리 부족 시 → 일부 레이어 CPU, 일부 레이어 GPU
  • Colab T4 같은 메모리 작은 GPU에서 특히 유용

quantization_config=quant_config

  • 양자화 설정을 적용하면서 모델을 로드하겠다는 의미.
  • 앞에서 만들었던:
quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_quant_type="nf4"
)

이 설정 그대로 적용 → 모델을 4 bit 양자화 상태로 로드해서 메모리를 크게 절약.

 

 model.get_memory_footprint()

  • 모델이 현재 차지하고 있는 전체 메모리(bytes)를 반환함.
    • 텐서 weight
    • 양자화 테이블
    • 옵저버
    • 기타 케시 등 포함

/ 1e6

  • 바이트를 메가바이트(MB) 단위로 변환.

 

=> 아마 8b 모델 크기만 고려 시 아래처럼 나와야 하지만, 일부는 cpu에 모델이 올라가 있어서 1gb 정도만 메모리 PRINT에 찍히는 것으로 보임. 

✔ 기본 weight (4bit)

8B × 0.5 byte = 4GB
 

✔ double quant 적용 시

  • scale도 압축되어, 약 10~20% 추가 절감 (≈ 4GB → 약 3.2 ~ 3.6GB)
✔ device_map="auto" 분산

예를 들어:

  • GPU: 25%
  • CPU: 75%

이면

3.5GB × 0.25 ≈ 0.9GB =>  지금 결과랑 거의 일치

 

5. 배운 점/느낀 점

1. 실제 LLM 파라미터와 정밀도를 더 뜯어보면서, 이래서 특정 모델이 gpu 몇 장 필요해라고 했을 때,
"정밀도에 따라, 훈련용이냐/추론용이냐에 따라서도 다르다고 말하는 이유"를 느낄 수 있었고
대략적인 모델별 gpu 소요량 계산이 어떻게 되는지를 알 수 있었다. 
2. 양자화 쪽을 더 깊게 파보면서
말로 많이 들어왔던 양자화가 실제로 어떻게 동작하는지에 대한 원리와, 왜 학습과 추론(inferenece)에서 서로 다른 정밀도를 사용하는지, 또 추론과정에서도 모델 저장(로딩)과 계산 시에 정밀도를 다르게 가져가는 이유, 어떻게 그게 가능한지를 원리적으로 이해해 볼 수 있었다. 

3. 많이 들어왔던 파인튜닝 기법 중 하나인 QLoRA는 어떻게 동작하는지 단계별로 나눠서 볼 수 있었다.

4. 또한 코드단에서 이러한 양자화가 어떻게 구현이 되어서 실제 GPU에 모델이 올라가고, GPU에 올라가는 메모리 양들을 줄여나가는 지를 볼 수 있던 게 좋았다. 


 

(꾸준히 공부하고, 적을 테니 많은 관심 부탁드립니다.)

 

Profile:

Linkedin