TensorRT 10 × Blackwell 移行ガイド【中編】ビルドが通っても正しいとは限らない — 沈黙劣化 5 連発
こんにちは!
前回の記事「TensorRT 10 × Blackwell 移行ガイド【前編】RTX 50 で推論資産が動かない — 基本と最初の壁」では、Blackwell 世代への移行で既存の推論資産が動かなくなる理由と、TensorRT 10 化を最小構成で通す手順を扱いました。前編で出てきた問題には、実はひとつ共通点があります。
すべて、エラーで止まってくれたということです。
本当に怖いのはその先です。TensorRT への移行パイプラインには、
- ビルドが通る
- 実行も通る
- 速度もちゃんと出る
- 出力の形も、値も、一見それらしい
- なのに、中身が間違っている
という失敗の仕方が存在します。
本記事では、TensorRT 本体の挙動だけでなく、export 時のミス・精度設定・エンジンの配布ミスまで含めて、エラーで停止せず誤った出力へ至る事象を便宜上 「沈黙劣化」 と呼びます。
テストが通り、ベンチマークが良い数字を出し、「◯倍速くなりました」と報告した後になって発覚するので、非常に厄介です。

本記事では、実際に踏んだ 5 つの沈黙劣化を、症状 → なぜ起きるか → どう見つけるか → 回避策の順に共有します。
最後に、これらを体系的に検出するための検証設計をまとめます。各事例をどこまで実証できているかを、先に正直に示しておきます。
| 事例 | 記事用ダミーで再現 | 実務で経験 | 本記事で確定した範囲 |
|---|---|---|---|
| 入出力 dtype の取り違え | あり | あり | 未定義動作。当環境では有限値の誤出力を観測 |
| GridSample のすり替わり | あり | あり | 当環境の TensorRT 10.16 で nearest 相当になること、opset 16 では正しく bilinear になることを実測 |
| 機種またぎエンジン | 小モデルでは出力一致 | あり | 警告 2 本と小モデルでの出力一致を確認。破損する条件は未特定 |
| FP32 設定と TF32 許可 | あり | あり | TF32 無効化で当モデルの誤差が縮小。速度差の原因は未切り分け |
| export の引数取り違え | なし(実務事例のみ) | あり | 実務コードで引数順の誤りを特定・修正 |
第1部: 入出力の dtype を取り違える
症状
FP16 tactic を許可してモデルをエンジン化し、PyTorch の half テンソルをそのまま渡して実行しました
(前編の TRTRunner に入っている「dtype 合わせの 2 行」を入れる前の、素朴な実装です)。
エラーは出ません。速度も出ます。ただ、出力が PyTorch とまったく違いました。
どれくらい違ったかというと、出力の値域が 0.5 弱のモデルで、最大絶対誤差が約 13.8 です。fp16 の精度の問題という水準ではありません。完全な別物です。
なぜ起きるか
config.set_flag(trt.BuilderFlag.FP16) は「FP16 の実装候補を許可する」指定で、内部演算で FP16 tactic が選択可能になります(層によっては fp32 が選ばれます)。しかし、ネットワークの入出力テンソルの型は ONNX の定義のままです。ONNX を fp32 で export していれば、エンジンの入出力も fp32 のままなのです。
そこに PyTorch の half テンソルのアドレス(data_ptr())を渡すとどうなるか。TensorRT はその領域を 「fp32 要素が並ぶ入力バッファ」として扱います。fp16 は 1 要素 2 バイト・fp32 は 4 バイトなので、値の解釈が変わるだけでなく、TensorRT が PyTorch 側の確保範囲を超えて読み出す可能性もあります。挙動は未定義で、出力の破損だけでなく、環境によっては CUDA エラーやクラッシュも起こり得ます。
そして最悪なのは、今回の実測では、この状態でも `execute_async_v3` が `True`(成功)を返し、NaN も出ず、例外も飛ばなかったことです。
| 渡し方 | enqueue の戻り値 | 最大絶対誤差 |
|---|---|---|
| エンジンの要求 dtype に合わせて渡す | True | 約 0.001(今回の設定では大きな数値破綻なし) |
| half テンソルのアドレスをそのまま渡す | True | 約 13.8(完全に別物。今回の環境で観測された一例で、再現可能な誤差量ではありません) |
回避策
エンジンが要求する dtype を問い合わせて、明示的に合わせてから渡します。 前編の TRTRunner に入れていた 2 行が、これに当たります。
want = _TRT2TORCH[self.engine.get_tensor_dtype(name)] # エンジンが要求する型を問い合わせる
t = t.to(device=self.device, dtype=want).contiguous() # 合わせてから渡す
if not self.ctx.set_tensor_address(name, t.data_ptr()):
raise RuntimeError(f"set_tensor_address failed: {name}")
「fp16 でビルドしたのだから入力も fp16 だろう」は成り立ちません。必ずエンジンに聞いてください。
第2部: mode='linear' が黙って nearest で実行されていた
5 つの中で最も見つけにくく、最も驚いた罠です。
症状
前編で扱った 5D の grid_sample は、TensorRT がビルドを拒否してくれます(=親切)。では 4D の `grid_sample` はどうかというと、ビルドも実行も問題なく成功します。
ところが出力を PyTorch と比べると、一様にズレている。fp16 でも fp32 でも同じだけズレる。つまり精度の問題ではなく、構造的に何かが違う。それでいて出力画像はパッと見では「ちゃんとそれらしい」のです。
まず、何が起きているかを突き止める
再現用の最小構成はこれだけです(build_engine / TRTRunner は前編のものをそのまま使います)。
class PlanarWarp(nn.Module):
"""4D grid_sample を含む最小モジュール"""
def forward(self, im, grid):
return F.grid_sample(im, grid, align_corners=False)
torch.manual_seed(0)
im = torch.randn(1, 8, 128, 128, device="cuda")
grid = torch.rand(1, 128, 128, 2, device="cuda") * 2 - 1
model = PlanarWarp().eval().cuda()
torch.onnx.export(model, (im, grid), "planar_warp.onnx",
input_names=["im", "grid"], output_names=["y"],
opset_version=20, dynamo=True)
build_engine("planar_warp.onnx", "planar_warp.plan", fp16=False)
runner = TRTRunner("planar_warp.plan")
trt_out = runner.run({"im": im, "grid": grid})["y"]
原因を突き止めるために、TensorRT の出力を、PyTorch の `F.grid_sample` の全解釈と総当たりで突き合わせました。mode(bilinear / nearest)× align_corners(True / False)× padding_mode(zeros / border / reflection)の全 12 通りです。
for mode, align, pad in itertools.product(
["bilinear", "nearest"], [False, True], ["zeros", "border", "reflection"]):
ref = F.grid_sample(im, grid, mode=mode, align_corners=align, padding_mode=pad)
print(mode, align, pad,
(trt_out - ref).abs().max().item(), # 最大絶対誤差
torch.equal(trt_out, ref)) # ビット単位の一致判定
結果がこちらです。

TensorRT の出力は、`mode='nearest', align_corners=False` の結果と `torch.equal` が True、つまりビット単位で完全一致しました。 一方、本来あるべき bilinear との差は 3.1 以上あります。バイリニア補間が最近傍補間にすり替わると、出力は「なんとなくジャギーになる」程度の変化しか見せません。ワーピング処理の中に埋もれていると、目視ではまず気づけません。
なぜ起きるか — opset 16 と 20 で切り分ける
ONNX ファイルのノード属性を確認すると、こう書かれていました。
ONNX GridSample attributes: {'align_corners': 0, 'mode': 'linear', 'padding_mode': 'zeros'}
mode は opset 20 の仕様どおり正しく linear と書かれています。ここで重要な事実があります。ONNX の GridSample は、opset 16 では補間モード名が `bilinear` で、opset 20 で `linear` へ改名されました。そして TensorRT の公式ドキュメント上、GridSample の linear 補間はサポート対象です。つまり「TensorRT が linear をサポートしない」わけではない。怪しいのは opset 20 の新しい属性名を、取り込み(parser)がどう解釈しているかです。
そこで、同じモデル・同じ入力を opset 16 と 20 の両方で export して比較しました。
| export | ONNX の mode 属性 | TensorRT 出力が一致した解釈 | 判定 |
|---|---|---|---|
| opset 16 | bilinear | bilinear(max_abs 2.4e-07) | 正しく線形補間される |
| opset 20 | linear | nearest(torch.equal = True) | nearest にすり替わる |
同じ TensorRT・同じモデルで、opset 16 なら正しく、opset 20 だと nearest になる。この実測から、当環境の TensorRT 10.16 の ONNX 取り込み経路が、opset 20 で導入された mode='linear' を正しく線形補間へ対応づけられず、nearest へフォールバックしている可能性が高いと強く推定できます(ONNX 仕様でも TensorRT のドキュメント上でも linear はサポート対象・既定側の値なので、「nearest が既定だから」ではありません。parse 後のレイヤ属性や parser 実装までは確認していないため、発生箇所の断定はしていません。将来のバージョンで修正される可能性もあります)。
回避策 — 簡単な順に 3 つ
回避策 1: opset 16 で export する(最も簡単・実測済み)
上の表のとおり、opset 16 で export すれば当環境では正しく bilinear になります。5D が必要(opset 20 が必須)でない限り、これで十分です。
回避策 2: `grid_sample` を基本演算に分解する(TensorRT 実行まで実測済み)
opset 20 を使う事情がある場合や、演算そのものを TensorRT 任せにしたくない場合は、その演算を、TensorRT で数値実績のある基本演算だけで数学的に等価に組み直す手があります。バイリニア補間は要するに「4 近傍を取ってきて重み付き平均する」だけなので、Floor / Clamp / Gather / 四則演算に分解できます。
回避策 3: カスタムプラグイン(後編で扱います)
import torch
import torch.nn.functional as F
def bilinear_grid_sample(im, grid, align_corners=False):
"""F.grid_sample(im, grid, mode='bilinear', padding_mode='zeros') の forward 推論を再現する実装。
export 時だけこちらへ差し替えて使う。有限値の grid を前提とし、
NaN/Inf・backward・border/reflection・5D 入力は対象外。
im: (N, C, H, W)
grid: (N, Hg, Wg, 2) 値域 [-1, 1](x, y の順)
"""
n, c, h, w = im.shape
gx, gy = grid[..., 0], grid[..., 1]
# 正規化座標 [-1, 1] → ピクセル座標
if align_corners:
x = (gx + 1) / 2 * (w - 1)
y = (gy + 1) / 2 * (h - 1)
else:
x = ((gx + 1) * w - 1) / 2
y = ((gy + 1) * h - 1) / 2
x0, y0 = torch.floor(x), torch.floor(y)
x1, y1 = x0 + 1, y0 + 1
# 4 近傍の重み
wa = (x1 - x) * (y1 - y)
wb = (x1 - x) * (y - y0)
wc = (x - x0) * (y1 - y)
wd = (x - x0) * (y - y0)
# padding_mode='zeros' の再現:
# 1px のゼロパディングを張り、インデックスを [-1, size] に clamp してから +1 する。
# どれだけ範囲外へ外れても、値 0 のパディング画素を参照する形に落ちる。
im_p = F.pad(im, (1, 1, 1, 1), mode="constant", value=0.0)
x0i = (x0.clamp(-1.0, float(w)) + 1).long()
x1i = (x1.clamp(-1.0, float(w)) + 1).long()
y0i = (y0.clamp(-1.0, float(h)) + 1).long()
y1i = (y1.clamp(-1.0, float(h)) + 1).long()
wp = w + 2
im_flat = im_p.reshape(n, c, -1) # (N, C, (H+2)*(W+2)) に平坦化
def gather(yi, xi):
# ONNX / TensorRT との対応を確実にするため GatherElements(dim=2) の形に寄せる
idx = (yi * wp + xi).reshape(n, 1, -1).expand(-1, c, -1)
return torch.gather(im_flat, 2, idx)
va, vb, vc, vd = gather(y0i, x0i), gather(y1i, x0i), gather(y0i, x1i), gather(y1i, x1i)
hg, wg = grid.shape[1], grid.shape[2]
out = (va * wa.reshape(n, 1, -1) + vb * wb.reshape(n, 1, -1)
+ vc * wc.reshape(n, 1, -1) + vd * wd.reshape(n, 1, -1))
return out.reshape(n, c, hg, wg)
この分解版は、PyTorch 上のセルフテストだけでなく、実際に ONNX export → TensorRT build → 実行まで通して検証しました。三者比較の実測はこうです。
| 比較 | 最大絶対誤差 |
|---|---|
PyTorch F.grid_sample vs PyTorch 分解版 | 2.4e-07 |
PyTorch F.grid_sample vs TensorRT 化した分解版 | 2.4e-07 |
| PyTorch 分解版 vs TensorRT 化した分解版 | 4.8e-07 |
TensorRT に載せても正しく bilinear のまま動く、というところまで確認できています。範囲外のグリッドを含むセルフテストも一緒に書いておくとよいでしょう。
torch.manual_seed(0)
dev = "cuda:0"
src = torch.randn(4, 3, 256, 256, device=dev)
cases = {
"uniform_pm1": torch.rand(4, 256, 256, 2, device=dev) * 2 - 1,
"outside_pm1.5": torch.rand(4, 256, 256, 2, device=dev) * 3 - 1.5, # 範囲外を含む
}
ys, xs = torch.meshgrid(torch.linspace(-1, 1, 256, device=dev),
torch.linspace(-1, 1, 256, device=dev), indexing="ij")
ident = torch.stack([xs, ys], dim=-1).expand(4, -1, -1, -1)
cases["identity_plus_flow"] = ident + torch.randn(4, 256, 256, 2, device=dev) * 0.05
for name, grid in cases.items():
ref = F.grid_sample(src, grid) # bilinear / zeros / align_corners=False
got = bilinear_grid_sample(src, grid)
ma = float((ref - got).abs().max())
print(f"[selftest] {name}: max_abs={ma:.3e} -> {'PASS' if ma < 1e-5 else 'FAIL'}")
ポイントは、この差し替えを export 用のラッパの中だけで行うことです。 本番のモデルコードには一切手を入れずに済みます。
第3部: 同じ世代でも、GPU の機種が違うとエンジンは壊れ得る
症状
ある GPU でビルドしたエンジンを、同じ Blackwell 世代(sm_120)の別の機種へ持っていって実行しました。動きはします。速度も出ます。
しかし出力が壊れていました。
TensorRT のログにはこう出ていました(後半の 1 文をそのまま引用します)。
[TRT] [WARNING] Using an engine plan file across different models of devices is not
supported and is likely to affect performance or even cause errors or deadlock.
前段にはもう 1 本、「このデバイスが持つ SM(Streaming Multiprocessor)の数より、このエンジンが要求する SM 数のほうが多い。デッドロックの可能性が高い」という趣旨の警告も出ます。それでも `deserialize` は成功し、`execute_async_v3` も `True` を返し、処理は止まりません。
なぜ起きるか
前編で書いたとおり、TensorRT はビルド時に実機でカーネルを実測し、最速の実装を選びます。既定設定のエンジンは、Compute Capability だけでなく、ビルドした GPU の複数のハードウェア特性(SM 数・共有メモリ・L2 キャッシュなど)を前提として実行計画を組んでいます。別機種ではこれらが異なるため、性能低下だけでなく実行上の問題につながる可能性があり、TensorRT 自身も不一致を検出して警告を出します。
「Compute Capability が同じなら互換」は誤りです。 同じ sm_120 同士でも、機種が違えば別物として扱う必要があります。
ここが本当に厄介なところ
今回、この状況を再現する検証もしてみました。SM 数の多い機種でビルドしたエンジンを、少ない機種でロードして実行したのです。結果はこうでした。
警告は 2 本とも出ました。しかし出力は、ビルドした機種での結果と一致しました(最大絶対誤差は実測値そのものが 0.0。ただし `torch.equal` までは確認していません)。
つまり、小さくて単純なモデルでは、警告を無視しても「動いてしまう」のです。これが最悪です。開発中に一度「動いた」経験をしてしまうと、この警告はただのノイズに見えます。そして本番の複雑なモデルに載せ替えたとき、CUDA エラーを吐きながら壊れた出力を流し続ける、という形で牙を剥きます。私たちが実際に経験したのは後者でした。
なお公式ドキュメントは、同一アーキテクチャの別 GPU でも機能上は動き、小さな性能低下に留まる場合があるとも説明しています。それでも私たちは、本番運用では この警告を fail-fast の対象(=出たら止める)として扱う方針にしています。壊れる条件が特定できていない以上、「今は動いているから大丈夫」に賭けるべきではない、という判断です。
回避策
- エンジンは Compute Capability 単位ではなく「GPU 機種」単位で管理・ビルドする — 保管ディレクトリを
sm_120/で切るだけでは足りません。機種名まで含めて分けます - ビルド時に「GPU 製品名・Compute Capability・SM 数・TensorRT / CUDA バージョン」をメタデータ(sidecar JSON など)として保存し、起動時に実行環境と照合して、不一致なら deserialize する前に拒否する — ここが実効的な防波堤です。注意すべきは、`execute_async_v3` の戻り値チェックはこの罠の検出には使えないことです。今回の検証でも、機種不一致の状態で
Trueが返りました - TensorRT の警告を本番ではエラー扱いにする — ロガーの WARNING を握りつぶさず、上の照合と併せて fail-fast にします
- 複数機種への配布がどうしても必要なら、hardware compatibility mode(`SAME_COMPUTE_CAPABILITY` など)でビルドする — 同じ Compute Capability の GPU 間で使うための正式な仕組みです。ただし性能や機能に制約が生じるため、速度と数値を別途検証してから使ってください
第4部: fp32 でビルドしても、TF32 が候補に入る
症状
FP16 tactic を許可した設定では精度が足りなかったモジュールを、FP16 tactic を許可しない FP32 設定でビルドし直しました。それでも PyTorch との一致度が期待より伸びません。しかも、ビルドし直すたびに一致度が微妙に変動する。合格ラインを超えたり超えなかったりします。
なぜ起きるか
TensorRT は fp32 のテンソルに対する畳み込みや行列積について、既定で TF32 tactic の使用を「許可」しています。 TF32 は仮数部を 10 bit に削った高速な形式で、Ampere 以降の Tensor Core で使われます。すべての fp32 演算が TF32 になるわけではなく通常の fp32 実装が選ばれる場合もありますが、「fp32 でビルドした=すべて fp32 で走っている」という思い込みは成り立ちません。
ビルドごとに結果が揺れた原因として考えられるのが、ビルド時の計測ノイズです。TensorRT のビルドは実機計測に基づくため、性能の近い実装候補からビルドごとに異なる tactic が選ばれる場合があります(公式ドキュメントにもこの説明があります)。ただし今回、実際に選択された tactic の比較までは行っていません。
回避策と、意外な副産物
忠実度を厳密に検証するエンジンでは、TF32 を明示的に無効化します。
config = builder.create_builder_config()
config.clear_flag(trt.BuilderFlag.TF32) # ★ TF32 tactic を候補から外す
実測結果がこちらです。

最大絶対誤差は 2.55e-04 から 6.26e-07 へ、3 桁改善しました。
そして面白いことに、今回の 1 組のビルドでは、TF32 を無効にした側のほうが速い結果になりました(0.874ms → 0.772ms)。別の tactic が選ばれた可能性はありますが、原因の切り分け(選択 tactic の比較)まではしていません。ビルド時の計測ノイズの影響も受け得る差なので、この約 12% を一般的な傾向と読まないでください。それでも「TF32 を切ると必ず遅くなる」という思い込みで試さないのはもったいないので、速度もその構成で実測してください。TF32 を無効にしても、演算順序や tactic の違いによる PyTorch との数値差が完全になくなるわけではない点も添えておきます。
第5部: ONNX export で引数が入れ替わっていた(最も泣けてきた事故)
最後は、TensorRT の問題ではなく 私たち自身のミスです。しかし、これがシリーズ中で最も発見に時間がかかりました。同じ構造の事故は誰にでも起こりうるので共有します。なお本事例は実務モデルで発生したもので、機密上モデルは公開できず、本記事用のダミーモデルでの再現実験は行っていません。
症状
あるモジュールを TensorRT 化したところ、入力によって出力の壊れ方が変わるという奇妙な症状が出ました。ある条件では完璧に一致するのに、別の条件では明確に劣化します。しかも劣化の量は入力の「動きの大きさ」に比例して拡大していました。
fp16 でも fp32 でも、TF32 を切っても、劣化量は変わりません。この時点で、単純な精度差より構造的な問題を強く疑いました。
なぜ起きたか
torch.onnx.export に渡した引数の順序が、モデルの forward の引数順と食い違っていました。
# forward の定義: forward(self, feature, kp_driving, kp_source)
# export で渡したもの: (feature, kp_source, kp_driving) ← 2 番目と 3 番目が逆
torch.onnx.export(model, (feature, kp_source, kp_driving), "m.onnx", ...)
位置引数なので、Python は何も文句を言いません。 export は成功し、ONNX ファイルも生成され、TensorRT のビルドも通り、実行もできます。ただ、モデルの中で 2 つの入力の役割が入れ替わったまま固定された ONNX ができあがります。
内部の計算が「基準 − 入力A + 入力B」のような差分の形になっていたため、誤差はちょうど 2 ×(2 つの入力の差) になります。だから「差が小さい入力では一致し、差が大きい入力ほど盛大に壊れる」という症状になりました。
なぜ発見が遅れたか — 「自己再演」検証の落とし穴
いちばんの問題はここです。当時の検証は、同じデータを両方の入力に与える形で行っていました。この条件では 2 つの入力が等しいので、入れ替わっていても結果は完全に一致します。テストは堂々と合格していました。
これを私たちは 「自己再演パリティ」の罠と呼んでいます。入力と基準が一致してしまう条件だけで等価性を検証すると、引数の取り違えのような構造的なバグが完全に無害化されます。
見つけ方 — 犯人が TensorRT か ONNX かを切り分ける
このとき有効だったのが、onnxruntime を使った切り分けです。

- まずモデルを段階的に分割し、どこで数値がズレ始めるかを二分探索する(中間テンソルの比較)
- ズレる部分グラフが特定できたら、同じ ONNX を onnxruntime の CPU 実行で走らせる
- onnxruntime でも同じ方向にズレるなら、まず ONNX グラフ・export 時の入力束縛・演算の仕様差を疑う。PyTorch と onnxruntime が一致して TensorRT だけズレるなら、TensorRT の parser・エンジン・実行側の設定を優先して疑う
これは犯人を論理的に確定する手続きではなく、どこから先に調べるかを決める切り分けです(onnxruntime 側にも実装差はあり得ますし、TensorRT 側の入力名や dtype の渡し方の問題ということもあります)。それでも調査の初動を大きく変えてくれます。今回のケースでは onnxruntime でも同じだけ食い違ったため export 側を先に調べ、位置引数の取り違えをコード上で特定しました。
なお、層ごとの比較には polygraphy の全層比較(mark all)が便利ですが、大きなモデルではメモリを使い切って落ちます。実務では、疑わしい部分だけを切り出す二分探索のほうが確実でした。
回避策 — kwargs による keyword 束縛
export への引数の受け渡し自体を keyword にします。 torch.onnx.export には位置引数用の args とは別に、keyword 入力として渡す正式な `kwargs` 引数があります。これを使えば、タプルの順番という概念そのものが消えます。
# 課題: args はタプルであり、位置順で束縛される。forward の引数順を
# 取り違えても export は成功してしまい、入力の役割が入れ替わった
# ままの ONNX が生成される(input_names は名前を付けるだけで、
# 意味上の対応を保証しない)
# 対策: kwargs 引数で「名前 → テンソル」を明示的に対応づける
torch.onnx.export(
model,
args=(),
kwargs={
"feature": feature,
"kp_source": kp_source,
"kp_driving": kp_driving,
},
f="m.onnx",
input_names=["feature", "kp_source", "kp_driving"],
output_names=["y"], opset_version=20, dynamo=True,
)
修正後、劣化は完全に消えました。
沈黙劣化を検出する検証設計
5 つの罠を並べると、共通する対策が見えてきます。
1. 速度より先に数値を測る
順序を逆にすると、「速くなった」という良い知らせが先に来てしまい、数値の検証が形式的になります。数値が合っていない高速化には価値がありません。
2. 乱数入力の一致を信用しない — 実データで検証する
これは実測でも裏づけが取れました。LayerNorm と GELU を重ねたブロックを FP16 tactic 許可でエンジン化し、入力のレンジだけを変えて誤差を測ったところ、次のようになりました(比較の基準は PyTorch fp32。表の「FP16 許可 / 不許可」はいずれも TF32 は既定=許可のままです)。
| 入力のスケール | FP16 許可の最大絶対誤差(nRMSE) | FP16 不許可の最大絶対誤差(nRMSE) |
|---|---|---|
| 1 倍(標準正規乱数) | 0.0074(8.9e-04) | 0.0010(1.9e-04) |
| 300 倍 | 1.77(5.4e-04) | 0.0010(6.9e-07) |
| 3000 倍 | 5.41(2.5e-04) | 0.0029(1.0e-07) |
入力スケールを広げると、FP16 設定の最大絶対誤差は大きく増えました(ただし増加は入力倍率への単純比例ではありません。LayerNorm がスケールを正規化するためです)。一方 nRMSE は 8.9e-04 から 2.5e-04 へむしろ低下しており、少なくとも入力スケールとともに悪化してはいません。つまり絶対誤差と正規化誤差では、同じ結果でも評価の見え方がまったく違います。ここから言えるのは、「絶対誤差いくつ以下なら OK」という合格バーを標準正規乱数だけで決めると、実データのレンジでは簡単に破られるということです。
さらに私たちの実モデルでは、これが単なる誤差の増大では済まず、実データを入れた瞬間に全出力が NaN になるという形で現れました(LayerNorm 直前で中間値が fp16 の表現範囲を超えたためです)。対処は、そのモジュールでは FP16 tactic を許可せず、FP32 設定でビルドすることでした。
3. 「自己再演」で等価性を検証しない
第5部のように、入力と基準が一致する条件だけのテストは、構造的なバグを完全に隠します。検証データには、実運用で起こりうる「差が大きいケース」を必ず含めてください。
4. 犯人の切り分けは「PyTorch / onnxruntime / TensorRT」の三点測量で
- PyTorch と onnxruntime が一致し、TensorRT だけズレる → TensorRT のセマンティクス・カーネルを疑う(第2部)
- onnxruntime でもズレる → ONNX、つまり export を疑う(第5部)
この切り分けができると、調査時間が桁で変わります。
5. 疑わしい演算は「1 個だけ」切り出してテストする
モデル全体で「なんとなくズレている」を追うのは非効率です。第2部は grid_sample 1 個を切り出して総当たり照合したことで、一発で確定しました。
6. 精度を切り替えて症状が変わるかを見る
fp16 / fp32 / TF32 を切り替えても劣化量がほとんど変わらないなら、丸め誤差よりも入力束縛や演算セマンティクスなど、構造的な問題を優先して疑います。証明ではありませんが、調査の優先順位を決める判定として非常に有効でした。
7. 失敗を握りつぶさない仕組みを入れる
execute_async_v3 の戻り値チェック、エンジンとデバイスの整合検査、部品が欠けていたら起動を拒否する設計。「一部が壊れたまま正常に見える」状態を作らないことが、沈黙劣化に対する最後の防波堤です。
まだ確かめていないこと
- `mode='linear'` が nearest になる件の根本箇所 — opset 16/20 の比較から parser 段階の解釈が原因と強く推定していますが、parser のソースコードや parse 直後のレイヤ属性までは確認していません。また検証は TensorRT 10.16.1.11 の 1 バージョンのみで、将来のバージョンで修正される可能性があります
- 機種またぎで壊れる条件 — 今回のダミーモデルでは警告が出ても出力は一致しました。「どの程度の規模・構造から壊れ始めるか」は特定できていません
- int8 量子化での同種の問題 — 本記事は fp16 / fp32 / TF32 のみを扱っています
まとめ
一言でまとめると、「TensorRT への移行パイプラインでは、『ビルドが通った』も『実行できた』も『速くなった』も、出力が正しいことを一切保証しない」ということです。
だからこそ、速度を測る前に数値を測り、乱数ではなく実データで測り、ズレたら三点測量で犯人を切り分け、失敗を握りつぶさない仕組みを入れておく。これが TensorRT を本番に載せるための最低条件だと考えています。
次回(後編)は、前編で保留にした最大の宿題、TensorRT がネイティブに対応していない 5D grid_sample を、カスタムプラグインで通す話をお届けします。新しい CUDA / TensorRT 世代でこれをビルドするには、いくつも越えるべきものがあります。手元の nvcc が Blackwell 向けのコードを生成できないときにどうするか、pip で入れた TensorRT には開発用ヘッダが入っていないという問題、そしてプラグインの fp16 カーネルが数値破綻するという、またしても沈黙劣化に近い話です。
それでは、また次回、お会いしましょう!
参考資料(一次情報・主要ソース)
- NVIDIA TensorRT ドキュメント(Developer Guide / Release Notes)
- ONNX Operators: GridSample(mode / align_corners / padding_mode の仕様)
- PyTorch: torch.nn.functional.grid_sample
- NVIDIA/TensorRT issue #3890: 5D GridSample のネイティブ対応要望
- NVIDIA: TensorFloat-32(TF32)の解説
- polygraphy(TensorRT のデバッグ・比較ツール)
- 当社関連記事: 2026年 NVIDIA GPU 一発検索ツール