9 min read

Static 양자화는 어디서 깨지는가 — 레이어별로 추적하는 법

Static 양자화는 어디서 깨지는가 — 레이어별로 추적하는 법

Whisper-small 인코더를 ONNX INT8로 정적 양자화했더니 최종 출력 코사인 유사도가 0.46까지 떨어졌다. 모델 전체가 망가진 걸까, 특정 레이어 하나 때문일까? 이 글은 "어디서 깨지는지"를 한 번에 짚어내는 디버깅 패턴을 정리한다.

전체 소스코드는 여기에 있다: https://github.com/rinechran/quant-bisect

TL;DR

  1. 캘리브레이션 데이터로 fp32 ONNX를 만들고 static INT8로 양자화한다.
  2. fp32 / int8 두 모델의 각 트랜스포머 레이어 잔차(add) 출력을 그래프 출력으로 노출시킨다.
  3. 같은 입력을 양쪽에 흘려서 레이어별 코사인 유사도를 계산한다.
  4. 유사도가 절벽처럼 떨어지는 레이어가 — 양자화가 깨진 지점이다.

전체 결과는 0.46이지만, 사실상 망가진 건 딱 한 블록(L7) 이었다는 것을 곧 보게 될 것이다.


1. 출발점: 끝단 코사인만 봐서는 모른다

가장 흔한 검증은 "입력 하나 넣고 fp32 출력 vs int8 출력 cos 비교"다.

out_fp = sess_fp32.run(None, {"mel": mel})[0]
out_q  = sess_int8.run(None, {"mel": mel})[0]
cos = (out_fp.flatten() @ out_q.flatten()) / (
    np.linalg.norm(out_fp) * np.linalg.norm(out_q) + 1e-12
)

Whisper-small 인코더로 10개 한국어 음성 샘플에 대해 돌린 결과:

flat cosine        : mean=0.4726  min=0.4361
per-token cosine   : mean=0.4655  min=0.4332

0.47. 망했다. 그런데 — 어디가 망했을까? 12개 트랜스포머 레이어 중 한 군데가 폭주한 건지, 전체가 골고루 나빠진 건지, 끝단 숫자만 봐서는 알 길이 없다. 끝단 코사인은 "건강검진 종합점수"일 뿐, 어느 장기가 문제인지 알려주지 않는다.

2. ONNX 그래프에 중간 출력 노출시키기

핵심 아이디어: ONNX 그래프의 임의의 텐서를 graph output으로 추가할 수 있다. 모델을 재학습/재export 할 필요가 없다.

먼저 encoder.onnx의 노드 출력 이름을 훑어보면 PyTorch exporter가 만든 generic name이 보인다 (add, add_1, add_2, ..., add_24, enc_out).

Whisper 인코더 한 레이어는 잔차 연결이 두 번 있다 — self-attention 다음, MLP 다음. 그래서:

  • add : conv stem + positional embedding (레이어 진입 전)
  • add_1 : L0 self-attn 잔차
  • add_2 : L0 MLP 잔차
  • add_3 : L1 self-attn 잔차
  • ...
  • add_24 : L11 MLP 잔차
  • enc_out : 최종 LayerNorm 출력

12개 레이어 × 2 = 24개 잔차 출력이 자연스럽게 정렬되어 있다. 이 이름들을 그래프 output에 추가만 하면 된다.

import onnx

def add_outputs(src, dst, tensor_names):
    m = onnx.load(src)
    existing = {o.name for o in m.graph.output}
    for name in tensor_names:
        if name in existing:
            continue
        vi = onnx.helper.make_tensor_value_info(
            name, onnx.TensorProto.FLOAT, None  # shape는 None으로 둬도 ORT가 추론
        )
        m.graph.output.append(vi)
    onnx.save(m, dst)

LAYER_TENSORS = []
for i in range(12):
    LAYER_TENSORS += [f"add_{2*i+1}", f"add_{2*i+2}"]

add_outputs("encoder.onnx",       "encoder.debug.onnx",      LAYER_TENSORS)
add_outputs("encoder.int8.onnx",  "encoder.int8.debug.onnx", LAYER_TENSORS)

여기서 운이 좋았던 점: quantize_static이 만든 INT8 그래프에도 동일한 add_N 텐서 이름이 그대로 살아있었다. QDQ 노드가 곳곳에 끼어들어도 잔차 add 자체의 output 이름은 보존된다. 이게 깨지면 노드 매칭이 까다로워지지만 — 보통 ORT의 quantize_static은 잘 보존해 주는 편이다.

팁: 만약 이름이 바뀌었다면, fp32와 int8 양쪽에서 "마지막 LayerNorm 직전 add"를 위상정렬로 찾아내는 헬퍼를 짜야 한다. 이 글에서는 운이 좋아서 생략한다.

3. 같은 입력으로 양쪽 다 돌리고 per-token cos 계산

레이어 출력은 (1, T, D) 모양이다. flatten 코사인 한 개로 뭉뚱그리면 토큰 간 변동을 못 본다. 토큰별로 계산해서 평균을 내는 게 더 정직하다.

def cos_per_token(a, b):
    a = a[0]; b = b[0]            # (T, D)
    num = (a * b).sum(-1)
    den = np.linalg.norm(a, axis=-1) * np.linalg.norm(b, axis=-1) + 1e-12
    return float((num / den).mean())

이제 캘리브레이션에 쓰지 않은 샘플 5개를 흘려서 레이어별 평균 cos을 모은다. (캘리브레이션 샘플로 검증하면 너무 낙관적인 숫자가 나온다.)

sess_fp = ort.InferenceSession("encoder.debug.onnx",      providers=["CPUExecutionProvider"])
sess_q  = ort.InferenceSession("encoder.int8.debug.onnx", providers=["CPUExecutionProvider"])
fp_names = [o.name for o in sess_fp.get_outputs()]
q_names  = [o.name for o in sess_q.get_outputs()]

for sample in eval_samples:
    mel = featurize(sample)
    fp = dict(zip(fp_names, sess_fp.run(None, {"mel": mel})))
    q  = dict(zip(q_names,  sess_q.run(None,  {"mel": mel})))
    for i in range(12):
        sims[f"L{i}_attn"].append(cos_per_token(fp[f"add_{2*i+1}"], q[f"add_{2*i+1}"]))
        sims[f"L{i}_mlp" ].append(cos_per_token(fp[f"add_{2*i+2}"], q[f"add_{2*i+2}"]))

4. 결과: 절벽이 보인다

layer          mean cos
-----------------------
L0_attn        0.9886
L0_mlp         0.9631
L1_attn        0.9662
L1_mlp         0.9375
L2_attn        0.9342
L2_mlp         0.9368
L3_attn        0.9311
L3_mlp         0.9151
L4_attn        0.9158
L4_mlp         0.9120
L5_attn        0.9085
L5_mlp         0.8877
L6_attn        0.8850
L6_mlp         0.8080      ← 흔들리기 시작
L7_attn        0.1470      ← 💥 붕괴
L7_mlp         0.0821      ← 최저점
L8_attn        0.1817
L8_mlp         0.2937
L9_attn        0.2669
L9_mlp         0.3712
L10_attn       0.3039
L10_mlp        0.5404
L11_attn       0.4564
L11_mlp        0.5182
FINAL          0.4643

이 표에서 읽어야 할 것:

  • L0~L5는 건강하다. 0.99에서 0.89로 천천히 빠진다. 이건 양자화의 정상적인 누적 오차다.
  • L6_mlp에서 0.81로 흔들리기 시작. 활성값 분포가 캘리브레이션 범위 끝에 닿기 시작했다는 신호.
  • L7_attn에서 0.15로 절벽. 이게 범인이다. self-attention의 어떤 텐서 — 거의 확실히 softmax 입력의 outlier — 가 INT8 범위를 폭주시켰고, 그 망가진 출력이 잔차로 흘러서 이후 모든 레이어를 오염시켰다.
  • L8 이후 부분 회복. 잔차 연결과 LayerNorm이 일부 정상 신호를 다시 끌어올리지만, L11까지도 0.5 근처를 못 넘는다.
  • 최종 0.46은 사실상 "L7 폭발의 잔향"이다. 모델 전체가 망가진 게 아니라, 한 블록이 망가진 것이다.

5. 왜 끝단만 보면 안 되는가

만약 0.46만 보고 "static INT8은 Whisper에 안 맞는다"고 결론냈다면 — 잘못된 처방을 내렸을 것이다. 실제로 필요한 건:

  • 모델 교체가 아니라
  • L7 한 블록을 양자화에서 제외하거나, 그 블록만 percentile / entropy 캘리브레이터로 다시 잡거나, smoothquant 같은 outlier 완화 기법을 적용하는 것

레이어별 cos이 없었다면 이 처방을 내릴 수 없다. "어디가 깨졌는지"를 알아야 "어떻게 고칠지"를 결정할 수 있다.

6. 일반화: 이 패턴은 어느 모델에도 쓸 수 있다

Whisper 인코더에만 국한된 트릭이 아니다. 양자화한 모델이 의심스러울 때 항상 같은 절차로 추적할 수 있다:

  1. 잔차 경계를 찾아라. 트랜스포머든 ConvNet이든 잔차 add 또는 블록 끝 LayerNorm/BN 출력은 "블록 단위 신호"를 가장 깨끗하게 들고 있다. ResNet이라면 각 stage 끝 conv 출력, BERT면 각 encoder layer의 LayerNorm 출력이 같은 역할을 한다.
  2. fp32와 양자화 모델 양쪽에 동일한 텐서를 graph output으로 추가하라. ONNX의 매력 중 하나는 이게 한 줄로 된다는 점이다.
  3. 캘리브레이션에 안 쓴 데이터로 per-token (또는 per-channel) cos을 측정하라. flatten cos은 outlier 토큰 하나에 묻힌다.
  4. 표를 위에서부터 읽어 내려가며 절벽을 찾아라. 한 블록에서 큰 폭으로 떨어지면 거기가 범인이다. 골고루 떨어지면 캘리브레이션 데이터 양/품질 문제다.

좋은 양자화 디버깅의 절반은 "어디서 망가졌는지를 빨리 좁히는 것"이다. 끝단 cos 하나에 만족하지 말고, 그래프를 열어서 중간을 들여다보자. ONNX는 그걸 거의 공짜로 해 준다.

7. NPU 오프로딩의 분할 기준으로도 쓸 수 있다

이 레이어별 cos 표는 사실 양자화 디버깅 외에도 한 가지 더 큰 쓸모가 있다. 바로 NPU/CPU 하이브리드 실행에서 어디를 NPU에 올리고 어디를 CPU에 남길지 결정하는 기준이 된다는 것.

NPU를 실제로 붙여보면 — Qualcomm Hexagon이든, Intel NPU든, Apple ANE든 — 양자화한 모델을 통째로 올렸을 때 결과가 크게 깨지는 경우가 흔하다. 이유는 거의 항상 비슷하다:

  • NPU는 보통 INT8/INT16 고정소수 연산만 지원하거나, fp16조차 정확도가 떨어진다.
  • Softmax, LayerNorm, GELU 같은 비선형 연산은 NPU에서 lookup table이나 근사식으로 처리되는데, outlier 있는 입력에서 오차가 폭주한다.
  • 특정 레이어의 활성값 동적 범위가 NPU의 표현 범위를 넘어가면 그 시점부터 출력이 망가진다.

이게 위에서 본 "L7 폭발"과 같은 메커니즘이다. 즉, 정적 INT8 양자화가 깨지는 레이어와 NPU에서 깨지는 레이어는 거의 같은 이유로 같은 곳에서 깨진다. 그래서 위의 레이어별 cos 표가 그대로 NPU 분할 가이드가 된다.

실전 분할 전략은 단순하다:

  1. 위 절차로 레이어별 cos 표를 만든다.
  2. cos가 일정 임계치(예: 0.95) 이상으로 안정적인 구간 — 위 예시에서 L0~L5 — 은 NPU에 올린다. 이 구간은 양자화 친화적이고, NPU의 INT8 파이프라인과 잘 맞는다.
  3. cos가 흔들리거나 폭락하는 레이어 — L6~L11 — 은 CPU(또는 GPU의 fp16)로 남긴다. 이 구간은 outlier에 민감해서 어떤 형태든 저정밀도 연산을 못 견딘다.
  4. 모델 중간에서 NPU → CPU로 한 번 넘기는 비용이 가장 싸므로, "한 절벽 지점에서 한 번만 자른다"가 기본 원칙이다. L0~L6은 NPU, L7부터는 CPU, 이런 식.