物差しを自分で作る — スケール・ゼロ点・往復誤差
한국어 원문으로 표시합니다.
목표
qmath.py 를 만들어 QuantizeLinear·DequantizeLinear 의 정의대로 스케일과 영점을 계산하고, 양자화와 역양자화를 구현하고, 왕복 오차가 스케일의 절반 안에 있는지 확인하고, 대칭과 비대칭 그리고 채널별 스케일과 텐서 전체 스케일을 같은 자료 위에서 견준다.
왜 중요한가
변환 도구가 내놓은 int8 모델의 정확도가 떨어졌을 때 고칠 곳을 고르려면, 그 도구가 안에서 하는 산술을 알아야 한다. 스케일은 관찰 범위를 눈금 수로 나눈 값이고, 영점은 실수 0.0 이 앉을 정수 자리다. 범위에 0 이 없으면 패딩과 ReLU 가 만든 0 이 0 이 아닌 값으로 되살아난다.
왕복 오차에는 상한이 있다. 값이 범위 안에 있는 한 오차는 스케일의 절반을 넘지 않는다 — 양자화가 값을 스케일 간격의 격자로 옮기는 일이기 때문이다. 그래서 오차가 그 절반을 크게 넘었다면 반올림이 아니라 범위가 문제다. 이 한 가지 잣대가 원인 판단을 빠르게 만든다.
자료의 모양도 방식을 정한다. ReLU 뒤의 활성은 전부 0 이상이라 대칭 int8 을 걸면 음수 쪽 눈금이 통째로 놀고 스케일이 두 배 거칠어진다. 가중치 행렬에 이상값이 하나 있으면 텐서 전체 스케일이 그 값에 끌려가 나머지가 전부 뭉개진다 — 채널을 나누면 피해가 그 채널 안에 갇힌다.
채점기는 여러분이 적어 낸 숫자를 믿지 않는다. 매번 다른 씨앗으로 배열을 만들어 여러분의 qmath.py 를 실제로 실행하고, 같은 입력을 ONNX 의 QuantizeLinear·DequantizeLinear 연산자에 그대로 태워 나온 결과와 대조한다.
단계
- /root/onnxq-scale/gen_data.py 를 만들어 실행해 /root/onnxq-scale/data 아래 배열 세 벌을 만들고, /root/onnxq-scale/qmath.py 에
params를 구현하세요. q를 더해 실수 배열을 정수 배열로 내리세요.dq를 더해 정수 배열을 실수로 되돌리세요.roundtrip을 더해 왕복 오차와 그 상한을 함께 내게 하세요.- 범위를 일부러 좁혀 포화를 만들고 결과를 /root/onnxq-scale/saturate.json 에 적으세요.
- 전부 0 이상인 자료에 대칭과 비대칭을 걸어 /root/onnxq-scale/symmetry.json 에 적으세요.
channel을 더해 채널별 스케일과 텐서 전체 스케일을 견주고 /root/onnxq-scale/channel.json 에 적으세요.- /root/onnxq-scale/report.json 과 /root/onnxq-scale/report.md 로 한 장에 정리하세요.
참고
- 파이썬은
/opt/onnx-lab/bin/python입니다. 시스템python3에는 numpy 가 없습니다. 실행 예:/opt/onnx-lab/bin/python /root/onnxq-scale/qmath.py params -3.2 5.1 asym-u8. - 실행 계약: 성공하면 종료 코드 0 이고 표준출력에 JSON 한 덩어리를 냅니다. 인자 수가 맞지 않으면 2 입니다.
params <lo> <hi> <mode>응답:{"scale": 실수, "zero_point": 정수, "qmin": 정수, "qmax": 정수, "mode": 문자열}.q <입력.npy> <출력.npy> <mode> [lo hi]응답:{"scale": 실수, "zero_point": 정수, "clipped": 정수, "dtype": 문자열, "count": 정수}. lo·hi 를 주지 않으면 입력 배열의 최솟값·최댓값을 씁니다. clipped 는 잘린 원소 수입니다.dq <양자.npy> <출력.npy> <scale> <zero_point> <mode>응답:{"min": 실수, "max": 실수, "count": 정수}.roundtrip <입력.npy> <mode> [lo hi]응답:{"mode", "scale", "zero_point", "lo", "hi", "clipped", "max_abs_error", "bound", "within_bound"}. bound 는 스케일의 절반이고 within_bound 는 불리언입니다.channel <입력.npy> <axis>응답:{"axis", "channels", "per_tensor_scale", "per_tensor_max_error", "per_channel_scales", "per_channel_max_error", "worst_channel", "channels_improved"}. 대칭 int8 로 계산합니다.- mode 는 둘입니다.
asym-u8은 uint8 · qmin 0 · qmax 255 · 비대칭이고,sym-i8은 int8 · qmin -128 · qmax 127 · 영점 0 인 대칭입니다. 대칭에서는 한쪽 폭을 127 로 나눕니다(128 이 아닙니다). - 범위에는 0 을 반드시 포함시킵니다. lo 가 0 보다 크면 0 으로 내리고, hi 가 0 보다 작으면 0 으로 올립니다.
- 반올림은 짝수 쪽으로 붙이는 반올림입니다. numpy 의
np.rint가 그렇게 동작합니다.round()내장 함수나int(x + 0.5)를 쓰면 답이 달라집니다. - 스케일을 만들 때
np.float32(...)로 감싸고 나눗셈도 float32 로 하세요. 파이썬 float 으로 나누면 경계에 걸린 원소에서 런타임과 한 눈금 어긋납니다. - 재료 배열은 /root/onnxq-scale/data 아래 spread.npy · positive.npy · weights.npy 세 개입니다. gen_data.py 가 만들며 채점기는 이 파일들을 읽습니다.
- 공식 문서: QuantizeLinear · DequantizeLinear · ONNX Concepts · ONNX Runtime 양자화
- 흔한 실수: 범위에 0 을 안 넣기, 대칭에서 128 로 나누기,
np.rint대신 반내림 반올림 쓰기, 포화가 난 경우에도 상한을 넘지 않는다고 적기.
관찰 범위를 스케일과 영점으로
/root/onnxq-scale/gen_data.py 를 만들어 실행해 /root/onnxq-scale/data 아래 세 배열을 만들고, /root/onnxq-scale/qmath.py 에 params <lo> <hi> <mode> 를 구현하세요. 범위에 0 이 없으면 넣어야 합니다.
비대칭은 (hi - lo) 를 255 로 나누고, 영점은 실수 0.0 이 앉을 정수 자리입니다 — qmin 에서 lo/scale 을 뺀 값을 반올림하세요. 대칭은 좌우 폭 중 큰 쪽을 127 로 나누고 영점은 0 입니다. lo 가 0 보다 크거나 hi 가 0 보다 작은 경우를 먼저 처리하세요.
실수를 정수 눈금으로 내리기
q <입력.npy> <출력.npy> <mode> [lo hi] 를 더해 실수 배열을 정수 배열로 내리고 결과를 .npy 로 저장하게 하세요. 응답에는 scale·zero_point·clipped·dtype·count 가 있어야 합니다.
정의는 saturate(round(x / scale) + zero_point) 입니다. 나누고, 반올림하고, 영점을 더하고, 자료형의 양끝으로 자릅니다. 순서를 바꾸면 답이 달라집니다 — 영점을 먼저 더하고 반올림하면 안 됩니다. 자른 원소 수를 세어 clipped 로 내세요.
정수를 실수로 되돌리기
dq <양자.npy> <출력.npy> <scale> <zero_point> <mode> 를 더해 정수 배열을 float32 실수 배열로 되돌리게 하세요. 응답에는 min·max·count 가 있어야 합니다.
정의는 (q - zero_point) * scale 입니다. 이 방향에는 반올림도 포화도 없습니다 — 앞에서 잘려 나간 값은 여기서 되살아나지 않습니다. 정수 배열을 float32 로 먼저 올린 뒤 빼야 uint8 에서 음수가 감싸 도는 일을 피합니다.
왕복 오차와 그 상한
roundtrip <입력.npy> <mode> [lo hi] 를 더해 양자화하고 곧바로 역양자화한 뒤 최대 절대 오차와 상한을 함께 내게 하세요. bound 는 스케일의 절반이고 within_bound 는 오차가 그 안에 있는지입니다.
양자화는 값을 스케일 간격의 격자 위 가장 가까운 점으로 옮기는 일입니다. 격자 간격이 스케일이니 가장 멀어도 절반입니다. 부동소수점 때문에 아주 조금 넘칠 수 있으므로 비교에 약간의 여유를 두세요. 이 상한은 값이 범위 안에 있을 때만 성립합니다.
범위를 좁히면 무엇이 깨지는가
data/spread.npy 를 관찰 범위 그대로 한 번, 그리고 -1.0 1.0 으로 좁혀 한 번 왕복시키고 두 결과를 /root/onnxq-scale/saturate.json 에 full·narrow·error_ratio 로 적으세요.
좁은 범위는 스케일을 곱게 만듭니다 — 그 자체는 좋은 일입니다. 문제는 범위 밖으로 나간 값이 벽에 붙는다는 것입니다. 오차가 상한을 크게 넘으면 반올림이 아니라 범위가 원인입니다. error_ratio 는 좁힌 쪽 오차를 원래 오차로 나눈 값입니다.
0 이상만 나오는 자료에 대칭을 걸면
data/positive.npy 에 asym-u8 과 sym-i8 을 각각 걸어 /root/onnxq-scale/symmetry.json 에 asym·sym·scale_ratio 를 적으세요. 각 항목에는 scale·zero_point·max_abs_error·usable_levels 가 있어야 합니다.
usable_levels 는 범위 [lo, hi] 안에 들어가는 눈금의 개수입니다 — 범위 폭을 스케일로 나눈 뒤 1 을 더해 반올림하면 나옵니다. 전부 0 이상인 자료에서 비대칭은 256, 대칭은 128 이 나올 것입니다. 스케일 비가 그대로 오차 비가 되는지 확인해 보세요.
이상값 하나를 가두기
channel <입력.npy> <axis> 를 더해 대칭 int8 로 채널별 스케일과 텐서 전체 스케일을 견주고, data/weights.npy 를 axis 0 으로 돌린 결과를 /root/onnxq-scale/channel.json 에 적으세요.
축 하나를 채널로 보고 채널마다 min·max 를 따로 구해 스케일을 만듭니다. 채널별 스케일은 1차원 배열이고, 브로드캐스트를 위해 해당 축만 길이를 갖도록 모양을 바꿔야 합니다. channels_improved 는 채널별 쪽 오차가 텐서 전체 쪽보다 작아진 채널의 수입니다. 이상값이 든 채널은 나아지지 않는다는 점이 핵심입니다.
잰 것을 한 장으로
/root/onnxq-scale/report.json 에 bound_rule·spread·positive_asym_over_sym·channel_gain·saturation_ratio 를 적고, /root/onnxq-scale/report.md 에 ## 스케일과 영점은 어디서 나오나 ## 왕복 오차의 상한 ## 대칭과 비대칭 ## 이상값 하나가 하는 일 네 절로 쓰세요.
channel_gain 은 텐서 전체 오차를 채널별 오차로 나눈 값이 아니라 가장 좋아진 채널 기준이 더 정직합니다. 이상값이 든 채널은 그대로이므로 전체 최대 오차는 거의 같습니다. 보고서에는 숫자와 함께 그 숫자가 무엇을 가리키는지 한 줄씩 적으세요.