본문으로 건너뛰기

[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의 출발점은 **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는 두 가지 트릭으로 이를 실용화한다.

  1. Row-wise 양자화: 한 행을 다 양자화한 뒤 다음 행으로 넘어간다. 행 사이는 독립적이다.
  2. 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_initializeon_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_hessianH_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.pyquantize_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 의 다른글