Static 양자화는 어디서 깨지는가 — 레이어별로 추적하는 법
Static 양자화는 어디서 깨지는가 — 레이어별로 추적하는 법
Whisper-small 인코더를 ONNX INT8로 정적 양자화했더니 최종 출력 코사인 유사도가 0.46까지 떨어졌다. 모델 전체가 망가진 걸까, 특정 레이어 하나 때문일까? 이 글은 "어디서 깨지는지"를 한 번에 짚어내는 디버깅 패턴을 정리한다.
전체 소스코드는 여기에 있다: https://github.com/rinechran/quant-bisect
TL;DR
- 캘리브레이션 데이터로 fp32 ONNX를 만들고 static INT8로 양자화한다.
- fp32 / int8 두 모델의 각 트랜스포머 레이어 잔차(add) 출력을 그래프 출력으로 노출시킨다.
- 같은 입력을 양쪽에 흘려서 레이어별 코사인 유사도를 계산한다.
- 유사도가 절벽처럼 떨어지는 레이어가 — 양자화가 깨진 지점이다.
전체 결과는 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 인코더에만 국한된 트릭이 아니다. 양자화한 모델이 의심스러울 때 항상 같은 절차로 추적할 수 있다:
- 잔차 경계를 찾아라. 트랜스포머든 ConvNet이든 잔차 add 또는 블록 끝 LayerNorm/BN 출력은 "블록 단위 신호"를 가장 깨끗하게 들고 있다. ResNet이라면 각 stage 끝 conv 출력, BERT면 각 encoder layer의 LayerNorm 출력이 같은 역할을 한다.
- fp32와 양자화 모델 양쪽에 동일한 텐서를 graph output으로 추가하라. ONNX의 매력 중 하나는 이게 한 줄로 된다는 점이다.
- 캘리브레이션에 안 쓴 데이터로 per-token (또는 per-channel) cos을 측정하라. flatten cos은 outlier 토큰 하나에 묻힌다.
- 표를 위에서부터 읽어 내려가며 절벽을 찾아라. 한 블록에서 큰 폭으로 떨어지면 거기가 범인이다. 골고루 떨어지면 캘리브레이션 데이터 양/품질 문제다.
좋은 양자화 디버깅의 절반은 "어디서 망가졌는지를 빨리 좁히는 것"이다. 끝단 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 분할 가이드가 된다.
실전 분할 전략은 단순하다:
- 위 절차로 레이어별 cos 표를 만든다.
- cos가 일정 임계치(예: 0.95) 이상으로 안정적인 구간 — 위 예시에서 L0~L5 — 은 NPU에 올린다. 이 구간은 양자화 친화적이고, NPU의 INT8 파이프라인과 잘 맞는다.
- cos가 흔들리거나 폭락하는 레이어 — L6~L11 — 은 CPU(또는 GPU의 fp16)로 남긴다. 이 구간은 outlier에 민감해서 어떤 형태든 저정밀도 연산을 못 견딘다.
- 모델 중간에서 NPU → CPU로 한 번 넘기는 비용이 가장 싸므로, "한 절벽 지점에서 한 번만 자른다"가 기본 원칙이다. L0~L6은 NPU, L7부터는 CPU, 이런 식.