본문으로 건너뛰기

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

들어가며

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

핵심 구조/코드 분석

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_val이 None일 때 EMA 공식이 NaN을 만든다. 첫 관측에서는 평균 없이 현재 값을 그대로 쓰고, 두 번째부터 혼합한다. 이는 "cold start" 문제를 간단히 해결한다.

자식 클래스 패턴

MinMaxObserver와 MovingAverageMSEObserver가 이 베이스를 상속한다.

@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 의 다른글