본문으로 건너뛰기

[llm-compressor] Moving Average Observer: 지수 이동 평균 기반 온라인 관측자

들어가며

캘리브레이션 데이터를 여러 배치 순회할 때, 각 배치마다 min/max 가 달라진다. MemorylessMinMaxObserver는 이전 배치를 전혀 기억하지 않고, StaticMinMaxObserver는 과거 최댓값을 그대로 유지한다. 둘 다 극단값에 민감하다. 그 중간이 이동 평균(moving average) 방식이다. 이번 배치의 min/max와 과거의 평균을 혼합해 점진적으로 수렴시킨다. 이 로직을 담당하는 것이 src/llmcompressor/observers/moving_base.pyMovingAverageObserverBase다.

핵심 구조/코드 분석

MovingAverageObserverBase: 지수 이동 평균 누적

class MovingAverageObserverBase(Observer):
    """
    Base class for observers which use a moving average to accumulate
    min/max values over multiple observations.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        observer_kwargs = self.args.observer_kwargs
        self.averaging_constant = observer_kwargs.get("averaging_constant", 0.01)
        # 과거 상태 — None 이면 아직 관측이 없음
        self.min_val: torch.Tensor | None = None
        self.max_val: torch.Tensor | None = None
        self.global_min_val: torch.Tensor | None = None
        self.global_max_val: torch.Tensor | None = None

    @abstractmethod
    def get_current_min_max(self, observed: torch.Tensor) -> MinMaxTuple:
        """자식 클래스가 구현 — 이번 관측에서 min/max 를 어떻게 구할지"""
        raise NotImplementedError()

    @abstractmethod
    def get_current_global_min_max(self, observed: torch.Tensor) -> MinMaxTuple:
        """자식 클래스가 구현 — global scale 용"""
        raise NotImplementedError()

    def get_min_max(self, observed: torch.Tensor) -> MinMaxTuple:
        # 1) 이번 관측의 로컬 min/max 호출 (자식이 구현)
        current_min, current_max = self.get_current_min_max(observed)

        # 2) 첫 관측이면 바로 저장
        if self.min_val is None:
            self.min_val = current_min
            self.max_val = current_max
        else:
            # 3) EMA 공식: new = α * current + (1-α) * past
            self.min_val = self.averaging_constant * current_min + \
                          (1 - self.averaging_constant) * self.min_val
            self.max_val = self.averaging_constant * current_max + \
                          (1 - self.averaging_constant) * self.max_val

        return self.min_val, self.max_val

    def get_global_min_max(self, observed: torch.Tensor) -> MinMaxTuple:
        current_min, current_max = self.get_current_global_min_max(observed)

        if self.global_min_val is None:
            self.global_min_val = current_min
            self.global_max_val = current_max
        else:
            self.global_min_val = self.averaging_constant * current_min + \
                                  (1 - self.averaging_constant) * self.global_min_val
            self.global_max_val = self.averaging_constant * current_max + \
                                  (1 - self.averaging_constant) * self.global_max_val

        return self.global_min_val, self.global_max_val
파라미터 기본값 의미
averaging_constant 0.01 EMA 가중치. 작을수록 과거 영향이 큼
min_val / max_val None → 텐서 누적된 min/max. 텐서 shape = qparam_shape

핵심은 지수 이동 평균(EMA) 공식 하나다.

$$ \text{min_val}^{(t)} = \alpha \cdot \text{current_min}^{(t)} + (1 - \alpha) \cdot \text{min_val}^{(t-1)} $$

averaging_constant=0.01이 기본값이므로, 새 관측의 가중치는 1%고 과거 누적이 99%다. 이는 "새로운 극단값 하나에 크게 흔들리지 않지만, 수백 배치 후에는 새 분포에 수렴"하는 효과를 만든다.

첫 관측 처리

if self.min_val is None:
    self.min_val = current_min
    self.max_val = current_max

이 가드가 없으면 self.min_valNone일 때 EMA 공식이 NaN을 만든다. 첫 관측에서는 평균 없이 현재 값을 그대로 쓰고, 두 번째부터 혼합한다. 이는 "cold start" 문제를 간단히 해결한다.

자식 클래스 패턴

MinMaxObserverMovingAverageMSEObserver가 이 베이스를 상속한다.

@Observer.register("minmax")
class MinMaxObserver(MovingAverageObserverBase):
    def get_current_min_max(self, observed):
        return _get_min_max(observed)          # 단순 amin/amax


@Observer.register("mse")
class MovingAverageMSEObserver(MovingAverageObserverBase):
    def get_current_min_max(self, observed):
        return _grid_search_mse(...)            # MSE grid search

두 variant 모두 "이번 관측에서 최적 min/max를 어떻게 구하느냐"만 다르고, 누적 로직은 부모가 공유한다. 이 덕분에 MinMax 버전과 MSE 버전 모두 동일한 EMA 수렴 특성을 가진다.

averaging_constant 튜닝

사용자는 레시피 YAML에서 observer_kwargs: {averaging_constant: 0.1}로 오버라이드할 수 있다. 값이 클수록 "새 관측에 민감", 작을수록 "과거에 기반한 안정"을 얻는다.

동작
0.01 (기본) 매우 안정, 수렴 느림
0.1 중간
0.5 과거와 현재를 동등하게
0.9 거의 최근 관측만 사용
1.0 memoryless와 동일

일반적으로 활성화 양자화에서는 0.01~0.1 사이가 적정이다. 너무 크면 마지막 몇 배치의 영향이 지배적이 되어 불안정하고, 너무 작으면 초반 몇 배치가 스케일을 고정해 버린다.

수학적 특성

EMA는 효과적 학습률(effective learning rate)이 있다. 100 스텝 후에는 과거 가중치가 (1-α)^100으로 지수 감쇠한다. α=0.01이면 약 0.366, 200 스텝 후는 0.134, 500 스텝 후는 0.0066이다. 즉 "약 100~200 스텝이 지나면 초반 관측의 영향이 사라진다"가 경험 법칙이다.

이는 캘리브레이션 샘플 수(num_calibration_samples=512)와 맞물려 "전체 데이터셋에 수렴할 정도로 충분히 많은 배치"를 쓰는 것이 중요하다는 의미다. 너무 적은 배치만 쓰면 EMA가 수렴하지 않아 스케일이 안정적이지 않다.

왜 이 설계인가

1. 템플릿 메서드 패턴. get_min_max는 부모가 구현하고, get_current_min_max만 자식이 구현한다. 덕분에 MinMax 버전과 MSE 버전이 같은 누적 로직을 공유하면서 각자의 "최적 결정 방식"을 가질 수 있다.

2. EMA 수식의 단순성. 복잡한 상태 머신이 아닌 단일 수식이다. 매 관측마다 상수 시간에 업데이트되며 과거 값 저장이 min_val, max_val 두 텐서면 충분하다.

3. None 초기 상태 처리. 첫 관측 전에는 None을 두고, 가드 체크로 cold start를 처리한다. 대안으로 zeros_like로 초기화할 수도 있지만, 실제 텐서 shape을 사전에 알기 어려우므로 None이 실용적이다.

4. 전역 스케일도 동일 패턴. global_min_val, global_max_val을 같은 EMA 로직으로 처리한다. 마이크로스케일 스킴을 쓰지 않는 경우 이 필드들은 건드리지 않는다.

5. averaging_constant를 observer_kwargs로. 사용자 커스터마이징이 필요한 유일한 파라미터라서, observer_kwargs 딕셔너리로 외부에서 오버라이드 가능하게 한다. 기본값 0.01이 대부분 잘 작동한다.

마무리

Moving Average Observer는 llm-compressor의 기본 activation observer다. observer_type을 명시하지 않으면 "minmax" 이름의 이 variant가 자동 선택된다. 다음 글은 중요도 행렬 기반의 iMatrix Observer를 본다.

참고 자료

댓글

관련 포스트

llm-compressor 의 다른글