[llm-compressor] GPTQ: 2차 정보 기반 후훈련 양자화 구현
들어가며
GPTQ(Generative Pre-trained Transformer Quantization)는 LLM 후훈련 양자화의 고전이다. 2022년 발표된 이 논문은 OBS(Optimal Brain Surgeon) 프레임워크를 초대형 모델에 실용적으로 적용 가능하게 만들었다. OPT-175B를 약 4시간 만에 4비트로 양자화하면서 perplexity 손실이 무시할 만한 수준이었다. 이 글은 GPTQ 논문의 핵심 아이디어를 정리하고, llm-compressor의 src/llmcompressor/modifiers/gptq/base.py 구현을 해부한다.
공식 문서
- 논문: GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
- llm-compressor 예제: examples/quantization_w4a16/
논문 핵심 내용
GPTQ의 출발점은 **Optimal Brain Surgeon(OBS)**이다. OBS는 "어떤 가중치를 제거할 때 나머지를 어떻게 보정해야 출력 손실을 최소화할 수 있는가"를 2차 Taylor 전개로 푼다. 양자화도 "가중치를 제거"하는 것이 아니라 "반올림"하는 것이지만, 동일한 2차 근사가 적용된다.
헤시안 $H = X^T X$는 입력 활성화의 2차 모멘트다. 한 가중치 $w_i$를 양자화하면 $\delta = Q(w_i) - w_i$만큼 오차가 생기는데, 나머지 가중치를 다음과 같이 보정한다.
$$ w_j \leftarrow w_j - \frac{\delta \cdot H^{-1}{ij}}{H^{-1}{ii}} $$
이 식을 그대로 적용하면 계산량이 엄청나다. GPTQ는 두 가지 트릭으로 이를 실용화한다.
- Row-wise 양자화: 한 행을 다 양자화한 뒤 다음 행으로 넘어간다. 행 사이는 독립적이다.
- Lazy batch update: 블록 단위(기본 128)로 양자화한 뒤 나머지 가중치에 보정을 한꺼번에 적용한다. 이는 O(n²)였던 O(n³) 복잡도를 효과적으로 낮춘다.
Activation Order는 GPTQ의 또 다른 핵심 기여다. 헤시안의 대각 원소가 큰 순서대로 가중치를 양자화하면 중요한 가중치가 먼저 처리된다. 이 순서는 활성화 에너지가 큰 입력 채널부터 양자화한다는 의미이므로 activation order라 불린다.
벤치마크 (OPT-175B, 논문 기준)
| 항목 | 수치 |
|---|---|
| 지원 비트 | 3, 4 |
| OPT-175B 양자화 시간 | ~4 GPU 시간 |
| 정확도 저하 | 무시할 만함 (negligible) |
| A100 추론 속도 향상 | 3.25배 |
| A6000 추론 속도 향상 | 4.5배 |
| 이전 방법 대비 압축률 | 2배 이상 |
핵심 구조/코드 분석
GPTQModifier 파라미터
class GPTQModifier(Modifier, QuantizationMixin):
"""Implements the GPTQ algorithm from https://arxiv.org/abs/2210.17323"""
# gptq modifier arguments
block_size: int = 128 # 한 번에 양자화할 컬럼 수
dampening_frac: Optional[float] = 0.01 # 헤시안 대각 정규화 비율
actorder: Optional[Union[ActivationOrdering, Sentinel]] = Sentinel("static")
offload_hessians: bool = False # 헤시안을 CPU 오프로드
# private variables
_module_names: Dict[torch.nn.Module, str] = PrivateAttr(default_factory=dict)
_hessians: Dict[torch.nn.Module, torch.Tensor] = PrivateAttr(default_factory=dict)
_num_samples: Dict[torch.nn.Module, torch.Tensor] = PrivateAttr(default_factory=dict)
| 파라미터 | 기본값 | 의미 |
|---|---|---|
block_size |
128 | GPTQ의 lazy batch 크기. 한 번에 이 만큼의 컬럼을 양자화 |
dampening_frac |
0.01 | 헤시안 대각에 더하는 정규화 계수 (0.01 * trace(H)/N) |
actorder |
"static" |
Activation order 모드: static / dynamic / none |
offload_hessians |
False | 헤시안을 CPU에 오프로드해 GPU 메모리 절약 |
**block_size=128**은 논문의 B=128 파라미터다. 더 크면 빠르지만 정확도가 약간 떨어질 수 있다. **dampening_frac=0.01**은 헤시안 역행렬 계산 시 수치 안정성을 위한 것이다. H가 거의 singular일 때 H + ε·I를 대신 역산해 안정화한다.
**actorder="static"**은 가장 중요한 기본값이다. Static은 "한 번 순서를 결정하고 그 순서대로 양자화"하는 것이고, "dynamic"은 매 블록마다 순서를 재계산한다. 정확도는 비슷하지만 static이 추론 런타임에서 더 싸다 (재정렬 인덱스 g_idx를 저장만 하면 됨).
_hessians, _num_samples 딕셔너리
이 두 private 속성이 GPTQ 실행의 상태를 보관한다.
_hessians[module]: 해당 모듈의 누적 헤시안 텐서_num_samples[module]: 누적된 샘플 개수
두 속성 모두 module을 key로 쓰는 딕셔너리다. 여러 레이어의 GPTQ 상태가 서로 간섭하지 않고 각자의 헤시안을 독립적으로 유지한다.
on_initialize와 on_start: 후크 등록
GPTQModifier의 라이프사이클 훅들은 Quantization Base와 비슷하지만, on_start에서 두 종류의 후크를 추가로 등록한다.
def on_start(self, state: State, event: Event, **kwargs):
# 1) QuantizationMixin 이 activation observer 등록 (base 와 동일)
QuantizationMixin.start_calibration(self, state.model)
# 2) 각 대상 Linear 에 GPTQ 누적 훅 등록
for name, module in match_named_modules(state.model, self.resolved_targets):
self._module_names[module] = name
self._hessians[module] = make_empty_hessian(module, offload=self.offload_hessians)
self._num_samples[module] = 0
def _accumulate_hook(mod, args):
input_ = args[0] if isinstance(args, tuple) else args
accumulate_hessian(
self._hessians[mod],
self._num_samples,
mod,
input_,
offload=self.offload_hessians,
)
module.register_forward_pre_hook(_accumulate_hook)
각 Linear 모듈에 forward pre-hook을 걸어 입력을 받을 때마다 헤시안을 누적한다. accumulate_hessian은 H_new = H_old + X^T X를 계산하는 함수다.
on_event: SEQUENTIAL_EPOCH_END에서 가중치 최종화
def on_event(self, state: State, event: Event, **kwargs):
# Sequential Pipeline이 한 서브그래프의 캘리브레이션을 끝낼 때
if event.type_ == EventType.SEQUENTIAL_EPOCH_END:
subgraph = kwargs.get("subgraph")
# 이 서브그래프에 속한 모든 GPTQ 대상 모듈에 대해
for module_name in subgraph.consumed_names:
module = find_module_by_name(state.model, module_name)
if module in self._hessians:
self._quantize_module(module)
# 헤시안 해제 — 메모리 회수
del self._hessians[module]
del self._num_samples[module]
def _quantize_module(self, module):
H = self._hessians[module]
num_samples = self._num_samples[module]
# 논문 Alg. 1 의 핵심 — block_size 단위로 순회하며 양자화
new_weight, new_scale, new_zp, g_idx = quantize_weight(
module=module,
hessian=H,
block_size=self.block_size,
dampening_frac=self.dampening_frac,
actorder=self.actorder,
)
# 양자화된 가중치를 모듈에 주입
update_offload_parameter(module, "weight", new_weight)
update_offload_parameter(module, "weight_scale", new_scale)
update_offload_parameter(module, "weight_zero_point", new_zp)
if g_idx is not None:
update_offload_parameter(module, "weight_g_idx", g_idx)
GPTQ 특유의 지점은 "한 서브그래프 전체의 캘리브레이션이 끝나면, 그 서브그래프 내 모든 타겟 모듈을 양자화"한다는 점이다. 이는 Sequential Pipeline에서 SEQUENTIAL_EPOCH_END가 발생하는 시점과 정확히 일치한다.
quantize_weight: 논문 Algorithm 1 구현
src/llmcompressor/modifiers/gptq/gptq_quantize.py의 quantize_weight는 논문의 알고리즘을 구현한다.
def quantize_weight(
module,
hessian, # 누적된 H
block_size, # 기본 128
dampening_frac, # 기본 0.01
actorder, # static / dynamic
):
W = module.weight.detach().clone().float()
H = hessian.float()
rows, cols = W.shape
# 1) 헤시안 대각 정규화
damp = dampening_frac * torch.mean(torch.diag(H))
diag = torch.arange(cols, device=H.device)
H[diag, diag] += damp
# 2) Activation order: 대각이 큰 순서로 재배열
if actorder == ActivationOrdering.STATIC:
perm = torch.argsort(torch.diag(H), descending=True)
W = W[:, perm]
H = H[perm][:, perm]
# 3) Cholesky 역행렬 (H = L L^T, Hinv = L^-T L^-1)
Hinv = torch.linalg.cholesky(torch.linalg.inv(H), upper=True)
# 4) block_size 단위로 순회하며 양자화
Q = torch.zeros_like(W)
Losses = torch.zeros_like(W)
for i1 in range(0, cols, block_size):
i2 = min(i1 + block_size, cols)
count = i2 - i1
W1 = W[:, i1:i2].clone()
Q1 = torch.zeros_like(W1)
Err1 = torch.zeros_like(W1)
Hinv1 = Hinv[i1:i2, i1:i2]
for j in range(count):
w = W1[:, j]
d = Hinv1[j, j]
# 양자화 (weight_scale, weight_zero_point 사용)
q = fake_quantize_one_col(w)
Q1[:, j] = q
err1 = (w - q) / d
# 블록 내 나머지 컬럼 보정
W1[:, j:] -= err1.unsqueeze(1) * Hinv1[j, j:].unsqueeze(0)
Err1[:, j] = err1
Q[:, i1:i2] = Q1
# 블록 외부 나머지 컬럼에 lazy batch update
W[:, i2:] -= Err1 @ Hinv[i1:i2, i2:]
# 5) actorder 재배열 되돌리기
if actorder == ActivationOrdering.STATIC:
invperm = torch.argsort(perm)
Q = Q[:, invperm]
return Q, scales, zero_points, perm if actorder else None
이 코드가 GPTQ 논문 Algorithm 1 그대로다. 핵심은 이중 루프다. 바깥 루프는 block_size=128 단위로 블록을 순회하고, 안쪽 루프는 블록 내부의 컬럼을 하나씩 양자화하면서 나머지 블록 내부 컬럼에 즉시 보정을 적용한다. 블록이 끝나면 블록 외부의 나머지 모든 컬럼에 lazy batch update를 한 번에 적용한다 (W[:, i2:] -= Err1 @ Hinv[i1:i2, i2:]).
이 구조가 O(n²) 복잡도와 메모리 효율성을 동시에 달성한다.
on_finalize: 훅 제거와 정리
def on_finalize(self, state: State, **kwargs) -> bool:
# 남아있는 헤시안이 있다면 아직 양자화 안 된 모듈 — 마지막 기회
for module in list(self._hessians.keys()):
self._quantize_module(module)
# 모든 훅 제거
self.remove_hooks(state.model)
self._hessians.clear()
self._num_samples.clear()
return True
왜 이 설계인가
1. dampening_frac=0.01. 논문 기본값은 1%다. 수치 안정성 트레이드오프 — 더 크면 H가 잘 역산되지만 보정이 부정확해지고, 더 작으면 정확하지만 singular에 가까워질 위험이 있다. 0.01이 대부분의 LLM에서 잘 작동한다.
2. block_size=128. 블록 크기가 커지면 lazy batch update가 드물게 일어나 속도가 빠르지만 "블록 외부 가중치가 더 오래된 상태로 양자화"되어 정확도가 떨어진다. 128은 속도와 정확도의 황금점으로 알려져 있다.
3. Static activation order. Dynamic보다 추론 런타임에서 훨씬 효율적이다. g_idx 텐서 하나만 저장하면 vLLM/SGLang이 로딩 시 이 순서로 가중치를 재배열한다. Dynamic은 블록마다 재정렬 정보가 달라 추론 엔진이 처리하기 어렵다.
4. SEQUENTIAL_EPOCH_END와의 연동. GPTQ가 Sequential Pipeline에 강하게 의존하는 이유는 "한 서브그래프의 캘리브레이션이 완전히 끝난 뒤 양자화"라는 순서가 핵심이기 때문이다. Basic Pipeline에서는 이 순서가 보장되지 않아 쓸 수 없다.
5. offload_hessians 옵션. 대형 모델에서 헤시안이 수십 GB에 달할 수 있다. CPU 오프로드를 허용하면 A100 80GB 하나로 70B 모델 양자화가 가능하다. 단 I/O 오버헤드로 속도가 2~3배 느려진다.
마무리
GPTQ는 llm-compressor의 간판 알고리즘이다. 대부분의 W4A16 양자화 체크포인트는 이 Modifier로 만들어진다. 다음 글은 활성화 분포를 활용하는 AWQ를 본다.
참고 자료
관련 포스트
llm-compressor 의 다른글
- 이전글 [llm-compressor] Group Size Validation: 그룹 크기 호환성 검사
- 현재글 : [llm-compressor] GPTQ: 2차 정보 기반 후훈련 양자화 구현
- 다음글 [llm-compressor] AWQ: 활성화 인식 가중치 양자화 구현
댓글