[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 의 다른글
- 이전글 [llm-compressor] MSE Observer: Grid Search로 양자화 오차 최소화
- 현재글 : [llm-compressor] Moving Average Observer: 지수 이동 평균 기반 온라인 관측자
- 다음글 [llm-compressor] iMatrix Observer: 입력 채널 중요도 가중 MSE
댓글