No Calibration Data: Measuring Dynamic Quantization
한국어 원문으로 표시합니다.
목표
dyntool.py 를 만들어 자기 모델에 동적 양자화를 걸고, 그래프가 어떻게 바뀌는지, 파일이 어디서 줄고 어디가 그대로인지, 입력 분포가 달라져도 왜 버티는지, 그리고 이 방식이 맞지 않는 경우가 어떤 모습인지를 전부 숫자로 남긴다.
왜 중요한가
정적 양자화는 대표 입력을 모아야 시작할 수 있고, 그 자료를 구하는 일이 현장에서 가장 오래 걸린다. 동적 양자화는 활성의 범위를 미리 정하지 않고 추론할 때마다 그 텐서를 보고 정하므로 모델 파일 하나만 있으면 된다. 그래서 무엇을 할지 정하기 전에 먼저 걸어 보는 기준선이 된다. 대신 무엇을 얻고 무엇을 잃는지 알아야 고를 수 있다. 얻는 것은 분포가 흔들려도 버티는 성질이다 — 배치마다 자를 다시 만드니 보정 때 본 범위 밖으로 나갈 일이 없다. 잃는 것은 재현성이다. 스케일이 그 배치 전체의 최댓값으로 정해지므로 이상값 한 줄이 같은 배치의 다른 줄까지 끌어내린다. 혼자 넣으면 멀쩡하던 입력이 이웃 때문에 나빠지는 것이다. 파일 크기도 재어 봐야 안다. 줄어드는 것은 행렬뿐이고 편향은 float32 로 남으며, 줄어든 행렬 옆에는 스케일과 영점이 따라붙는다. 가중치가 작은 모델에서는 새로 들어온 노드가 줄어든 양보다 커서 파일이 오히려 커진다. 채점기는 여러분이 적어 낸 숫자를 믿지 않는다. 매번 다른 씨앗과 모양으로 모델을 새로 지어 여러분의 도구를 실제로 실행하고, DynamicQuantizeLinear 연산자의 실제 결과 그리고 채점기가 직접 돌린 추론 결과와 대조한다.
단계
- /root/onnxq-dyn/gen_model.py 를 만들어 실행해 /root/onnxq-dyn/fp32.onnx 를 만드세요.
- /root/onnxq-dyn/dyntool.py 에
quantize를 만들어 /root/onnxq-dyn/dyn.onnx 를 만드세요. sizes를 더해 초기자별 바이트를 세고 /root/onnxq-dyn/size.json 에 적으세요.dqparams를 더해 DynamicQuantizeLinear 가 추론마다 하는 계산을 직접 구현하세요.compare를 더해 입력 배율을 바꿔 가며 오차를 재고 /root/onnxq-dyn/batches.json 에 적으세요.outlier를 더해 이상값 한 줄이 배치를 흔드는지 재고 /root/onnxq-dyn/outlier.json 에 적으세요.- /root/onnxq-dyn/gen_small.py 로 아주 작은 모델을 만들어 양자화하고 /root/onnxq-dyn/unfit.json 에 적으세요.
- /root/onnxq-dyn/report.json 과 /root/onnxq-dyn/report.md 로 한 장에 정리하세요.
참고
- 파이썬은
/opt/onnx-lab/bin/python입니다. 시스템python3에는 numpy 도 onnx 도 없습니다. 실행 예:/opt/onnx-lab/bin/python /root/onnxq-dyn/dyntool.py sizes /root/onnxq-dyn/fp32.onnx. - 실행 계약: 성공하면 종료 코드 0 이고 표준출력에 JSON 한 덩어리를 냅니다. 인자 수가 맞지 않으면 2 입니다.
quantize <입력.onnx> <출력.onnx>응답:{"out": 경로, "bytes": 정수, "ops": {연산자: 개수}, "removed": [사라진 연산자], "added": [새로 든 연산자]}. 가중치는 QInt8 입니다.sizes <모델.onnx>응답:{"file_bytes": 정수, "initializers": {이름: {"dtype": 문자열, "elements": 정수, "bytes": 정수}}, "initializer_bytes": 정수}.dqparams <입력.npy> <출력.npy>응답:{"scale": 실수, "zero_point": 정수, "min": 실수, "max": 실수, "count": 정수}. 양자화한 uint8 배열을 출력 경로에 저장합니다.compare <fp32.onnx> <int8.onnx> <씨앗> <행> <배율>응답:{"rows", "span", "max_abs_output", "max_abs_error", "relative"}. relative 는 오차를 fp32 출력의 최대 절댓값으로 나눈 값입니다.outlier <fp32.onnx> <int8.onnx> <씨앗> <행> <튀는값>응답:{"rows", "spike", "clean", "with_spike", "ratio"}.- 배치를 만드는 규칙(채점기가 같은 배치를 다시 만듭니다): 특징 수는 모델 입력의 마지막 축 크기입니다.
rng = numpy.random.default_rng(씨앗)으로(rng.normal(0, 1, (행, 특징)) * 배율).astype(numpy.float32)입니다. 곱한 뒤에 float32 로 내립니다. outlier의 규칙: 위 규칙을 배율 1.0 으로 써서 배치를 만들고, clean 은 그 배치만 넣었을 때의 최대 절대 오차입니다. with_spike 는numpy.full((1, 특징), 튀는값, dtype=numpy.float32)한 줄을 아래에 붙여 넣은 뒤 앞의 행 개만큼에서 잰 최대 절대 오차입니다. ratio 는 with_spike 를 clean 으로 나눈 값입니다.- DynamicQuantizeLinear 의 정의: 범위는
[min(x, 0), max(x, 0)]이고 스케일은 그 폭을 255 로 나눈 값이며, 영점은 실수 0.0 이 앉을 정수 자리를 0 부터 255 사이로 자른 것입니다. 반올림은 numpy 의np.rint와 같은 짝수 쪽 붙이기입니다. - 스케일은
np.float32(...)로 만들고 나눗셈도 float32 로 하세요. 파이썬 float 으로 나누면 경계에 걸린 원소에서 런타임과 한 눈금 어긋납니다. fp32.onnx의 배치 축은 이름만 있는 동적 축이어야 합니다(["N", 20]). 숫자로 굳히면 채점기가 다른 행 수를 넣지 못합니다.- 5단계는 배율 0.05 · 1.0 · 50.0 을 씨앗 20260917 · 행 32 로 잽니다. 6단계는 씨앗 20260917 · 행 12 · 튀는값 60.0 입니다.
- 공식 문서: ONNX Runtime 양자화 · DynamicQuantizeLinear · MatMulInteger · ONNX Concepts
quantize_dynamic을 부르면 전처리를 권하는 경고가 납니다. 이 실습의 모델은 이미 단순해서 전처리 없이도 그대로 동작합니다.- 흔한 실수: 파일이 줄었을 것이라 짐작하고 재지 않기, 편향까지 int8 이 되었다고 적기, 배치 규칙에서 float32 로 내리는 순서 바꾸기, 이상값 실험에서 튀는 줄까지 포함해 오차를 재기.
잴 대상을 손에 쥔다
/root/onnxq-dyn/gen_model.py 를 만들어 실행해 /root/onnxq-dyn/fp32.onnx 를 만드세요. 배치 축은 이름만 있는 동적 축이어야 하고, 행렬 두 개와 편향 두 개를 담은 MLP 입니다.
onnx.helper 로 그래프를 짓고 onnx.checker 로 확인한 뒤 저장하세요. 입력 모양의 첫 축은 숫자가 아니라 이름("N")으로 둡니다. 초기자는 numpy_helper.from_array 로 만듭니다. 만든 뒤 onnxruntime 으로 한 번 돌려 보면 확실합니다.
명령 한 줄로 가중치만 굳히기
/root/onnxq-dyn/dyntool.py 에 quantize <입력.onnx> <출력.onnx> 를 만들어 /root/onnxq-dyn/dyn.onnx 를 만드세요. 응답에는 바뀐 뒤의 연산자 수와 사라진 연산자, 새로 든 연산자가 있어야 합니다.
onnxruntime.quantization 의 quantize_dynamic 에 weight_type 을 QInt8 로 주면 됩니다. 보정 자료는 넣지 않습니다 — 넣을 자리가 없습니다. 바뀐 그래프는 onnx.load 로 열어 node 의 op_type 을 세면 보입니다. MatMul 이 어디로 갔는지 확인해 보세요.
어디가 줄었는지 이름으로 말하기
sizes <모델.onnx> 를 더해 초기자마다 자료형·원소 수·바이트를 세게 하고, 두 모델을 견준 결과를 /root/onnxq-dyn/size.json 에 fp32_bytes·dyn_bytes·shrunk·kept_float·weight_bytes_before·weight_bytes_after 로 적으세요.
onnx.load 의 graph.initializer 를 돌면서 numpy_helper.to_array 로 바꾸면 dtype 과 nbytes 가 나옵니다. shrunk 는 fp32 에는 있는데 동적 모델에는 이름이 사라진 초기자들이고, kept_float 는 그대로 float32 로 남은 것들입니다. 편향이 어느 쪽에 있는지 보세요.
추론마다 자를 다시 만드는 계산
dqparams <입력.npy> <출력.npy> 를 더해 DynamicQuantizeLinear 가 추론마다 하는 계산을 직접 구현하세요. 스케일·영점·범위를 내고 양자화한 uint8 배열을 저장합니다.
범위에 0 을 반드시 넣습니다 — 최솟값이 양수면 0 으로 내리고, 최댓값이 음수면 0 으로 올립니다. uint8 이라 눈금은 255개이고, 영점은 실수 0.0 이 앉을 정수 자리입니다. 전부 양수인 배열과 전부 음수인 배열을 넣어 보면 영점이 양 끝으로 가는 것이 보입니다.
입력을 1000배 흔들어 보기
compare <fp32.onnx> <int8.onnx> <씨앗> <행> <배율> 을 더하고, 씨앗 20260917 · 행 32 로 배율 0.05 · 1.0 · 50.0 을 재어 /root/onnxq-dyn/batches.json 에 runs 와 relative_spread 로 적으세요.
배치를 만드는 규칙은 참고 절에 못박혀 있습니다 — 채점기가 같은 배치를 다시 만들어야 하므로 그대로 따르세요. relative_spread 는 세 relative 값의 최댓값을 최솟값으로 나눈 값입니다. 절대 오차는 입력 크기를 따라 커지지만 상대 오차는 어떤지 보세요.
이웃 한 줄이 배치를 흔든다
outlier <fp32.onnx> <int8.onnx> <씨앗> <행> <튀는값> 을 더하고, 씨앗 20260917 · 행 12 · 튀는값 60.0 으로 재어 /root/onnxq-dyn/outlier.json 에 적으세요.
같은 12줄을 두 번 넣습니다 — 한 번은 그대로, 한 번은 아래에 튀는 줄을 붙여서. 오차는 앞의 12줄에서만 잽니다. 튀는 줄 자체의 오차는 세지 않습니다. 그래야 '이웃 때문에 나빠졌다' 는 말이 성립합니다.
작은 모델에서는 오히려 커진다
/root/onnxq-dyn/gen_small.py 로 아주 작은 모델 /root/onnxq-dyn/small.onnx 를 만들고 양자화해 /root/onnxq-dyn/small_dyn.onnx 를 만든 뒤, 결과를 /root/onnxq-dyn/unfit.json 에 small_fp32_bytes·small_dyn_bytes·grew·growth_bytes·dynamic_quantize_nodes·kept_float_ops 로 적으세요.
가중치가 몇 백 바이트뿐이면 새로 들어온 노드와 스케일·영점 초기자가 줄어든 양보다 큽니다. kept_float_ops 는 fp32 모델과 동적 모델 양쪽에 모두 있는 연산자들입니다 — 바뀌지 않고 남은 것들이지요. grew 는 문자열이 아니라 불리언입니다.
얻은 것과 잃은 것을 한 장으로
/root/onnxq-dyn/report.json 에 graph_change·file·relative_error_spread·outlier_ratio·small_model_grew 를 적고, /root/onnxq-dyn/report.md 에 ## 그래프가 어떻게 바뀌나 ## 어디가 줄고 어디가 안 줄었나 ## 보정 자료가 필요 없는 이유 ## 동적 양자화가 맞지 않는 경우 네 절로 쓰세요.
앞 단계에서 만든 JSON 들을 읽어 조립하면 됩니다. file 의 ratio 는 동적 모델 바이트를 fp32 바이트로 나눈 값입니다. 보고서에는 숫자와 함께 그 숫자가 무엇을 가리키는지 한 줄씩 적으세요 — 읽는 사람은 여러분의 실험을 보지 못했습니다.