- Published on
로컬 LLM 양자화 - bitsandbytes vs GPTQ 비교
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 로컬 PC에서 LLM을 직접 돌리다 보면, 결국 병목은 VRAM과 대역폭입니다. 같은 7B 모델이라도 FP16으로 올리면 VRAM이 빠르게 고갈되고, KV 캐시까지 얹히면 컨텍스트가 늘어날수록 메모리 압박이 커집니다. 그래서 실무에서 가장 먼저 만나는 선택지가 **양자화(quantization)**이고, Transformers 생태계에서 특히 많이 쓰는 방식이 bitsandbytes 4bit와 GPTQ 4bit입니다.
이 글은 두 방식을 “어떤 상황에서 무엇을 선택할지”를 결정할 수 있도록 속도, VRAM, 품질, 운영 난이도를 중심으로 비교하고, 바로 실행 가능한 코드와 벤치 방법까지 제공합니다.
양자화 한 장 요약: 무엇이 줄고, 무엇이 늘까
양자화는 가중치(weight)를 더 적은 비트 수로 저장하고 계산하도록 바꾸는 기법입니다.
- 줄어드는 것
- 모델 가중치가 차지하는 VRAM
- GPU 메모리 대역폭 요구량(대체로)
- 늘거나 변하는 것
- 커널/연산 경로가 달라져 **지연 시간(latency)**이 바뀜
- 일부 모델/태스크에서 **정확도(품질)**가 감소
- 런타임 의존성(커널, CUDA, 드라이버, 패키지) 복잡도
여기서 중요한 포인트는, “VRAM 절감”은 둘 다 강력하지만 “속도”는 하드웨어와 구현 방식에 따라 체감이 크게 갈린다는 점입니다.
bitsandbytes 4bit: 로딩이 쉽고 범용적인 선택
Transformers에서 bitsandbytes는 사실상 표준 옵션처럼 쓰입니다. 대표적으로 NF4(정규화 4비트)와 double quant 같은 옵션을 켜서, 품질 손실을 상대적으로 줄이면서 VRAM을 크게 절약합니다.
장점
- 가장 쉬운 적용: 모델 체크포인트를 별도로 변환하지 않고, 로딩 시점에 4bit로 올릴 수 있음
- Transformers 통합이 성숙:
BitsAndBytesConfig로 대부분 해결 - 호환성: 다양한 모델 아키텍처에서 무난하게 동작
단점
- GPU와 환경에 따라 속도가 기대보다 덜 나오거나 오히려 손해를 보는 케이스가 있음
- 일부 조합에서 커널 경로가 최적이 아니면, 디코딩 토큰당 latency가 늘 수 있음
bitsandbytes 4bit 로딩 코드
아래 코드는 Transformers에서 NF4 4bit로 로컬 LLM을 로드하는 기본 패턴입니다.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "meta-llama/Llama-2-7b-chat-hf" # 예시
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16,
)
prompt = "Explain quantization in one paragraph."
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
팁: dtype 선택
- Ampere 이상에서
bfloat16이 안정적이고 성능도 좋은 편입니다. - 카드가
bfloat16을 잘 못 받으면torch.float16으로 바꾸세요.
GPTQ 4bit: 추론 속도에 강한 “오프라인 양자화”
GPTQ는 보통 미리 양자화된 체크포인트를 사용하거나, 별도 스크립트로 오프라인 양자화를 수행한 뒤 그 결과물을 로딩합니다. 흔히 “GPTQ 모델 파일”로 배포되는 것들이 여기에 해당합니다.
장점
- 환경이 맞으면 **디코딩 속도(토큰 생성 속도)**가 더 잘 나오는 경우가 많음
- 특정 GPU와 커널 조합에서 추론 최적화가 강함
단점
- 모델을 “그냥 로드”하는 경험은 bitsandbytes보다 번거로울 수 있음
- 양자화 설정(그룹 사이즈, act order 등)에 따라 품질과 속도가 크게 갈림
- 모델 배포 형태가 다양해, 팀 내 재현성을 확보하려면 규격을 정해야 함
GPTQ 로딩 예시(Transformers 기반)
GPTQ는 배포 방식이 여러 갈래지만, 핵심은 “GPTQ로 양자화된 가중치를 로딩한다”입니다. 아래는 대표적인 형태의 예시 코드입니다. 사용 중인 패키지 조합에 따라 클래스나 인자명이 달라질 수 있으니, 본인의 체크포인트 문서에 맞춰 조정하세요.
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
model_id = "TheBloke/Llama-2-7B-Chat-GPTQ" # 예시
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.float16,
)
prompt = "Write a short checklist for benchmarking LLM inference."
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
use_cache=True,
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
속도 비교: “프리필 vs 디코드”를 분리해서 봐야 한다
LLM 추론 속도는 크게 두 구간으로 나뉩니다.
- 프리필(prefill)
- 입력 프롬프트를 한 번에 처리해 KV 캐시를 채우는 단계
- 입력 길이가 길수록 비용이 큼
- 디코드(decode)
- 토큰을 한 개씩 생성하는 단계
- 실사용 체감은 보통 여기서 결정됨
실무에서 자주 보는 경향은 다음과 같습니다.
- bitsandbytes 4bit
- 프리필은 무난하거나 좋은 편인 경우가 많음
- 디코드는 GPU/커널 경로에 따라 편차가 큼
- GPTQ 4bit
- 디코드가 잘 튜닝된 조합에서 강점이 나오는 경우가 많음
다만 “항상 GPTQ가 빠르다”는 식의 결론은 위험합니다. 같은 4bit라도 커널, 드라이버, GPU 아키텍처, 배치 크기, 시퀀스 길이에 따라 결과가 뒤집힙니다.
재현 가능한 간단 벤치 코드
아래는 프리필과 디코드를 대략 분리해 측정하기 위한 간단한 스니펫입니다. 엄밀한 마이크로벤치는 아니지만, 비교 방향을 잡기엔 충분합니다.
import time
import torch
@torch.inference_mode()
def bench_generate(model, tokenizer, prompt, max_new_tokens=128, n_warmup=2, n_runs=5):
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
# warmup
for _ in range(n_warmup):
_ = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, use_cache=True)
torch.cuda.synchronize()
times = []
for _ in range(n_runs):
t0 = time.perf_counter()
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, use_cache=True)
torch.cuda.synchronize()
t1 = time.perf_counter()
times.append(t1 - t0)
text = tokenizer.decode(out[0], skip_special_tokens=True)
return {
"avg_sec": sum(times) / len(times),
"runs": times,
"last_output_len": len(out[0]),
"sample": text[:200],
}
벤치할 때 꼭 고정할 것
do_sample=False로 디코딩 랜덤성 제거- 같은
max_new_tokens, 같은 프롬프트 길이 torch.cuda.synchronize()로 측정 오차 줄이기- 가능하면 GPU 클럭 변동을 줄이기 위해 전원 정책 고정
VRAM 비교: 가중치만 보지 말고 KV 캐시를 포함하라
양자화로 줄어드는 건 주로 가중치 VRAM입니다. 그런데 실제로 컨텍스트가 길어지면 KV 캐시가 VRAM의 주인공이 됩니다.
- 7B급 모델이라도
- 짧은 컨텍스트: 가중치가 대부분
- 긴 컨텍스트: KV 캐시가 대부분
즉, 4bit로 가중치를 줄여도 컨텍스트를 무작정 늘리면 결국 OOM이 납니다.
VRAM 사용량 측정 스니펫
import torch
def vram_mb():
torch.cuda.synchronize()
alloc = torch.cuda.memory_allocated() / 1024 / 1024
reserved = torch.cuda.memory_reserved() / 1024 / 1024
return alloc, reserved
alloc, reserved = vram_mb()
print(f"allocated_mb={alloc:.1f}, reserved_mb={reserved:.1f}")
OOM이 자주 난다면, 원인 추적 관점에서는 리눅스 메모리 압박과 유사한 접근이 필요합니다. GPU OOM이든 시스템 OOM이든 “누가 메모리를 먹는지”를 먼저 특정해야 합니다. 시스템 차원의 OOM 분석은 리눅스 OOM Killer로 프로세스 죽을 때 원인 추적 글의 흐름을 참고하면 진단 습관을 잡는 데 도움이 됩니다.
품질(정확도) 비교: NF4 vs GPTQ는 설정 싸움이다
품질은 한 줄 결론이 어렵습니다. 다만 실무 경험상 다음 경향은 자주 관찰됩니다.
- bitsandbytes NF4
- “기본값으로도” 품질이 괜찮게 나오는 경우가 많음
- 특히 지시 따르기, 요약, 일반 대화에서 무난
- GPTQ
- 설정이 좋으면 품질도 좋고 속도도 좋을 수 있음
- 설정이 나쁘면 특정 태스크에서 급격히 무너질 수 있음
GPTQ에서 특히 영향을 크게 주는 요소는 다음입니다.
- 그룹 사이즈(group size)
- activation ordering 여부
- 캘리브레이션 데이터 품질
따라서 GPTQ를 팀 단위로 운영한다면, “어떤 설정으로 양자화했는지”를 모델 아티팩트에 메타데이터로 남겨야 재현이 됩니다.
운영 관점: 로컬 개발과 배포에서 무엇이 덜 고장 날까
bitsandbytes가 유리한 경우
- Hugging Face 원본 체크포인트를 그대로 쓰고 싶다
- 모델을 자주 바꿔 끼우며 실험한다
- 배포 파이프라인을 단순하게 유지하고 싶다
GPTQ가 유리한 경우
- 특정 GPU에서 디코드 속도가 최우선이다
- 동일 모델을 장기간 고정해 운영한다
- “미리 양자화된 아티팩트”로 배포를 표준화할 수 있다
운영 중 흔한 장애는 결국 메모리 부족(OOM)과 스로틀링입니다. 컨테이너나 쿠버네티스에서 돌린다면, OOMKilled를 반복하며 원인을 못 잡는 일이 잦습니다. 이때는 cgroup 관점에서 메모리 한도와 실제 사용량을 함께 봐야 합니다. 관련해서 K8s OOMKilled 반복? cgroup v2 메모리 진단도 같이 읽어두면, “모델이 커서 죽는 건지, 설정이 잘못돼서 죽는 건지”를 분리하는 데 도움이 됩니다.
추천 의사결정 표
| 상황 | 추천 | 이유 |
|---|---|---|
| 처음 로컬 LLM을 세팅 | bitsandbytes 4bit | 적용이 쉽고 실패 확률이 낮음 |
| 모델을 자주 교체하며 실험 | bitsandbytes 4bit | 체크포인트 변환 없이 즉시 로딩 |
| 동일 모델을 고정 운영, 디코드 TPS가 최우선 | GPTQ 4bit | 환경이 맞으면 디코드가 더 잘 나올 수 있음 |
| 긴 컨텍스트가 핵심 | 둘 다 가능, KV 캐시 최적화 병행 | 가중치보다 KV 캐시가 병목이 되기 쉬움 |
실전 체크리스트: 비교 실험을 제대로 하려면
- 동일 조건 고정
- 프롬프트 길이,
max_new_tokens, 디코딩 옵션
- 프롬프트 길이,
- 프리필과 디코드를 분리해 관찰
- 긴 프롬프트, 짧은 프롬프트 두 케이스로 측정
- VRAM은
allocated와reserved를 같이 기록 - 품질은 최소 10개 이상 프롬프트로 회귀 테스트
- 요약, 번역, 코드 생성, 지시 따르기 등 섞기
- OOM이 나면 “가중치 vs KV 캐시 vs 배치”부터 의심
추가로, 모델을 ONNX나 다른 런타임으로 내리는 최적화 루트도 고려할 수 있습니다. 특히 CPU나 다양한 엣지 환경까지 염두에 둔다면, 양자화 외 최적화 옵션을 함께 보는 게 좋습니다. 파이썬 ONNX Runtime로 BERT 3배 경량·고속화 글은 “모델 최적화는 양자화만이 전부가 아니다”라는 관점에서 참고할 만합니다.
마무리: 결론은 하나가 아니라 “조건부 최적”
- 빠르게 시작하고, 모델을 자주 바꾸고, 설정 복잡도를 낮추려면
bitsandbytes4bit가 가장 안전한 출발점입니다. - 특정 모델을 고정 운영하면서 디코드 TPS를 끝까지 끌어올리고 싶다면 GPTQ가 강력한 카드가 될 수 있습니다.
최종 선택은 “내 GPU에서, 내 프롬프트 길이에서, 내 디코딩 설정에서” 벤치한 결과로 결정해야 합니다. 위 벤치 코드와 체크리스트로 최소한의 재현성을 확보한 뒤, 속도와 VRAM, 품질을 함께 보고 결론을 내리는 것을 권합니다.