[실습] GPU 기반 LLM 양자화 및 최적화
실습 4 — LLM 양자화 및 최적화(bitsandbytes: INT8와 NF4)
LLM 추론에서 메모리 계산은 매우 엄격하다.
정밀도 파라미터당 바이트 Llama 3 8B 가중치 Llama 3 70B 가중치 —————- ——————- ——————- ——————– fp32 4 32 GB 280 GB fp16 / bf16 2 16 GB 140 GB INT8 1 8 GB 70 GB INT4 / NF4 ~0.5 4 GB 35 GB
RTX 3060 Ti는 8 GB VRAM을 가진다. fp16에서는 Llama 3 8B를 적재할 수 없다. 가중치만 16 GB가 필요하기 때문이다. NF4에서는 가중치를 적재하고도 KV 캐시와 배칭에 사용할 메모리를 남길 수 있다. 양자화는 “H100이 있어야 실행 가능하다”는 조건을 “Notebook에서도 실행 가능하다”는 조건으로 바꾼다.
핵심 양자화 논문
- LLM.int8() (Dettmers et al., 2022) — LLM에서 8비트 추론을 실용화한 논문이다. 이상치 활성화값(activation)을 처리하는 mixed-precision decomposition을 도입해 INT8에서도 거의 품질 저하가 없도록 한다.
- QLoRA (Dettmers et al., 2023) — 정규분포 형태의 가중치에 맞춘 4비트 데이터 타입인 NF4(NormalFloat-4)를 도입한다. 단일 48GB GPU에서 65B 모델을 파인튜닝했다. 오픈 파인튜닝 환경에서 핵심 실험 인프라로 쓰인다.
- GPTQ (Frantar et al., 2022) — second-order 정보를 사용하는 사후 학습 양자화 기법이다. 단순 반올림 방식보다 정확도와 압축률이 좋다. activation-aware 변형으로는 AWQ (Lin et al., 2023)도 참고한다.
측정할 항목
TinyLlama-1.1B-Chat(1.1B 파라미터, 공개 모델, 인증 불필요, fp16
기준 약 2.2 GB)을 세 가지 형식으로 로드하고 비교한다.
- VRAM 사용량 — fp16, INT8, NF4 비교
- 생성 지연 시간 — int8이 실제로 더 느린지 확인한다. 소비자용 GPU에서는 느려질 수 있다.
- 품질 — held-out 영어 데이터의 perplexity로 측정한다. NF4는 일반적으로 fp16 대비 1% 이내의 품질 차이를 보인다.
운영 환경에서 선택 기준
목표 선택 —————————— —————————————- 가능한 가장 빠른 추론 fp16 또는 bf16. A100/H100에서는 TensorRT-LLM INT8/FP8도 사용한다.
8GB GPU에 7B 모델 적재 NF4
메모리 절감과 품질 균형 INT8
파인튜닝 QLoRA: NF4 base + LoRA adapter
프로덕션 4비트 서빙 GPTQ 또는 AWQ로 사전 양자화된 모델 사용 ———————————————————————–
단계 1 — FP16 에서 TinyLlama 불러오기(기준선, the baseline)
import torch
import time
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
# 모델과 토크나이저를 로드
device = torch.device('cuda')
MODEL_ID = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'
# VRAM 계산 함수
def vram_of(model):
"""Sum parameter + buffer bytes, report in GB."""
bytes_ = sum(p.numel() * p.element_size() for p in model.parameters())
bytes_ += sum(b.numel() * b.element_size() for b in model.buffers())
return bytes_ / 1e9
# 토크나이저 로드
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
# fp16 모델 로드
print('Loading fp16 baseline...')
model_fp16 = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float16).to(device)
vram_fp16 = vram_of(model_fp16)
# fp16 모델의 VRAM 사용량과 파라미터 수를 출력
print(f'fp16 model weights: {vram_fp16:.2f} GB on GPU')
print(f' (TinyLlama has {model_fp16.num_parameters()/1e9:.2f}B params × 2 bytes ≈ {model_fp16.num_parameters()*2/1e9:.2f} GB theoretical)')
/usr/local/lib/python3.11/dist-packages/torch/cuda/__init__.py:65: FutureWarning: The pynvml package is deprecated. Please install nvidia-ml-py instead. If you did not install pynvml directly, please report this to the maintainers of the package that installed pynvml for you.
import pynvml # type: ignore[import]
Loading fp16 baseline...
`torch_dtype` is deprecated! Use `dtype` instead!
fp16 model weights: 2.20 GB on GPU
(TinyLlama has 1.10B params × 2 bytes ≈ 2.20 GB theoretical)
단계 2 — bitsandbytes로 INT8 및 NF4 로드
BitsAndBytesConfig는 bitsandbytes를 사용하기 위한 HuggingFace의 간단한
설정 인터페이스다. 이 설정을 from_pretrained에 전달한다.
INT8 (load_in_8bit=True)
LLM.int8()은 논문 방식을 구현한다. 대부분의 linear layer는 INT8로 실행하고, 정밀도 손실이 크게 발생할 수 있는 이상치 특성(feature)는 fp16 fallback으로 처리한다. 대부분의 벤치마크에서 거의 품질 저하가 없다. VRAM 사용량은 fp16의 절반 수준이다.
NF4 (load_in_4bit=True + bnb_4bit_quant_type='nf4')
QLoRA 논문에서 제안한 NormalFloat-4 형식이다. Transformer 가중치가 대체로 정규분포에 가깝다는 점을 이용해 표준 정규분포의 분위수(quantile)에 맞춘 16개 레벨로 각 가중치를 4비트로 인코딩한다. fp16 대비 약 1/4 수준의 VRAM만 사용하며 품질 손실도 예상보다 작다.
fp4 옵션도 있다. 인코딩 방식은 조금 더 단순하지만 일반적으로 NF4가
기본값이며 성능도 더 좋은 경우가 많다.
Double quantization (bnb_4bit_use_double_quant=True)
선택 옵션이다. 양자화에 사용되는 상수까지 다시 양자화해 파라미터당 약 0.4비트를 추가로 절감한다. 비용 대비 효과가 좋아 일반적으로 활성화한다.
# 양자화 모델을 로드하기 전에 FP16 모델이 사용 중인 VRAM을 해제
# 생성 벤치마크 단계에서 FP16 모델을 다시 로드함
print('Loading INT8 model...')
bnb_int8 = BitsAndBytesConfig(load_in_8bit=True)
model_int8 = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb_int8, device_map='cuda')
vram_int8 = vram_of(model_int8)
print(f'INT8 weights: {vram_int8:.2f} GB')
# INT8 모델을 해제하고 NF4 모델을 로드함
del model_int8
torch.cuda.empty_cache()
print('Loading NF4 model...')
# NF4 양자화 구성 설정
bnb_nf4 = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type='nf4',
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16,
)
# NF4 모델 로드
model_nf4 = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb_nf4, device_map='cuda')
vram_nf4 = vram_of(model_nf4)
print(f'NF4 weights: {vram_nf4:.2f} GB')
Loading INT8 model...
INT8 weights: 1.23 GB
Loading NF4 model...
NF4 weights: 0.75 GB
# VRAM 비교 결과 출력
print(f'\n=== VRAM comparison ===')
print(f'fp16 : {vram_fp16:.2f} GB (baseline)')
print(f'int8 : {vram_int8:.2f} GB ({100*vram_int8/vram_fp16:.0f}% of fp16)')
print(f'nf4 : {vram_nf4:.2f} GB ({100*vram_nf4/vram_fp16:.0f}% of fp16)')
print()
print('Takeaway for an 8GB GPU: NF4 lets you hold a ~30B-param model, INT8 a ~15B model,')
print('and fp16 a ~3.5B model. Which is why every local-inference stack (Ollama, llama.cpp,')
print('LM Studio) ships 4-bit quantizations of everything.')
=== VRAM comparison ===
fp16 : 2.20 GB (baseline)
int8 : 1.23 GB (56% of fp16)
nf4 : 0.75 GB (34% of fp16)
Takeaway for an 8GB GPU: NF4 lets you hold a ~30B-param model, INT8 a ~15B model,
and fp16 a ~3.5B model. Which is why every local-inference stack (Ollama, llama.cpp,
LM Studio) ships 4-bit quantizations of everything.
단계 3 — 정밀도별 지연 시간 벤치마크
양자화 모델은 메모리를 덜 사용한다. 그렇다면 더 빠른지도 확인해야 한다.
A100, H100 같은 서버 GPU는 native INT8 tensor core를 제공하므로, INT8 서빙이 fp16보다 최대 2배 빠를 수 있다. 하지만 3060 Ti 같은 소비자용 GPU에서는 양상이 더 복잡하다. bitsandbytes가 matrix multiplication을 수행할 때 가중치를 다시 fp16으로 dequantize해야 하며, 이 dequantization 오버헤드가 성능 이득을 상쇄할 수 있다.
소비자용 GPU에서 양자화를 사용하는 가장 큰 이유는 처리량이 아니라 메모리 절감이다. fp16으로 모델이 VRAM에 들어가지 않으면 다른 최적화는 의미가 없다. VRAM이 충분하다면 지연 시간 측면에서는 fp16이 보통 더 유리하다.
동일한 50토큰 생성을 기준으로 세 가지 정밀도를 모두 벤치마크한다.
# 벤치마크용 FP16 모델을 새로 로드할 공간을 확보하기 위해 먼저 NF4 모델을 해제
del model_fp16
del model_nf4
torch.cuda.empty_cache()
# 벤치마크를 위한 프롬프트와 입력 토큰 생성
prompt = 'The future of artificial intelligence is'
input_ids = tokenizer(prompt, return_tensors='pt').input_ids.to('cuda')
GEN_TOKENS = 50 # 생성할 토큰 수
# 벤치마크 함수 정의
def bench(model, label, n_trials=3):
times = []
# 워밍업 단계: 첫 번째 호출은 캐시를 채우기 위해 수행
_ = model.generate(input_ids, max_new_tokens=5, do_sample=False, pad_token_id=tokenizer.eos_token_id)
torch.cuda.synchronize()
# 실제 벤치마크 수행
for _ in range(n_trials):
t0 = time.perf_counter()
_ = model.generate(input_ids, max_new_tokens=GEN_TOKENS, do_sample=False, pad_token_id=tokenizer.eos_token_id)
torch.cuda.synchronize()
times.append((time.perf_counter() - t0) * 1000)
# 평균 토큰당 시간 계산 및 출력
ms_per_token = (sum(times) / len(times)) / GEN_TOKENS
print(f' {label:>5}: {ms_per_token:6.2f} ms/token')
return ms_per_token
# 벤치마크 수행
latencies = {}
# fp16 모델 벤치마크
print('Benchmarking fp16...')
model_fp16 = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float16).to(device)
latencies['fp16'] = bench(model_fp16, 'fp16')
del model_fp16; torch.cuda.empty_cache()
# int8 모델 벤치마크
print('Benchmarking int8...')
model_int8 = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb_int8, device_map='cuda')
latencies['int8'] = bench(model_int8, 'int8')
del model_int8; torch.cuda.empty_cache()
# nf4 모델 벤치마크
print('Benchmarking nf4...')
model_nf4 = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb_nf4, device_map='cuda')
latencies['nf4'] = bench(model_nf4, 'nf4')
Benchmarking fp16...
The attention mask is not set and cannot be inferred from input because pad token is same as eos token. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.
fp16: 6.97 ms/token
Benchmarking int8...
int8: 28.71 ms/token
Benchmarking nf4...
nf4: 13.51 ms/token
실험 4 — 품질: 양자화가 실제로 정확도를 보존하는가?
held-out 영어 문장에 대해 perplexity(Lab 8 참조)를 측정한다. 양자화가 유용하려면 perplexity 저하가 작아야 한다. LLM.int8()와 QLoRA 논문은 각각의 방식에서 거의 품질 저하가 없다고 보고한다. 여기서 이를 직접 확인한다.
참고로 held-out이란 모델 학습이나 양자화 보정에 사용하지 않고 평가를 위해 따로 분리해 둔 영어 문장을 말함. 예를 들어, held-out data, held-out set, held-out sentences, held-out English sentences 등이 있다.
예상 패턴은 다음과 같다.
- fp16: 기준선
- int8: 약 1–3% 저하
- nf4: 약 2–5% 저하. 약 4배 메모리 절감과의 tradeoff다.
nf4에서 품질 저하가 훨씬 크게 나타나면 Ampere 이상 GPU에서
bnb_4bit_compute_dtype=torch.bfloat16 설정이 도움이 될 수 있다.
3060 Ti에서는 fp16도 대체로 잘 동작한다. bf16도 대안이다. 일부 사용자는 학습 안정성 측면에서 bf16을 선호한다.
Perplexity 평가 설정
Perplexity는 cross-entropy loss의 지수값이며 언어 모델 품질을 평가하는 표준 지표다. 값이 낮을수록 좋다. 약 350토큰 규모의 영어 문단(passage)를 사용해 양자화 변형과 fp16의 차이를 구분할 수 있을 만큼 충분한 신호를 확보한다. 50토큰 시퀀스에서 1% 수준의 perplexity 증가는 노이즈에 가깝지만, 350토큰에서는 의미 있는 차이로 볼 수 있다. 동일한 passage를 fp16, int8, nf4에 대해 평가해 정밀도와 품질의 tradeoff를 한 번에 확인한다.
import math
# 평가에 사용할 약 350토큰 길이의 영어 지문
eval_text = (
'Deep learning has transformed how we build machine learning systems. '
'Rather than hand-engineering features, we now train neural networks on massive datasets '
'and let them discover useful representations on their own. Transformers in particular, '
'introduced in the Attention Is All You Need paper in 2017, have become the dominant architecture '
'for language, vision, and speech tasks. Fine-tuning, quantization, and efficient serving '
'are the three engineering levers that let small teams deploy these models at scale. '
'Quantization in particular converts 32-bit floating-point weights into lower-precision representations '
'like 8-bit integers or 4-bit normal-float values. The smaller footprint means more of the model '
'fits in fast GPU memory, and since memory bandwidth is the bottleneck during autoregressive generation, '
'lower-precision weights often mean faster inference as well.'
)
# 평가 텍스트를 토큰화하고 GPU로 이동
input_ids = tokenizer(eval_text, return_tensors='pt').input_ids.to('cuda')
print(f'Eval text: {input_ids.shape[1]} tokens')
# 평가 지문에 대한 perplexity를 측정하는 함수 정의
@torch.no_grad() # 함수 전체를 감싸서 그 함수 안에서는 기울기 계산을 하지 않도록 설정함
def measure_ppl(m, ids):
m.eval()
out = m(ids, labels=ids)
return math.exp(out.loss.item())
Eval text: 184 tokens
세 가지 정밀도 전체 측정
모델을 한 번에 하나씩 로드하고, perplexity를 측정한 뒤 VRAM을 해제하고 다음 모델을 로드한다. 세 모델을 동시에 로드하면 소비자용 GPU에서는 OOM이 발생할 수 있다. fp16을 기준선으로 사용한다. int8은 보통 perplexity가 1% 미만으로 증가하고, nf4는 1–3% 정도 증가한다. 대신 VRAM 사용량은 2–4배 줄어든다.
# NF4 모델에 대한 perplexity 측정
ppl_nf4 = measure_ppl(model_nf4, input_ids)
print(f' nf4: ppl = {ppl_nf4:.3f}')
del model_nf4; torch.cuda.empty_cache()
# fp16 모델에 대한 perplexity 측정
print('Reloading int8 for perplexity...')
model_int8 = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb_int8, device_map='cuda')
ppl_int8 = measure_ppl(model_int8, input_ids)
print(f' int8: ppl = {ppl_int8:.3f}')
del model_int8; torch.cuda.empty_cache()
# fp16 모델에 대한 perplexity 측정
print('Reloading fp16 for perplexity...')
model_fp16 = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.float16).to(device)
ppl_fp16 = measure_ppl(model_fp16, input_ids)
print(f' fp16: ppl = {ppl_fp16:.3f} (baseline)')
# perplexity 비교 결과 출력
print(f'\n=== Quality vs baseline ===')
print(f' int8: {100*(ppl_int8/ppl_fp16-1):+.2f}%')
print(f' nf4 : {100*(ppl_nf4/ppl_fp16-1):+.2f}%')
nf4: ppl = 11.216
Reloading int8 for perplexity...
int8: ppl = 11.103
Reloading fp16 for perplexity...
fp16: ppl = 11.024 (baseline)
=== Quality vs baseline ===
int8: +0.72%
nf4 : +1.74%
실습 결과
프로덕션 형태의 양자화 평가 절차를 구현했다. 세 가지 로딩 전략을 사용하고, 각 전략에 대해 VRAM 사용량, 지연 시간과 품질을 표로 비교했다. 이는 실제 프로덕션 배포에서 양자화 방식을 선택하기 전에 수행하는 벤치마크 방법론과 같다.
참고 자료
- LLM.int8() paper (Dettmers et al., 2022) — mixed-precision outlier 처리 기법을 설명한다.
- QLoRA paper (Dettmers et al., 2023) — NF4, double quantization, 파인튜닝 절차를 설명한다.
- GPTQ paper (Frantar et al., 2022) 및 AWQ paper (Lin et al., 2023) — bitsandbytes 4비트보다 정확도가 좋은 calibration 기반 4비트 방법이다. 프로덕션 배포에서 검토할 가치가 있다.
- bitsandbytes docs — 설정 옵션과 사용 시점을 설명한다.
- Tim Dettmers — 8-bit quantization blog — 이상치 발견 배경을 설명한다.
- TensorRT-LLM — H100/A100에서 우수한 INT8/FP8 kernel을 제공하는 NVIDIA의 프로덕션 서빙 스택이다. 대규모 배포에 사용하는 선택지다.
댓글남기기