Depth Anything V2 SmallにFP32は必要か。CPU・GPUでINT8・INT4・FP8を比べた

このPCでは、CPUはdynamic INT8、RTX 5070 TiはFP16を第一候補にします。CPUのINT8は容量と時間を減らせましたが、深度の正解値(Ground Truth、GT)として実測値を使うと、画素ごとの誤差がわずかに増えました。4bitはさらに小さくなる一方、GPUではFP16より速くならず、実測GTに対する誤差も増えています。

Depth Anything V2 SmallをFP32、INT8、INT4へ変換し、Core i7-14700FとRTX 5070 Tiで動かしました。CPUのdynamic INT8はFP32の275.0 msから198.8 msへ短縮。一方、学習済みモデルを保存・実行する形式であるONNXの同じファイルをCUDAへ渡すと59.36 msかかり、FP32の12.89 msより遅くなっています。整数化した演算の一部がCPUに残っていました。

FP8も同じGPU・実行環境で追加比較しました。FP16から容量は約43%減りましたが、推論時間は6.45 msから45.50 msへ延び、NYUv2の実測GTに対する誤差も小さく増えています。GPUがFP8に対応していても、この実行経路ではFP16を選ぶ結果になりました。

CPUではINT8が198.8 ms、INT4は小さいが速いとは限らない

まずは同じ4つのONNXを、CPUの8スレッドで比較しました。入力は518×518画素、一度に処理する枚数(batch size)は1。時間はモデルの読み込み後の推論呼び出しで、画像読込や前後処理は含みません。

画像はOpenCVのCUBIC補間で518×518へ直接リサイズしています。切り抜きはせず、縦横比も変わります。縦横比を保つ公式の通常前処理とは違うため、以下はこの固定入力条件での比較です。

モデル ONNX容量 CPU推論の中央値 GT近遠正答率 FP32の正答から悪化 / 改善
FP32 94.36 MiB 275.0 ms 476/507、93.89% ―
dynamic INT8 33.89 MiB 198.8 ms 478/507、94.28% 0 / 2ペア
INT4、途中の値はFP32 26.02 MiB 266.9 ms 472/507、93.10% 11 / 7ペア
INT4、内部INT8近似を許可 26.02 MiB 429.6 ms 473/507、93.29% 10 / 7ペア
CPUの推論中央値、ONNX容量、FP32からの近遠正答率差と95%信頼区間を並べたグラフ
左から推論時間、ファイル容量、近遠正答率差です。右の横線は画像単位で再抽出した95%信頼区間。INT4は容量が減っても、CPU推論が速くなるとは限りません。画像を開くと拡大できます。

dynamic INT8は容量が約64%減り、推論は約1.38倍の速さになりました。INT4へ下げるともう少し小さくなりますが、高速化の効果はほぼ消えています。内部のINT8近似を許可した版は、むしろ遅くなりました。ファイル名のint4_fastは設定を区別するコード名で、速さの保証ではありません。

正答率に使ったのは、作者のDA-2Kから選んだ256画像です。8カテゴリから各32画像をseed42で抽出し、2点のうちどちらが近いか、507ペアを判定しました。メートルで距離を当てる問題ではありません。

INT8の正答が2ペア増えましたが、これだけで精度が上がる手法とは言えません。FP32との差は+0.39ポイント、対応のある95%信頼区間は[0.00, +1.00]ポイントです。INT4の2設定も区間に0を含みました。数百枚で見分けられない小さな差は残りますし、同じ正答率でも答えが入れ替わったペアがあります。

また、GTの正答を維持しても深度画像が同じとは限りません。暗い室内と明るい窓が写った画像では、INT8でもFP32との差が大きくなりました。全画像の平均だけでなく、使う写真で細部を見る必要があります。

GPUでは、まずFP16を比較に入れる

次はRTX 5070 Tiです。CPU版をそのまま流すだけでなく、途中の浮動小数点計算をFP16へ変えた版も用意しました。

W8A16は、対象の重みを8bit、途中の浮動小数点値を16bitにした構成。W4A16は対象重みが4bitです。ここでのWとAは、それぞれ重みと計算途中の値を表す目印です。モデル全体が一律に整数演算になるという意味ではありません。

モデル ONNX容量 CPU上の入出力を使う時間 入出力をGPU上へ置く時間
FP32 94.36 MiB 12.89 ms 12.56 ms
FP16 47.26 MiB 6.47 ms 6.12 ms
dynamic INT8 33.89 MiB 59.36 ms 62.00 ms
INT4、途中の値はFP32 26.02 MiB 13.48 ms 13.11 ms
INT4、内部INT8近似を許可 26.02 MiB 13.54 ms 13.12 ms
W8A16 28.28 MiB 6.44 ms 6.13 ms
W4A16 18.15 MiB 6.48 ms 6.12 ms
RTX 5070 Tiで各数値形式の推論時間とONNXファイル容量を比較したグラフ
青はCPU上の入力をGPUへ送り、結果をCPUへ戻す呼び出し。薄い青は入出力をGPU上へ置いたI/O Bindingです。dynamic INT8には内部のCPU演算が残るため、入出力をGPUへ置いても転送がなくなりません。

FP16、W8A16、W4A16は約6.4 msへまとまりました。数十マイクロ秒の差で、この3つの速度順位を決めるのは無理があります。今回のGPUで整数化する理由は、FP16よりさらに容量を減らしたいかどうかです。W4A16は18.15 MiBまで小さくなりました。

時間は20回ウォームアップした後の100回の中央値で、TF32は無効にしました。TF32はFP32の行列計算を別の精度・経路で速くする設定です。今回はFP32との比較に混ぜないため、ONNX Runtimeのuse_tf32=0を指定しています。モデル読込、初回呼び出し、画像の前後処理は、この表には含めていません。

入出力をGPU上へ置く方は、I/O BindingでCUDA上の入力と出力を指定し、完了を待って計測しました。単にGPU処理をキューへ入れるまでの時間ではありません。

CUDAが使えても、全演算がGPUとは限らない

dynamic INT8が遅い理由を探るため、時間の計測とは別にONNX Runtimeの実行記録を取りました。

その結果、DynamicQuantizeLinearとMatMulIntegerが各48個、CPU上で実行されていました。残りのGPU演算との間を行き来するので、小さくなった重みだけを見ても処理全体は速くなりません。Provider一覧にCUDAExecutionProviderがあることと、すべての演算がCUDAへ割り当てられたことは別でした。

providers=[
    ("CUDAExecutionProvider", {"use_tf32": 0}),
    "CPUExecutionProvider",
]

後ろのCPU Providerは、CUDAが扱えない演算の受け皿にもなります。これを含む実行はGPU専用のINT8推論とは呼べません。配布コードでは、実行記録から演算名とProviderを数え、results_gpu/benchmark.jsonへ保存しています。

W8A16/W4A16など、ほかの比較モデルの計算演算はCUDAへ割り当てられていました。ただし、MatMulNBitsという演算があるだけで、ネイティブのINT4/INT8 Tensor Coreを使ったとまでは分かりません。内部で重みを戻してFP16の行列計算へ渡す経路もあり得ます。ここで確認したのは配置と所要時間です。

近遠判定で見えなかった差を、画素ごとのGTで調べる

DA-2Kは2点の順序を調べるための正解データです。窓の奥行きや壁の起伏が変わっても、その2点の順序が同じなら正答のままです。そこで、画素ごとの正解深度を持つNYUv2とMPI Sintelでも比較しました。

先に、ここで測る「精度」をはっきりさせます。V2 Smallの通常版が返すのは、値が大きいほど近い相対的な逆深度です。出力の21や1.6に、そのままメートルを付けられません。V2論文も、倍率と切片に自由度のある逆深度を予測すると説明しています。

今回は各画像のGTを使い、予測の倍率と切片をGTの逆深度へ最小二乗で合わせました。倍率は負にならないようにします。正解を見ながら整合した後の形の評価なので、未知の写真から絶対距離を当てられた、という結果ではありません。この条件は全モデルで共通です。

実測GTと補完GTでは、量子化後の順位が変わった

NYUv2は公式テスト654画像からseed42で128画像を抽出しました。Kinectの実測値をRGBへ投影したrawDepthsと、欠損を補完したdepthsが別々にあります。実測の有効画素は約69.10%、補完した方は100%でした。補完された残りの画素まで、センサーが独立に測った値ではありません。

AbsRelは、各画素で「距離の差の絶対値÷正解距離」を求めたときの平均です。まず各画像の有効画素で平均し、次に128画像の値を平均しました。表には100倍せず、そのまま載せています。小さい方が誤差は少なくなります。有限な0 < depth < 10 mを全モデル共通の有効画素(mask)にし、元画像480×640を切り抜かず評価しました。

CPUのモデル 実測GTのAbsRel ↓ 補完GTのAbsRel ↓
FP32 0.051792 0.088964
dynamic INT8 0.052307 0.088124
INT4、途中の値はFP32 0.055520 0.087340
INT4、内部INT8近似を許可 0.055582 0.087484

実測GTで見ると、CPUのINT8はFP32からAbsRelが約0.000515増えました。撮影場所(scene)単位で再抽出した95%信頼区間は[+0.000090, +0.000935]で、0を含みません。近遠判定で「正答から誤答へ変わったペアが0組」でも、画素ごとの誤差は増えていました。容量と速度の利点に対して、この小さな誤差増を許容できるかがCPU側の判断になります。

GPUのFP16系も、同じ128画像で比べます。

GPUのモデル 実測GTのAbsRel ↓ 補完GTのAbsRel ↓ 実測GTのδ1 ↑
FP32 0.051792 0.088964 97.56%
FP16 0.051757 0.088932 97.57%
W8A16 0.051731 0.089035 97.57%
W4A16 0.055508 0.087291 97.28%

δ1は、予測と正解の大きい方を小さい方で割った比が1.25未満になる画素の割合です。こちらは高い方がよく、表では100倍して%にしています。

FP16とW8A16の実測GTの数値はFP32にごく近いものでした。わずかに誤差が下がっていますが、別の写真でも改善するかは分かりません。W4A16では実測GTのAbsRelが約0.003717増え、その差の95%信頼区間は[+0.001938, +0.005596]でした。一方、補完GTだけを見ると、W4A16の方が低い誤差に見えます。両者はGTの値だけでなく有効画素も違い、倍率と切片もそれぞれの有効画素で別々に合わせています。補完の有無だけでは原因を切り分けられませんが、評価方法によって順位が変わる結果でした。採用判断では実測側を主に見ます。

ここでの信頼区間は、同じ撮影場所の画像をひとまとめにして重複を許して再抽出するbootstrapで求めました。128画像には95のsceneが含まれます。同じ場所の画像は似ているため、画素数の多さだけでは比較の確かさを判断できません。

差が大きかった1枚も載せます。平均だけでなく、4bitで変わった部分を見るための例です。

NYUv2の同一入力に対するGPUモデル出力の比較。上段にFP32、FP16、INT8 W8A16、INT4 W4A16の逆深度予測を共通色域で表示し、下段にFP32へscaleとshiftを整合した差を青・白・赤で表示。INT4 W4A16で実測GTのAbsRel増加が最大だった画像を選択。元RGB画像とGT深度画像は含まない。
実測GTに対するAbsRelの増加が128画像中で最大だったnyu_0861。FP32は0.0717、W4A16は0.1126でした。上段は共通色域の逆深度予測、下段はFP32へ倍率と切片を合わせた差(色域±10%)。下段はGT誤差ではありません。AbsRelは各画像のGTによる整合後の値です。元RGB・GT深度画像は掲載していません。

Sintelでは、距離へ戻すと誤差が膨らんだ

Sintelは合成映像をレンダリングした深度なので、センサーの穴を色から埋めたGTとは違います。23シーケンスから計64画像を選び、有効な約2857万画素を使いました。元画像は436×1024、切り抜きなしです。

元データには背景相当の約1e11という値があるため、有限な0 < depth < 10000 mだけを評価します。全画素の99.97355%を残し、約0.02645%を除きました。この上限は実験側で決めた条件で、公式の標準規則ではありません。背景相当値を除く深度の最大値約3364 mは残っています。配布深度がカメラ軸方向のZか光線方向の距離かは一次資料で確定できず、配布値は変換せず使いました。

この広い距離範囲では、逆深度の整合後に0付近や負になる予測が一部ありました。距離は逆数なので、0へ近づくと値が膨らみます。今回は逆深度の下限を1/10000にし、その画素も落とさず評価しました。下限処理された割合も一緒に記録しています。

GPUのモデル 逆深度NRMSE ↓ δ1 ↑ 距離へ戻したAbsRel ↓ 下限処理した画素
FP32 0.413164 65.47% 4.9512 1.489%
FP16 0.413102 65.45% 4.9445 1.488%
W8A16 0.412991 65.58% 4.9809 1.493%
W4A16 0.423222 67.11% 4.4796 2.915%

逆深度NRMSEは、整合後の逆深度のRMSEを、GT逆深度の標準偏差で割った値です。逆数へ戻す前の差を見るので、ここでは主にこちらを使います。

W4A16は逆深度NRMSEが0.413164から0.423222へ増えましたが、δ1は上がり、距離側のAbsRelは下がっています。下限処理される画素も約2倍になりました。このAbsRelだけを見て「4bitの方が正確」とは言えません。逆数と下限処理の影響が大きく、指標ごとに違う面を拾っています。

W4A16とFP32の逆深度NRMSE差について、シーケンス単位で再抽出した95%信頼区間は[-0.004198, +0.023870]です。観測値の増加はありましたが、この64画像から全シーンで誤差が増えるとまでは決められません。Sintelは合成映像、NYUv2は屋内実写で、距離範囲も違います。

今回は抽出した画像を正方形入力にし、切り抜きなし、独自の評価範囲とGT整合で測っています。公式標準スコアや論文の順位表とは直接比較できません。

FP8は27.03 MiBになったが、この実装では遅かった

INT8と同じ8bitでも、FP8は浮動小数点です。今回は符号1bit・指数4bit・仮数3bitのE4M3FNを使いました。FP16より刻みは粗くなり、扱う値の範囲に合わせたスケールも必要です。ONNXのFP8解説に、形式ごとの表現範囲がまとまっています。

対象はこれまでと同じ48個の定数重みMatMulです。重みと入力の両方をFP8にするW8A8にし、ほかの部分は既存のFP16モデルの構成を保ちました。外部の入出力はFP32です。前の表のW8A16は整数の8bit重みでしたが、ここでのW8A8は重み・入力ともに浮動小数点の8bitです。

入力のスケールを決める校正には、NYUv2の公式train 795画像からseed43で選んだ64画像・58撮影場所を使いました。公式test 654画像とは画像IDも撮影場所も重複せず、GTの値は校正に使っていません。各層で、重みはテンソル全体の最大絶対値、入力は校正64画像での入力テンソルの最大絶対値からスケールを決める、単純なMax校正です。屋内画像だけで校正しているため、Sintelのように分布が違う画像で使う場合は、この条件も含めて結果を読む必要があります。

実行にはONNX Runtime 1.31.0のGemmFloat8を使いました。FP8の重み・入力をcuBLASLtというNVIDIAの行列計算ライブラリへ渡し、FP16の結果を返す経路です。内部の設定はFP32 compute typeと高速累積(FAST_ACCUM=1)で、計算の全過程をFP32精度に保つ設定ではありません。実行記録では48個のGemmFloat8と他の計算演算がCUDAへ割り当てられ、CPU側の計算は0個でした。GPUの命令単位の追跡までは行っていません。

今回のFP8モデルはONNXの演算仕様の版であるopset 21で作ったため、FP16も同じ版へ公式変換して比較しました。変換前後のFP16出力は、評価用の計192画像すべてで完全一致しました。以下の時間は、このFP16とFP8を交互に測り直した値です。前のGPU表の100回測定とは分けています。

モデル ONNX容量 CPU上の入出力を使う時間 入出力をGPU上へ置く時間
FP16、opset 21 47.26 MiB 6.45 ms 6.08 ms
FP8、48層のW8A8 27.03 MiB 45.50 ms 45.02 ms

518×518・batch 1・TF32無効・CPU 8スレッドは共通です。FP16→FP8→FP8→FP16と、FP8→FP16→FP16→FP8を使って3ラウンド測り、各ブロックで20回ウォームアップして100回計測しました。各モデル・入出力方式につき計600回の中央値です。モデル読込や実行記録の取得は含めず、GPU上の入出力も完了を待って計測しています。

容量は減りましたが、CPU上の入出力を使う時間は約7.1倍になりました。3ラウンドともFP8の方が遅くなっています。ORTのGemmFloat8実装には、呼び出しごとの同期、cuBLASLt用資源の生成、一時領域の確保・解放が入っています。45.50 msはこれらの準備も含む時間です。TensorRTなどで層をまとめて最適化した場合のFP8速度は、別の比較になります。

正解深度は前と同じNYUv2 128画像とSintel 64画像です。有効画素の選び方、元解像度への復元、GTによる逆深度の倍率・切片整合、画像ごとの平均も同じです。両モデルに共通する全有効画素で評価しました。

データとGT 指標 ↓ FP16 FP8 FP8−FP16の差の95%信頼区間
NYUv2、実測 AbsRel 0.051757 0.052491 [+0.000165, +0.001315]
NYUv2、補完 AbsRel 0.088932 0.091257 [+0.001697, +0.002953]
Sintel、レンダリング 逆深度NRMSE 0.413102 0.415166 [−0.002063, +0.005614]

差は正なら誤差増加で、信頼区間は撮影場所・シーケンスをまとめて再抽出したものです。NYUv2の実測GTではAbsRelが約0.000734増え、区間も0を含みません。δ1はいずれも約97.6%でした。大まかな合格画素の割合がほぼ同じでも、誤差の平均には差が出ています。

Sintelの逆深度NRMSEは少し増えましたが、差の信頼区間は0を含みます。距離に戻したAbsRelは4.9445から5.3814、逆深度を下限処理した画素の割合は1.488%から1.727%へ増えました。こちらは先ほど説明した逆数と下限処理の影響もあるので、AbsRelだけで採用を決めず、逆深度側の指標と合わせて見ます。

同じRTX 5070 TiでFP16とFP8を比較した図。上段は推論時間とONNX容量、下段はFP16に対するGT誤差の差と撮影場所単位の95%信頼区間。NYUv2の実測・補完GTは誤差増、Sintelの逆深度NRMSE差は区間に0を含む。
上段は同じ実行環境で測り直したFP16とFP8。下段はGTで整合した後の誤差差分で、右ほど悪化です。NYUv2は95撮影場所、Sintelは23シーケンスをまとめて再抽出しています。Sintelの横線は0をまたいでいます。画像を開くと拡大できます。

このMax校正・48層W8A8・ORTの構成では、FP16から速度を落としてまでFP8へ変える理由は見つかりませんでした。ファイル容量を優先する場合も、実測GTに対する小さな誤差増を含めて選ぶことになります。

再現コードのREADMEに、データ準備から校正、モデル生成、速度・GT評価までの手順を追加しました。FP16用に用意した環境とデータを使い、追加の依存パッケージなしで実行できます。以下は校正データの準備後に使うコマンドです。

.venv_gpu/Scripts/python.exe scripts/build_fp8_models.py
.venv_gpu/Scripts/python.exe scripts/benchmark_fp8.py

量子化したのは48個の定数重みMatMul

Smallの公式重みをそのまま読み、公式のforwardを変更せずにONNXへ変換しました。seed42の乱数入力でPyTorchとFP32 ONNXを照合したところ、最大絶対差は約0.000004、RMSEは約0.0000013でした。

INT8とINT4で対象にしたのは、入力の片側が固定の重みになっている48個のMatMulです。ONNXに保存された重みなどの定数(initializer)のFP32要素のうち、約85.9%が対象でした。畳み込みや正規化、attention内の可変入力同士の行列積まで、すべて整数にしたわけではありません。

dynamic INT8は出力チャネルごとに重みを符号付き8bitへ写し、途中の値を推論時に符号なし8bitへ写します。静的量子化で使う事前の校正画像は要りません。以前の小型CNNを静的INT8へ量子化した記事とは、この方式もモデルも違います。

INT4は32要素ごとのブロックで対称に量子化し、最も近い値へ丸めました。accuracy_level=1は途中のFP32を保持する設定、4は内部でINT8近似を許可する設定です。ONNX Runtimeの仕様でも、この違いを確認できます。

なお、4bitの版がFP32ファイルのちょうど8分の1にならないのは、対象外の重みや補助のスケール値も保存するためです。保存ファイルが18 MiBでも、推論時のGPUメモリー使用量が18 MiBになるわけではありません。

手元で同じ比較を動かす

実験機はWindows 11、Core i7-14700F、RAM 32 GB、RTX 5070 Ti 16 GB、NVIDIAドライバー610.88です。CPU用はPython 3.12.14、ONNX Runtime 1.31.0、NumPy 2.5.2、PyTorch 2.14.1+cpu。GPU用もPython 3.12.14、ONNX Runtime GPU 1.31.0で、CUDA 13とcuDNN 9のライブラリを別環境へ入れました。パッケージの詳細な版はリポジトリの固定requirementsに残しています。

必要なのはPython 3.12、Git、インターネット接続です。CPUだけでも試せます。GPU比較はCUDA対応GPUとドライバーを追加で使います。環境やデータ、生成物を含む空き容量は20 GB程度を見込みます。通常の実行に管理者権限や再起動は要りませんが、Pythonやドライバーを新たに導入する場合は別です。

コードは記事用リポジトリの実験フォルダーにあります。PowerShellで次のように取得します。

git clone --branch depth-anything-v2-quantization --single-branch https://github.com/matrizea/yuyuyuroom-ai-experiments.git ai-experiments
cd ai-experiments/experiments/depth-anything-v2-quantization

そのフォルダーのREADMEに従って、CPU用の仮想環境と公式モデル実装を用意します。公式実装は、READMEで指定したcommitの版を使います。

.venv/Scripts/python.exe scripts/build_models.py
.venv/Scripts/python.exe scripts/compare_image.py --image "C:/photos/example.jpg" --output-dir results/custom

自分の画像を1枚比べるなら、2行目のパスを替えます。comparison.jpgと数値のJSON、表示加工前の深度配列が出ます。この画像には正解データがないので、確認できるのはFP32との違いです。

No module named depth_anything_v2が出た場合は、READMEの公式リポジトリ取得が抜けていないか確認します。CUDAが使えない場合は、CPU用Pythonを呼んでいないか、GPU用のProvider一覧にCUDAが現れるかを先に見ます。環境全体を入れ直す前に、まず今使っているPythonを確認します。

このPCでは、古いシステム側のVCランタイムが使われると、確認用のimport onnxruntimeだけでも異常終了しました。配布手順ではvenv内のMSVC DLLを先に読むruntime_initを使います。下の確認コマンドにも含めているので、この部分を抜かずに実行します。

再現確認で深いフォルダーへvenvを作ったときには、パッケージ導入でWinError 3も出ました。こちらはWindowsのパス長制限です。短い作業場所へ環境を作り直すと導入できました。エラーの種類が違うので、DLLの先読みだけでは直りません。

.venv_gpu/Scripts/python.exe -c "import sys; sys.path.insert(0,'scripts'); import runtime_init; import onnxruntime as ort; print(sys.executable); ort.preload_dlls(directory=''); print(ort.get_available_providers())"