PyTorchをONNXへ変換して本当に同じ?FashionMNIST 1万枚と速度を実測

実験日: 2026-08-24
環境: RTX 5070 Ti / WSL2 / PyTorch 2.13.0+cu130 / ONNX 1.22.0 / ONNX Runtime 1.29.0

PyTorchで学習したmodelを別のruntimeやedge deviceへ持ち出すとき、ONNXはよく使われます。しかしexport commandが成功しただけでは、出力が同じか、dynamic batchが動くか、速くなるかは分かりません。

export成功は確認の入口です。1万枚の出力が同じかは、別に検証します。

FashionMNIST CNNをこのPCで3 epoch学習し、opset 18のONNXへexportしました。10,000枚すべてでlogitを比較すると最大絶対差は5.72×10^-6、top-1 predictionの変化は0枚、accuracyは両方86.50%でした。

CPU推論はbatch 1でONNX Runtimeがわずかに遅く、batch 32で2.59倍、batch 256で3.66倍速くなりました。「ONNXにすれば必ず速い」ではなく、batchとruntime最適化を含めて測る必要があります。

1万枚で出力が一致した

確認項目 結果
test画像 10,000枚
最大logit絶対差 5.72e-6
平均logit絶対差 5.07e-7
top-1一致率 100%
prediction変化 0枚
PyTorch accuracy 86.50%
ONNX Runtime accuracy 86.50%
batch PyTorch CPU中央値 ONNX Runtime CPU中央値 ORT倍率
1 0.0799 ms 0.0851 ms 0.94×
32 0.4791 ms 0.1848 ms 2.59×
256 3.2021 ms 0.8744 ms 3.66×
FashionMNIST CNNをPyTorchからONNXへ変換したCPU推論時間と出力一致
FashionMNIST CNNをPyTorchからONNXへ変換したCPU推論時間と出力一致

ONNXはweight fileだけではない

PyTorchのstate_dictはparameter名とtensorを保存します。model classのPython codeは別に必要です。

torch.save(model.state_dict(), "fashion_cnn_state_dict.pt")

ONNX fileは、operator graph、initializer tensor、input/output definitionなどを共通形式で表します。Pythonのmodel classをそのまま配布しなくても、ONNXを理解するruntimeで実行できます。

今回のfile sizeです。

PyTorch state_dict  427,551 bytes
ONNX graph          425,562 bytes

ほぼ同じなのは大部分が同じFP32 weightだからです。ただしdeployment sizeではありません。PyTorchやONNX Runtime本体、CUDA provider、Python、OS libraryを含めていません。

学習modelを固定する

architectureはこれまでのFashionMNIST記事と同じ小型CNNです。

Conv2d 1→16, 3×3 + ReLU + MaxPool
Conv2d 16→32, 3×3 + ReLU + MaxPool
Flatten
Linear 1568→64 + ReLU
Linear 64→10

seed 42、Adam、learning rate 1e-3、batch 256でCUDA学習しました。

epoch 1  2.241 s  loss 0.7327
epoch 2  1.644 s  loss 0.4251
epoch 3  1.634 s  loss 0.3742

学習後にCPUへ移し、eval()を呼びます。dropoutやbatch normalizationがあるmodelでは、train/eval modeを間違えるとexport前後の比較が崩れます。

model = model.cpu().eval()

dynamic batch付きでexport

dummy inputはshape (1, 1, 28, 28)です。

example = torch.zeros(1, 1, 28, 28)

torch.onnx.export(
    model,
    example,
    "fashion_cnn_dynamic.onnx",
    input_names=["image"],
    output_names=["logits"],
    dynamic_axes={
        "image": {0: "batch"},
        "logits": {0: "batch"},
    },
    opset_version=18,
    dynamo=False,
)

batch axisをdynamicにしないと、exampleと同じbatch 1専用graphになる場合があります。今回は1、32、256の全batchを同じONNX fileへ入力できました。

opset_versionはONNX operator specificationのversionです。大きければ常に良いわけではなく、exporterとtarget runtimeの対応範囲を合わせます。

legacy exporterのwarningを無視しない

PyTorch 2.13は次のwarningを出しました。

You are using the legacy TorchScript-based ONNX export.
Starting in PyTorch 2.9, the new torch.export-based ONNX exporter
has become the default.

今回はdynamo=Falseを明示し、simple CNNを既知のlegacy pathでexportしました。記事の再現性のため、warningを消していません。

新規projectではPyTorchのONNX export documentationを確認し、dynamo=Trueの新exporterもtestすべきです。export routeが違えば対応operatorやdynamic shapeの扱いも変わります。

checkerを通してからruntimeへ渡す

fileが書けた後、ONNX checkerを実行しました。

graph = onnx.load(path)
onnx.checker.check_model(graph)

今回のgraphはIR version 8、node数10で、checkerを通過しました。

checkerはgraph schemaやtypeの整合性を検査します。しかしPyTorchと数値的に同じ出力を保証しません。そのため次に実dataでparity testします。

ONNX Runtime CPU sessionを作る

session = ort.InferenceSession(
    "fashion_cnn_dynamic.onnx",
    providers=["CPUExecutionProvider"],
)

今回はCPU同士を比較しました。PyTorch CUDAとONNX Runtime CPUを比べると、format差とdevice差が混ざるからです。

input名imageとoutput名logitsはexport時に固定しました。

ort_logits = session.run(
    ["logits"],
    {"image": batch.numpy()},
)[0]

ONNX RuntimeはNumPy arrayを受け取ります。dtypeはFP32、shapeはNCHWです。

10,000枚すべてのlogitを比較

1~2 sampleの目視ではなく、test set全体を512枚ずつ実行しました。

diff = np.abs(pytorch_logits - onnx_logits)

max_abs = diff.max()
mean_abs = diff.mean()
agreement = np.mean(
    pytorch_logits.argmax(1) == onnx_logits.argmax(1)
)

結果です。

max absolute difference   0.000005722
mean absolute difference  0.000000507
top-1 agreement           1.0000
changed predictions       0 / 10,000

floating-point演算はoperator fusion、計算順序、library実装で丸め差が出ます。logitのbitwise equalityを要求せず、誤差の大きさとtask-level predictionの両方を確認しました。

今回は全predictionが一致しましたが、class境界で2つのlogitが極めて近いsampleなら、数µの差でargmaxが変わる可能性があります。だから最大差だけでなくtop-1 agreementも必要です。

accuracyも再計算する

PyTorch CPU accuracy       86.50%
ONNX Runtime CPU accuracy  86.50%

top-1が10,000枚すべて一致するため、accuracyも一致します。重要なのは、accuracy 86.50%だけでは変換品質を判定できないことです。

例えば100枚のpredictionが別classへ入れ替わっても、正解→誤答と誤答→正解が同数なら全体accuracyは同じです。parityにはsample-level agreementを使います。

benchmarkはwarmup後100回

各batchで同じinputを使い、20回warmupした後100回測定しました。

for _ in range(20):
    inference()

for _ in range(100):
    started = time.perf_counter()
    inference()
    times.append(time.perf_counter() - started)

中央値、平均、p95をJSONへ保存しています。記事の表は外れ値に比較的強いmedianです。

DataLoader、file read、前処理、postprocessは含みません。すでにmemory上にあるtensor/NumPy arrayのforward latencyです。

batch 1ではPyTorchがわずかに速い

PyTorch CPU  0.0799 ms
ORT CPU      0.0851 ms

ONNX Runtimeは0.94倍、つまり約6%遅い結果です。差は約0.005 msで非常に小さく、OS schedulingや測定noiseの影響を受けます。

低latency requestでは、graph optimizationの利益よりruntime call overheadが支配することがあります。「ONNXへ変換したから速い」とは限りません。

batchを増やすとONNX Runtimeが速かった

batch 32   PyTorch 0.4791 ms  ORT 0.1848 ms  2.59×
batch 256  PyTorch 3.2021 ms  ORT 0.8744 ms  3.66×

batch 256のmedian throughput換算です。

PyTorch CPU       約79,947 images/s
ONNX Runtime CPU 約292,756 images/s

同じCPUでもONNX Runtimeのgraph optimizationやoperator implementationがこのCNNとbatch sizeで効いた可能性があります。CPU thread設定は明示固定していないため、libraryのdefault thread poolも差へ含まれます。

productionで比較するときはthread数、process数、power setting、NUMA、input copyまで固定します。

dynamic batchが動くこともtestになっている

export exampleはbatch 1でしたが、ONNX Runtimeへbatch 32と256を渡せました。これはdynamic axis指定がruntimeで機能した実証です。

shape flexibilityはmodel parityとは別のcontractです。可変解像度や可変sequence lengthが必要なら、それぞれのaxisをdynamicにして複数shapeでtestします。

model deploymentで追加確認すること

今回のsimple CNNは成功しましたが、実modelでは次が問題になります。

  • unsupported operator
  • custom autograd function
  • Python control flow
  • dynamic shapeの不完全な伝播
  • preprocessing差
  • NCHW/NHWCの取り違え
  • FP16/INT8変換後のaccuracy低下
  • providerごとのoperator fallback

export、checker、runtime load、parity、performanceを別stageにすると、失敗場所を切り分けられます。

PyTorch eval model
  ↓ export
ONNX file
  ↓ checker
valid graph
  ↓ runtime load
executable session
  ↓ full test parity
numerical confidence
  ↓ workload benchmark
deployment decision

再現手順

source /opt/ai-lab/venv/bin/activate
pip install onnx onnxruntime

cd /mnt/c/Users/user/Documents/Codex/2026-08-24/ko/work/ai_lab
python -u experiment_pytorch_onnx.py
python plot_pytorch_onnx.py

artifactです。

work/ai_lab/experiment_pytorch_onnx.py
work/ai_lab/models/fashion_cnn_state_dict.pt
work/ai_lab/models/fashion_cnn_dynamic.onnx
work/ai_lab/results/pytorch_onnx.json
work/figures/pytorch-onnx-fashionmnist.png

raw JSONにはenvironment、training、10,000枚parity、batch別100回timing、file size、graph metadataを保存しました。

結論

このFashionMNIST CNNはPyTorchからONNXへ変換しても、10,000枚のtop-1 predictionがすべて一致しました。最大logit差は5.72e-6で、task accuracyも86.50%を維持しています。

速度はbatch依存でした。batch 1はONNX Runtimeが少し遅く、batch 256では3.66倍速くなりました。format名ではなく、実際のrequest sizeでbenchmarkする必要があります。

同じtrained weightをINT8とINT4へ丸めるsimulationを行うと、model sizeの理論値だけでなく、logit誤差・prediction変化・accuracy低下まで評価できます。

関連記事