INT8量子化でCNNをエッジ推論向けに最適化する — ONNX Runtime PTQ 実践記
はじめに
FP32 の MobileNetV2 を、PTQ(Post-Training Quantization)で INT8 に変換し、ファイルサイズ約 1/4・CPU 推論 2〜3× を狙った実験記録です。
INT8のSIMD命令のsdotがない Raspberry Pi 4 でも INT8 Static は約 2.2× 高速化しました。「sdot がなければ速くならない」という予想が外れた理由を、律速要因の観点から整理します。
環境
| 項目 | バージョン / 機種 |
|---|---|
| Python | 3.10 |
| onnxruntime | 1.25.1 |
| モデル | MobileNetV2 (torchvision pretrained) |
| ホスト | MacBook Air M5 |
| 実機 | Raspberry Pi 4 (Cortex-A72) / Pi 5 (Cortex-A76) |
| 評価データ | ImageNet-val(Top-1 精度計測・500 枚サブセット) |
| キャリブレーションデータ | ImageNet-val(Static 量子化の活性化値域推定・100 枚) |
量子化の種類を整理する
PTQ の 2 方式
Dynamic Quantization
重みだけを export 時に INT8 化し、活性化(activation)は推論のたびにスキャンしてスケールを動的に計算します。キャリブレーションデータが不要で手軽ですが、各層で値域スキャンのオーバーヘッドが発生します。
Static Quantization
重みと活性化の両方を事前に INT8 化します。キャリブレーション(代表データを流して活性化の値域を観測する工程)が必要ですが、推論時にスキャンは走りません。
CNN では Static が本命です。 層数が多い CNN で Dynamic を使うと、毎層のスキャンコストが積み重なります。また活性化の分布は入力画像ドメインでほぼ一定なので、事前にキャリブレーションしておける Static の方が安定して速くなります。
QDQ 形式 vs QOperator 形式
ONNX の量子化表現には 2 種類あります。現在の標準は QDQ 形式(QuantizeLinear / DequantizeLinear op をグラフに挿入する方式)です。
グラフの見た目の違い
QDQ 形式 — Q/DQ op がグラフに明示的に現れる。Q と DQ の間が「INT8 領域」を意味する。
QOperator 形式 — 量子化が専用 op(QLinearConv)に内包されている。融合済みのためグラフはシンプルだが、ランタイム依存が強い。
なぜ QDQ が標準になったか
| 観点 | QDQ 形式 | QOperator 形式 |
|---|---|---|
| ランタイム互換性 | ORT / TensorRT / その他で動く | ORT 依存が強い |
| コンパイラの最適化 | Q/DQ をマーカーにして融合・削除できる | すでに融合済みで再構成しにくい |
| 部分的 FP32 実行 | Q/DQ を外せば FP32 に戻せる | 戻せない |
QDQ の Q/DQ ペアは「ここから先は整数域」という宣言的なマーカーです。ランタイムが「整数で計算する」「一部だけ FP32 に戻す」を自由に選べるため、モデルと実装の分離が保たれます。
per-tensor vs per-channel
量子化のスケール(倍率)をテンソル全体で 1 つ持つのが per-tensor、出力チャネルごとに持つのが per-channel です。
Conv の重みはチャネルごとに値域が 10 倍以上ばらつくことが珍しくありません。per-tensor で全チャネルを 1 スケールに押し込むと、値域の小さいチャネルは「実質 2〜3 bit しか使われない」状態になり情報が潰れます。per-channel なら各チャネルが 8 bit を丸ごと使えます。
「チャネル」の単位は層の種類によって異なります。Conv はフィルタ 1 本 = 1 チャネル、FC(Linear)は重み行列の 1 行 = 1 チャネルです。どちらも「1 つの出力を生成する重みのまとまり」がスケールの単位になります。
推論時の追加コストはほぼゼロです(スケール乗算は DQ op に吸収されるため)。重み: per-channel symmetric、活性化: per-tensor asymmetric が現代のデファクトスタンダードで、onnxruntime.quantization の既定もこれです。
Dynamic Quantization は CNN に効かない
まず Dynamic Quantization を試します。実装は quantize_dynamic(...) を呼ぶだけで、キャリブレーションデータも不要です。
ところがそのまま実行すると、Conv 層に ConvInteger op が挿入されて推論時にクラッシュします。
ONNX Runtime の CPU カーネル(MLAS)は ConvInteger を実装していません。実装があるのは MatMulInteger(Transformer の self-attention などに使う op)だけです。
回避策として Conv を除外し、MatMul だけを量子化します。
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
input_model_path,
output_model_path,
weight_type=QuantType.QInt8,
op_types_to_quantize=["MatMul"], # Conv を除外
)
MobileNetV2 は最終分類層ぐらいしか MatMul を持たない純 Conv モデルなので、レイテンシと精度はほぼ変わりません。
| 量子化方法 | サイズ (MB) | Latency M5 Mac (ms) | Latency Pi 5 (ms) | Latency Pi 4 (ms) | Top-1 (%) |
|---|---|---|---|---|---|
| fp32(量子化なし) | 13.35 | 8.42 | 15.01 | 56.66 | 57.92 |
| int8_dynamic | 9.70 | 9.46 | 14.62 | 56.80 | 58.02 |
Dynamic の真価は LSTM や Transformer など「層数が少なく 1 回の matmul が重い」モデルです。CNN では Static を使いましょう。
Static Quantization で本命の INT8 化
per-tensor から試す
quantize_static(...) を per_channel=False で呼びます。事前に CalibrationDataReader を実装して ImageNet-val の代表画像を流す必要があります。CalibrationDataReader は onnxruntime.quantization からインポートする抽象クラスで、get_next() をオーバーライドして使います。
from onnxruntime.quantization import CalibrationDataReader
class CalibrationImageReader(CalibrationDataReader):
def get_next(self):
# {"input": np.ndarray(1,3,224,224)} を返す。データが尽きたら None
...
from onnxruntime.quantization import (
quantize_static,
QuantType,
QuantFormat,
CalibrationMethod,
)
from calibration_reader import CalibrationImageReader
reader = CalibrationImageReader(calibration_data_dir, input_name="input")
quantize_static(
model_input,
model_output,
calibration_data_reader=reader,
quant_format=QuantFormat.QDQ,
per_channel=False,
activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8,
calibrate_method=CalibrationMethod.MinMax,
)
per-channel に切り替える
per_channel=True に変えるだけです。Conv の重みスケールがチャネルごとに計算されます。
結果
| 量子化方法 | サイズ (MB) | Latency M5 Mac (ms) | Latency Pi 5 (ms) | Latency Pi 4 (ms) | Top-1 (%) |
|---|---|---|---|---|---|
| fp32(量子化なし) | 13.35 | 8.42 | 15.01 | 56.66 | 57.92 |
| int8_dynamic | 9.70 | 9.46 | 14.62 | 56.80 | 58.02 |
| int8_static_tensor | 3.49 | 3.72 | 6.28 | 25.39 | 51.69 |
| int8_static_channel | 3.72 | 3.73 | 6.37 | 25.70 | 57.44 |
ファイルサイズは fp32 の約 1/4 に収まっています。per-channel(57.44%)は per-tensor(51.69%)より Top-1 が 5.75pt 高く、チャネルごとのスケール管理の効果がはっきり出ています。
sdot がなくても速くなった — 律速要因で見る INT8 の効果
sdot 命令とは
sdot は ARMv8.2 dotprod 拡張で追加された INT8 専用 SIMD 命令で、4 個の int8 積和を 1 命令でまとめて計算します。
| SoC | コア | sdot |
|---|---|---|
| Raspberry Pi 4 | Cortex-A72 | なし |
| Raspberry Pi 5 | Cortex-A76 | あり |
| Apple M5 | Firestorm/Icestorm 系 | あり(smmla) |
当初「Pi 4 は sdot がないので INT8 にしても速くならないはず」と予想していましたが、実測では FP32: 56.66ms → INT8 Static per-ch: 25.70ms(約 2.2×) と大きく改善しました。
メモリ帯域律速 vs 演算律速
INT8 の恩恵は「どこがボトルネックか」で決まります。
-
演算律速(計算がボトルネック):
sdotの有無が直接速度に効く。大きな行列積(Transformer の Linear 層、LLM の prefill など)が典型 -
メモリ帯域律速(データ転送がボトルネック):
sdotの効果は小さい。代わりに モデルサイズ削減(13MB → 3.5MB)によるメモリ帯域の節約が効く
MobileNetV2 は Depthwise Conv が主体で、チャネルごとに小さいカーネルを走らせるためメモリ帯域律速になりやすいモデルです。Pi 4 でも INT8 Static が 2× 以上速くなったのはこのためです。
| 演算 | 律速要因 |
sdot の効果 |
|---|---|---|
| CNN Depthwise Conv | メモリ帯域 | 小さい |
| LLM decode(GEMV) | メモリ帯域 | 小さい |
| LLM prefill(GEMM) | 演算 | 大きい |
| デバイス | FP32 (ms) | INT8 Static per-ch (ms) | 倍率 |
|---|---|---|---|
| M5 Mac | 8.42 | 3.73 | 2.3× |
Pi 5 (A76, sdot あり) |
15.01 | 6.37 | 2.4× |
Pi 4 (A72, sdot なし) |
56.66 | 25.70 | 2.2× |
sdot の有無にかかわらず、ほぼ同じ倍率の高速化が得られました。INT8 の速度効果を見積もるには、まずそのモデルが演算律速かメモリ帯域律速かを把握することが重要です。
なお FP32 での Pi 4(56.66ms)と Pi 5(15.01ms)の約 3.8× の絶対差はアーキテクチャによる差であると考えられます。クロック(1.5GHz → 2.4GHz、1.6×)× IPC(A76 は A72 より ~1.5〜2×)に加え、A76 のキャッシュ拡大・プリフェッチ改善などメモリサブシステム全体の強化があります。
まとめ
- Dynamic Quantization は CNN にほぼ効かない。ORT の CPU カーネルが
ConvIntegerを実装していないため - CNN の本命は Static Quantization(QDQ 形式、per-channel)。キャリブレーションデータを用意すれば FP32 比 -1pt 以内で収まる
- INT8 の速度向上メカニズムは律速要因で変わる。メモリ帯域律速(CNN Depthwise Conv、LLM decode など)ではモデルサイズ削減による帯域節約が効き、
sdotがなくても高速化する。演算律速(LLM prefill の大規模 GEMM など)ではsdotのような高速 SIMD 命令が効くかどうかが速度を左右する
Discussion