物差しを自分で作る — スケール・ゼロ点・往復誤差
目標
qmath.pyを作成して、QuantizeLinear・DequantizeLinearの定義どおりにスケールとゼロポイントを計算し、量子化と逆量子化を実装し、往復誤差がスケールの半分以内に収まっているかを確認し、対称と非対称、そしてチャンネルごとのスケールとテンソル全体のスケールを、同じデータの上で比べます。
なぜ重要なのか
変換ツールが出力したint8モデルの精度が落ちたとき、直す場所を選ぶには、そのツールが内部で行う算術を知っている必要があります。スケールは観察範囲を目盛りの数で割った値で、ゼロポイントは実数0.0が収まる整数の位置です。範囲に0がないと、パディングとReLUが作った0が、0でない値によみがえります。
往復誤差には上限があります。値が範囲の中にある限り、誤差はスケールの半分を超えません。量子化が、値をスケール間隔の格子に移す作業だからです。そのため、誤差がその半分を大きく超えたなら、丸めではなく範囲が問題です。この1つの物差しが、原因の判断を速くします。
データの形も、方式を決めます。ReLUの後ろの活性化値はすべて0以上なので、対称int8を適用すると負の側の目盛りがそっくり遊び、スケールが2倍粗くなります。重み行列に外れ値が1つあると、テンソル全体のスケールがその値に引きずられて、残りがすべて潰れます。チャンネルを分ければ、被害はそのチャンネルの中に閉じ込められます。
採点ツールは、皆さんが書き出した数字を信じません。毎回違うシードで配列を作り、皆さんのqmath.pyを実際に実行して、同じ入力をONNXのQuantizeLinear・DequantizeLinear演算子にそのまま通して得られた結果と照合します。
ステップ
- /root/onnxq-scale/gen_data.pyを作成して実行し、/root/onnxq-scale/dataの下に配列を3つ作ってから、/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)。- 1枚にまとめてください(出力先: /root/onnxq-scale/report.json、/root/onnxq-scale/report.md)。
参考
- Pythonは
/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を1つの塊で出します。引数の数が合わないときは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は2つです。
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で行ってください。Pythonのfloatで割ると、境界にかかった要素で、ランタイムと1目盛りずれます。 - 素材の配列は、/root/onnxq-scale/dataの下にあるspread.npy・positive.npy・weights.npyの3つです。gen_data.pyが作り、採点ツールはこれらのファイルを読みます。
- 公式ドキュメント: QuantizeLinear · DequantizeLinear · ONNX Concepts · ONNX Runtimeの量子化
- よくある間違い: 範囲に0を入れないこと、対称で128で割ること、
np.rintの代わりに半分を切り下げる丸めを使うこと、飽和が起きた場合にも上限を超えないと書くことです。
観察範囲からスケールとゼロポイントへ
/root/onnxq-scale/gen_data.pyを作成して実行し、/root/onnxq-scale/dataの下に3つの配列を作ってから、/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回、-1.0 1.0に狭めて1回、往復させ、2つの結果をfull・narrow・error_ratioとして書いてください(出力先: /root/onnxq-scale/saturate.json)。
狭い範囲はスケールを細かくします。それ自体は良いことです。問題は、範囲の外に出た値が端に張り付くことです。誤差が上限を大きく超えたら、丸めではなく範囲が原因です。error_ratioは、狭めたほうの誤差を元の誤差で割った値です。
0以上しか出ないデータに対称を適用すると
data/positive.npyにasym-u8とsym-i8をそれぞれ適用して、asym・sym・scale_ratioを書いてください(出力先: /root/onnxq-scale/symmetry.json)。各項目には、scale・zero_point・max_abs_error・usable_levelsが必要です。
usable_levelsは、範囲[lo, hi]の中に入る目盛りの個数です。範囲の幅をスケールで割り、1を足して丸めると出ます。すべて0以上のデータでは、非対称は256、対称は128になるはずです。スケールの比がそのまま誤差の比になるか、確認してみてください。
外れ値1つを閉じ込める
channel <입력.npy> <axis>を追加して、対称int8でチャンネルごとのスケールとテンソル全体のスケールを比べさせ、data/weights.npyをaxis 0で実行した結果を書いてください(出力先: /root/onnxq-scale/channel.json)。プレースホルダーは、入力ファイルと軸です。
軸を1つ選んでチャンネルと見なし、チャンネルごとにminとmaxを別々に求めてスケールを作ります。チャンネルごとのスケールは1次元配列で、ブロードキャストのために、その軸だけが長さを持つように形を変える必要があります。channels_improvedは、チャンネルごとのほうの誤差がテンソル全体のほうより小さくなったチャンネルの数です。外れ値のあるチャンネルは改善しないというのが核心です。
測ったことを1枚にまとめる
/root/onnxq-scale/report.jsonにbound_rule・spread・positive_asym_over_sym・channel_gain・saturation_ratioを書き、/root/onnxq-scale/report.mdに## 스케일과 영점은 어디서 나오나 ## 왕복 오차의 상한 ## 대칭과 비대칭 ## 이상값 하나가 하는 일の4つのセクションで書いてください(韓国語の見出しは、順に「スケールとゼロポイントはどこから出てくるのか」「往復誤差の上限」「対称と非対称」「外れ値1つが及ぼす影響」という意味です)。
channel_gainは、テンソル全体の誤差をチャンネルごとの誤差で割った値ではなく、最も良くなったチャンネルを基準にするほうが正直です。外れ値のあるチャンネルはそのままなので、全体の最大誤差はほとんど同じです。レポートには、数字とあわせて、その数字が何を指すのかを1行ずつ書いてください。