TensorRT 10 × Blackwell 移行ガイド【前編】RTX 50 で推論資産が動かない — 基本と最初の壁
こんにちは!
新しい GPU を手に入れてワクワクしながら既存の推論環境を載せ替えたら、
昨日まで普通に動いていたものが軒並みエラーで止まった
そんな経験はないでしょうか。NVIDIA RTX 50 系、NVIDIA RTX PRO 系(Blackwell 世代)への移行では、これがかなりの高確率で起きます。
そして厄介なことに、エラーで止まってくれるのは、まだ親切なほうで、、TensorRT の世界には
「ビルドは通る、実行も通る、速度もちゃんと出る、けれど出力だけが静かに壊れている」
という、いちばん見つけにくい失敗の仕方が存在します。
本記事はその全体像を扱うシリーズの前編です。
対象環境
OS: Ubuntu 24.04 (WLS)
GPU: NVIDIA RTX PRO 4000 Blackwell・GeForce RTX 5060 Ti
(ともに Compute Capability 12.0 = sm_120)
Stack: PyTorch 2.11.0 + cu128 / TensorRT 10.16.1.11。
前編では次の 3 つをお届けします。
- そもそも TensorRT 化とは何なのか
なぜ PyTorch のまま動かすより速くなるのか。そして何を代償に払うのか - Blackwell(RTX 50系、RTX PRO 系)で既存資産が動かなくなる理由
確認すべき領域は 1 つではなく、大きく 3 つあります - 最小構成で TensorRT 10 化を通す手順
環境構築からエンジンビルド、数値照合、速度計測まで、本文のコードを順番に実行すれば再現できる形で
題材には、特定の製品に依存せず同じ検証手順を追えるよう、この記事の中に全コードを載せた小さなダミーモデルを使います。
特定の製品やモデルの話ではなく、TensorRT 化そのものの話として読んでいただけます。
本稿に掲載している数値・エラーログ・挙動は、実際に走らせた結果です。なおコードは TensorRT 10.x の API を対象としています。
TensorRT 11 では precision flag(`BuilderFlag.FP16` 等)を含む一部 API が変更・削除されているため、そのままでは動きません。
既存資産の移行過程を再現するため、意図的に 10.16 を使っています。
なお、お手元の GPU がどの世代(SM いくつ)なのかは、当社が公開している「2026年 NVIDIA GPU 一発検索ツール」で調べられます。本記事の話が自分の環境に当てはまるかどうかは、まずそこからご確認ください。
第1部: TensorRT 化とは何か ~ なぜ PyTorch より速いのか
一言でいうと「事前コンパイル」
PyTorch を普通に(torch.compile や CUDA Graphs を使わない eager 実行で)推論するとき、GPU の中で何が起きているかを思い出してみます。
演算子が呼ばれるたびにディスパッチが走り、演算ごとに GPU カーネルが起動されます。融合されていない演算のあいだでは、中間結果が GPU のメモリ(VRAM)へ書き出され、次の演算がまたそれを読み直します。
TensorRT 化とは、この実行の仕方を根本から変えることです。
モデルの計算グラフ全体を、あらかじめ「その GPU 専用の実行計画」へコンパイルしておき、推論時はホスト側からその実行計画を 1 回の呼び出しで投入するだけにします。
この事前コンパイル済みの成果物を、TensorRT ではエンジン(engine / plan)と呼びます。
なお本記事の比較対象はあくまで PyTorch の eager 実行です。PyTorch 自身にも torch.compile や CUDA Graphs といった高速化経路があり、それらを使った場合の差はまた別の話になります。
ソースコードをインタプリタで 1 行ずつ解釈しながら実行するのと、事前にネイティブバイナリへコンパイルしておいて実行するのと、その違いに近いイメージです。

速くなる 5 つの仕組み
「なぜ速いのか」を分解すると、だいたい次の 5 つに整理できます。
| 仕組み | PyTorch eager(torch.compile 未使用) | TensorRT エンジン |
|---|---|---|
| レイヤ融合 | 融合されていない Conv・BatchNorm・ReLU などが複数のカーネルとして実行され、そのあいだで中間テンソルの読み書きが発生する | 複数の層を 1 つのカーネルに融合し、中間結果は GPU の高速な内部メモリに留める。メモリの読み書きがボトルネックになっているモデルほど劇的に効く |
| カーネル自動チューニング | cuDNN などが演算単位で実装を選ぶ(設定によっては入力形状へのベンチマーク選択も行う) | ビルド時にネットワーク全体を見て、実機で複数の実装候補を実測し、全体が速くなる組み合わせを採用する(ビルドに時間がかかる理由であり、エンジンが GPU 機種固有になる理由でもあります) |
| 低精度の作り込み | autocast で大まかに fp16 化 | 層単位で許可する精度を制御して Tensor Core を使い切る。危ない層だけ fp32 に固定するといった制御ができる |
| メモリの静的計画 | 中間テンソルを演算ごとに生成し、caching allocator でメモリを管理する(グラフ全体を見た寿命計画には限界がある) | 形状が固定なら全テンソルの寿命が事前に分かる。領域を再利用して VRAM を削り、実行時の動的確保のオーバーヘッドを抑える |
| 実行時オーバーヘッドの削減 | Python インタプリタ・ディスパッチャを毎演算通過する(autograd も inference_mode を使わなければ有効のまま) | C++ ランタイムがグラフ全体を 1 回の投入(enqueue)で流す。小さい演算が大量にあるモデルほど差が開く |
【代償】 TensorRT 化で何を失うか
TensorRT の記事は「速くなりました!」で終わりがちですが、
実務では失うものを把握しておくほうが重要です。
- ビルドに時間がかかる —
実機でカーネルを実測して選ぶため、モデルによっては数分から数十分かかります - エンジンの移植性が低い —
既定設定でビルドしたエンジンは、作成時の TensorRT バージョンと GPU への依存が強く、「開発機で作ったエンジンを本番機にコピーする」は原則できません。version compatibility や hardware compatibility といった互換モードで対象範囲を広げることもできますが、性能や使える機能に制約が生じます - 未対応の演算があると、そもそもビルドが落ちる
本記事の第4部がまさにこれです - 数値が変わる。ときには静かに壊れる
精度が落ちるだけでなく、演算の意味そのものがすり替わることすらあります(中編の主題です) - 固定形状が最も扱いやすい —
可変形状も optimization profile で正式に扱えますが、対応範囲を広く取りすぎると形状ごとの最適な実装を選びにくくなったり、メモリが増えたりする場合があります - デバッグしづらい
PyTorch のようにモデルの途中にprintを挟んで中身を覗く、ということができません
「TensorRT 化すべきか」の判断基準
以上を踏まえると、判断はかなりはっきりします。
効きやすいケース
- CPU 実行のままの ONNX モデルがパイプラインに残っている
(最優先。効果が桁で違います) - 小さな演算が大量にあり、カーネル起動のオーバーヘッドがボトルネックになっているモデル
- 入力形状が固定で、同じモデルを大量に回し続ける推論サービス
効きにくい、あるいは慎重に判断すべきケース
- 計算そのものに時間の大半を使っているモジュール …主要演算の計算時間がすでに支配的で、、単純な ONNX → TensorRT 変換による 1 件あたりのレイテンシ改善は限定的でした
(バッチを増やしても 1 件あたりの時間はほぼ不変。ただしこれはこのモジュールでの結果で、量子化や専用カーネルまで含めれば話は変わりますし、スループットはまた別の指標です) - 形状の変動範囲が大きく、optimization profile の設計や複数エンジンの管理コストが効果に見合わない用途
- TensorRT 非対応の演算を含む
(→ 第4部のハイブリッド構成で部分適用する) - バッチ処理でよい用途
ここは強調しておきたい点です。カーネル起動のオーバーヘッドがボトルネックなら、TensorRT 化しなくても PyTorch 側でバッチを組むだけで数倍取れることがあります。
私たちのケースでは、あるモジュールをバッチで回したところ 1 フレームあたり 2 倍以上まで縮み、わざわざ TensorRT 化した構成より速いという結果になりました。「リアルタイムで 1 フレームずつ処理する」用途と「まとめて書き出す」用途では、最適解が変わります
つまり
TensorRT は万能の高速化ボタンではなく、
「どの段が・何に時間を食われているか」を実測してから当てる道具
です。ここを飛ばして全段 TensorRT 化に突っ込むと、ビルドの苦労に見合わない結果になります。
第2部: なぜ「昨日まで動いていた資産」が Blackwell で動かないのか
さて、ここからが Blackwell の話です。
RTX 50 系と、今回検証した RTX PRO Blackwell ワークステーション GPU は sm_120(Compute Capability 12.0) という新しい世代で、既存資産が動かなくなる原因はここに集約されます
(なお Blackwell ファミリー全体ではデータセンター向けに別の Compute Capability を持つ製品もあります。製品ごとの世代は冒頭で紹介した GPU 検索ツールで確認できます)。
私たちの検証環境では、次の 3 つの領域で問題が発生しました。
- TensorRT
sm_120 に初対応したのは TensorRT 10.8.0 です(リリースノートに "supports NVIDIA Blackwell GPUs, such as the GeForce 50-series" と明記されています)。
裏を返すと、
既定設定でビルドされた TensorRT 8 系の `.engine` / `.plan` は互換性チェックを通らず、sm_120 では原則として再利用できません
(TensorRT 8.6 以降で version compatibility と hardware compatibility を有効にして構築したエンジンは例外になり得ますが、互換設定が確認できない既存資産は再ビルド前提が安全です) - PyTorch
Blackwell ネイティブ対応の最初の安定版は 2.7.0(CUDA 12.8 wheel = cu128) です。それ以前の wheel を入れるとsm_120 is not compatible with the current PyTorch installation.のように弾かれます - onnxruntime: 私たちの検証環境(onnxruntime 1.27)では、CUDAExecutionProvider が初期化できず CPU 実行にフォールバックしました。
エラーで止まらないので気づきにくく、「なんだか遅い」だけが症状として残ります。
ただしこれは onnxruntime のバージョン・配布ビルド・CUDA の組み合わせに依存する話で、Blackwell で公式 wheel が一律に動かないという意味ではありません。お使いのバージョンで実際に確認してください

つまり Blackwell 移行とは、単に GPU を挿し替える話ではなく、
「PyTorch を cu128 世代へ」
「TensorRT を 10.x 世代へ」に加えて
「onnxruntime などの周辺ランタイムが実際にどのバックエンドで動いているかを見直す」
を同時に走らせる作業
です。
3 番目の onnxruntime については「CUDA で動かないなら CPU のままでいいか」と放置されがちですが、そこがパイプライン全体のボトルネックになっている可能性が高いので要注意です。
まずは providers を実際に出力して、CPU に落ちていないか確認してください。
import onnxruntime as ort
print(ort.__version__)
print(ort.get_available_providers()) # CUDAExecutionProvider が無ければ、この環境では CUDA EP を利用できない
sess = ort.InferenceSession("model.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
print(sess.get_providers()) # セッションに登録された provider と優先順位
1 点だけ注意です。sess.get_providers() で分かるのは「セッションにどの provider が登録されたか」までで、全ノードが GPU で実行された証明にはなりません
(一部のノードだけ CPU に割り当てられることがあります)。ノード単位の配置まで確認したい場合は、onnxruntime のプロファイリングや詳細ログを使ってください。
第3部: 最小構成で TensorRT 10 化を通す ~5 ステップ
ここからは実際に手を動かします。
いきなり本命のモデルを変換しないでください。
まずは数十行の最小モデルで「export → build → 実行 → 数値照合 → 速度計測」の 5 ステップを通し、環境そのものが健全であることを確認します。
この自己診断を挟んでおくと、後で本命モデルが失敗したときに「環境のせいか、モデルのせいか」を即座に切り分けられます。
環境構築 ~ ここに最初の罠があります
検証はすべて WSL2(Ubuntu 24.04)上で行いました。
WSL2 を選ぶのは、CUDA / TensorRT まわりの Linux 前提のツールチェーンがそのまま使えるためです。venv は uv で立て、システムの Python には触りません。
# 作業用 venv を uv で作成(システム Python 非侵襲)
uv venv ~/work/trt_venv --python 3.11
source ~/work/trt_venv/bin/activate
# PyTorch は cu128(CUDA 12.8)世代を明示(2026-07-23 時点では 2.11.0 が入ることを確認)
uv pip install torch --index-url https://download.pytorch.org/whl/cu128
# TensorRT は 10.x の cu12 ビルドにピンする(★理由は下記)
uv pip install "tensorrt-cu12==10.16.1.11"
# onnxscript は PyTorch の新しい exporter(dynamo=True)が必要とする
uv pip install onnx onnxscript numpy
罠その1: pip install tensorrt が CUDA 13 系を連れてくる
素の pip install tensorrt は、検証時点で TensorRT 11.1.0.106(CUDA 13 系ビルド) に解決されました。
一方、本記事の PyTorch 環境は cu128(CUDA 12.8)です。異なる CUDA メジャー系列のライブラリが同じ環境に存在すること自体が直ちに不正というわけではありませんが、依存関係や障害の切り分けが複雑になります。
そして何より、本記事のコードは TensorRT 11 の API では動きません。
そこで検証条件を揃えるため、torch の CUDA 系列に TensorRT の CUDA 系列を合わせて、TensorRT 10.16 の cu12 ビルドを明示的にピンします。
「最新版を入れれば良い」ではありません。
環境が整ったら、GPU が本当に sm_120 として認識されているかを先に確認しておきます。
import torch
print(torch.__version__, torch.version.cuda) # 例: 2.11.0+cu128 / 12.8
print(torch.cuda.is_available()) # True であること
print(torch.cuda.get_device_capability()) # RTX 50 系・今回の検証 GPU なら (12, 0)
ステップ 1: 題材となるダミーモデルを用意する
小さな畳み込みブロックを用意します。特定の製品とは無関係の、ただの Conv + BatchNorm + ReLU の積み上げです。
# tiny_model.py
import torch
import torch.nn as nn
class TinyBlock(nn.Module):
"""TensorRT 化の動作確認用の最小モデル(Conv+BN+ReLU ×3)。
レイヤ融合の効果が分かりやすいよう、あえて小さな演算を並べている。"""
def __init__(self, ch=64):
super().__init__()
layers = []
for _ in range(3):
layers += [nn.Conv2d(ch, ch, 3, padding=1), nn.BatchNorm2d(ch), nn.ReLU(inplace=True)]
self.body = nn.Sequential(*layers)
def forward(self, x):
return self.body(x)
INPUT_SHAPE = (1, 64, 256, 256) # 固定形状で扱う
ステップ 2: ONNX へ export する
後のステップで「export したモデルそのもの」と数値を突き合わせるので、`model` と `x` はここで作ったものをこの後もずっと使い回します。
シードも固定しておくと、結果を再現できます。
torch.manual_seed(0)
model = TinyBlock().eval().cuda().float()
x = torch.randn(*INPUT_SHAPE, device="cuda")
torch.onnx.export(
model, (x,), "tiny.onnx",
input_names=["x"], output_names=["y"],
opset_version=20,
dynamo=True, # PyTorch 2.x の新しい exporter(onnxscript が必要)
)
入力が複数あるモデルでは、ここに極めて見つけにくい事故の種があります。
torch.onnx.export に渡す args はタプルで、位置引数として順に束縛されます。
モデルの forward の引数順を勘違いしていると、ONNX の中で引数が入れ替わったまま、エラーも警告も出さずに export が成功します。
この事故と恒久的な回避策は中編で詳しく扱います。
ステップ 3: TensorRT エンジンをビルドする
import tensorrt as trt
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
def build_engine(onnx_path, engine_path, fp16=True, workspace_gb=4, tf32=True):
builder = trt.Builder(TRT_LOGGER)
# TensorRT 10 では explicit batch が既定。下記フラグは後方互換で残っているもの
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, TRT_LOGGER)
# parse_from_file を使うと、大きなモデルの外部データ(.onnx.data)も
# ONNX と同じディレクトリから自動で解決してくれる
if not parser.parse_from_file(onnx_path):
errs = [str(parser.get_error(i)) for i in range(parser.num_errors)]
raise RuntimeError("ONNX parse failed:\n" + "\n".join(errs))
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, workspace_gb * (1 << 30))
if fp16:
# FP16 の実装候補(tactic)を選択肢に加える。全層が FP16 になる保証ではなく、
# 層によっては FP32 が選ばれる。実際の実行精度はレイヤ情報のダンプで確認できる
config.set_flag(trt.BuilderFlag.FP16)
# ★ TensorRT は fp32 ビルドでも既定で TF32(仮数 10bit)を使う。
# 忠実度を厳密に検証したいときは無効化する(詳細は中編)
if not tf32:
config.clear_flag(trt.BuilderFlag.TF32)
plan = builder.build_serialized_network(network, config)
if plan is None:
raise RuntimeError("build_serialized_network returned None(エンジンのビルドに失敗)")
with open(engine_path, "wb") as f:
f.write(plan)
return engine_path
build_engine("tiny.onnx", "tiny_fp16.plan", fp16=True)
コメントに書いたとおり、BuilderFlag.FP16 は「全部を fp16 にする」指定ではなく、「FP16 の実装候補を許可する」指定です。以降、本文で「fp16 でビルド」と書いたときは、この「FP16 tactic を許可してビルド」の意味だと読んでください。
ステップ 4: エンジンを実行する(PyTorch テンソルを直接渡す)
ここが実務上いちばん知りたいところだと思います。
device・dtype・形状・メモリ配置(contiguous)があらかじめエンジンの要求と一致していれば、PyTorch テンソルの GPU メモリアドレス(`data_ptr()`)を TensorRT にそのまま登録でき、境界での追加コピーを避けられます。
これができると、「モデルの一部だけ TensorRT 化して、残りは PyTorch のまま」というハイブリッド構成が現実的になります(第4部で使います)。
以下のサンプルは安全のため dtype と device を自動で合わせてから渡します
(条件が合わない場合はそこで変換コピーが発生します。なぜこの自動変換が要るのかは、すぐ後の罠で説明します)。
_TRT2TORCH = {
trt.float32: torch.float32, trt.float16: torch.float16,
trt.int32: torch.int32, trt.int64: torch.int64,
trt.int8: torch.int8, trt.bool: torch.bool,
}
class TRTRunner:
"""エンジンをロードし、PyTorch テンソルを入出力に使って実行する(固定形状前提)"""
def __init__(self, engine_path, device="cuda:0"):
self.device = torch.device(device)
# Runtime / engine / context はカレント CUDA デバイスに紐づくため、
# 対象デバイスを明示して作成する(cuda:0 以外を使う場合に必須)
with torch.cuda.device(self.device):
self.runtime = trt.Runtime(TRT_LOGGER)
with open(engine_path, "rb") as f:
self.engine = self.runtime.deserialize_cuda_engine(f.read())
if self.engine is None:
raise RuntimeError(f"deserialize failed: {engine_path}")
self.ctx = self.engine.create_execution_context()
self.inputs, self.outputs = [], []
for i in range(self.engine.num_io_tensors):
name = self.engine.get_tensor_name(i)
if self.engine.get_tensor_mode(name) == trt.TensorIOMode.INPUT:
self.inputs.append(name)
else:
self.outputs.append(name)
def prepare(self, feed: dict):
"""入力の形状とアドレスを設定し、出力バッファを確保する。
入力バッファを使い回すなら 1 回で足りる(速度計測ではここを計測の外に出す)"""
self._feed = {}
for name, t in feed.items():
# ★ FP16 tactic を許可してビルドしても、エンジンの入出力型は ONNX の定義のまま(多くは fp32)。
# half テンソルのアドレスをそのまま渡すと、fp16 のビット列が fp32 として
# 読まれて出力が壊れる。しかも例外は出ない(詳細は下記)
want = _TRT2TORCH[self.engine.get_tensor_dtype(name)]
t = t.to(device=self.device, dtype=want).contiguous()
# set_* 系も bool を返す。失敗の握りつぶしはこの記事のテーマ的に厳禁
if not self.ctx.set_input_shape(name, tuple(t.shape)):
raise RuntimeError(f"set_input_shape failed: {name}, shape={tuple(t.shape)}")
if not self.ctx.set_tensor_address(name, t.data_ptr()):
raise RuntimeError(f"set_tensor_address failed: {name}")
self._feed[name] = t
self._outs = {}
for name in self.outputs:
shape = tuple(self.ctx.get_tensor_shape(name))
if any(d < 0 for d in shape):
raise RuntimeError(f"output shape unresolved: {name} {shape}")
dtype = _TRT2TORCH[self.engine.get_tensor_dtype(name)]
o = torch.empty(shape, dtype=dtype, device=self.device).contiguous()
self._outs[name] = o
if not self.ctx.set_tensor_address(name, o.data_ptr()):
raise RuntimeError(f"set_tensor_address failed: {name}")
return self._outs
def enqueue(self):
"""現在のストリームに実行を積む(非同期)。★ 戻り値を必ず確認すること"""
ok = self.ctx.execute_async_v3(torch.cuda.current_stream(self.device).cuda_stream)
if not ok:
raise RuntimeError("TensorRT enqueue failed: エンジンと実行環境の不一致等の可能性。"
"出力は信頼できないため処理を停止する")
def run(self, feed: dict):
"""検証用の簡便版(毎回 prepare + 同期する)。速度計測には使わない"""
outs = self.prepare(feed)
self.enqueue()
torch.cuda.current_stream(self.device).synchronize()
return outs
罠その2: FP16 tactic を許可してビルドしても、エンジンの入出力は fp32 のまま
これは今回の検証で実際に踏んだものです。
config.set_flag(trt.BuilderFlag.FP16) を有効にすると TensorRT は FP16 の実装候補を選択肢に加えますが(全層が fp16 になるわけではありません)、
ネットワークの入出力テンソルの型は ONNX の定義に従うため、この例では fp32 のままです。
ここに PyTorch の half テンソルのアドレスをそのまま渡すと、TensorRT はその領域を「fp32 要素が並ぶ入力バッファ」として扱います。fp16 は 1 要素 2 バイト・fp32 は 4 バイトなので、値の解釈が変わるだけでなく、TensorRT が PyTorch 側の確保範囲を超えて読み出す可能性もあります。
挙動は未定義で、出力の破損だけでなく、環境によっては CUDA エラーやクラッシュも起こり得ます
(set_tensor_address はポインタを登録するだけで、バッファのサイズまでは伝えないためです)。
実測すると、こうなりました。
- 正しく dtype を合わせた場合:
最大絶対誤差 約 0.001
(今回の FP16 tactic 許可設定では、大きな数値破綻は見られません) - half のアドレスをそのまま渡した場合
最大絶対誤差 約 13.8
(出力の値域が 0.5 弱のモデルで、です。なおこの値は今回の環境で観測された一例で、再現可能な誤差量を意味しません)
そして最悪なのは、今回の実測ではこの状態でも `execute_async_v3` が `True`(成功)を返し、NaN も出ず、例外も飛ばなかったことです。上のコードで want = _TRT2TORCH[...] の 2 行を入れているのは、この沈黙劣化を防ぐためです。
罠その3: `execute_async_v3` の戻り値を捨ててはいけない
このメソッドは失敗しても例外を投げず、`False` を返すだけです。
放置すると、更新されなかった出力バッファ(=ゴミ、あるいは直前のフレームの値)がそのまま下流へ流れていきます。
処理は「成功」に見えたまま、出力だけが壊れるという最悪の形になります。上のコードのように False を即例外にしておくことを強くおすすめします。
ステップ 5: 数値を突き合わせてから、速度を測る
速度を測る前に、まず数値が合っているかを確認します。
順序を逆にしてはいけません。
照合の基準は、ステップ 2 で export に使った fp32 の `model` と `x` そのものです。ここで新しくモデルを作り直してはいけませんし
(ランダム初期化された「別の重み」と比較することになります)、fp16 化した別のモデルを基準にしてもいけません(「export とエンジンが正しいか」の検証になりません)。
# model / x はステップ 2 で export に使った fp32 のものをそのまま使う
with torch.inference_mode():
torch_ref = model(x)
runner = TRTRunner("tiny_fp16.plan")
trt_out = runner.run({"x": x})["y"]
diff = trt_out.float() - torch_ref.float()
print("max_abs :", diff.abs().max().item())
print("nRMSE :", (diff.norm() / torch_ref.norm()).item())
出力の値域そのものを併記しないと、最大絶対誤差の大小は判定できません。学習済みでないダミーモデルは値域が実モデルと違うので、正規化した相対誤差(nRMSE)を併せて見るのが安全です。
今回の TinyBlock では max_abs 約 0.001・nRMSE 約 0.0016 で、FP16 tactic を許可したエンジンの、今回の設定における誤差として小さい水準でした。
計測は 専用の CUDA ストリーム + CUDA Event で行います。time.time() では GPU の非同期実行を正しく測れません。
import numpy as np
def bench_callable(fn, n_warmup=30, n_iter=200, device="cuda:0"):
"""fn() を計測して mean/median/min/max(ミリ秒)を返す。
専用ストリームを使うことで、PyTorch と TensorRT を同じ条件で公平に比較できる"""
dev = torch.device(device)
# これ以前に別ストリームへ積まれた処理(モデル変換・prepare 等)を完了させる。
# ストリーム間は明示的に同期しない限り実行順序が保証されない
torch.cuda.synchronize(dev)
stream = torch.cuda.Stream(dev)
with torch.cuda.stream(stream):
for _ in range(n_warmup): # ウォームアップは必須(初回は遅い)
fn()
stream.synchronize()
times = []
for _ in range(n_iter):
s = torch.cuda.Event(enable_timing=True)
e = torch.cuda.Event(enable_timing=True)
s.record(stream)
fn()
e.record(stream)
stream.synchronize()
times.append(s.elapsed_time(e))
t = np.array(times)
return {"mean": float(t.mean()), "median": float(np.median(t)),
"min": float(t.min()), "max": float(t.max())}
速度計測では、PyTorch 側の実運用候補として fp16 化したモデルを使います。照合に使った fp32 の model はそのまま残したいので、コピーしてから fp16 化します。ポイントは、TensorRT 側は `prepare()`(バッファ確保・アドレス登録)を計測の外で 1 回だけ行い、計測中は `enqueue()` だけを呼ぶことです。
毎回 run() を呼ぶと、dtype 変換・出力確保・同期まで TensorRT の時間に含まれてしまい、公平な比較になりません。
import copy
# 速度計測用の fp16 モデルは、export に使った fp32 model を保つため deepcopy で別に作る
model_half = copy.deepcopy(model).half()
x_half = x.half()
# PyTorch 側(eager + inference_mode・fp16)
def torch_fn():
with torch.inference_mode():
model_half(x_half)
# TensorRT 側: prepare は計測の外で 1 回だけ。計測中は enqueue のみ
runner.prepare({"x": x})
def trt_fn():
runner.enqueue()
print("torch:", bench_callable(torch_fn))
print("trt :", bench_callable(trt_fn))
結果はこうなりました。

計測条件: GeForce RTX 5060 Ti / batch=1 / PyTorch 2.11 eager + inference_mode・fp16 モデル / TensorRT 10.16・fp32 入出力・FP16 tactic 有効。TensorRT は入出力バッファを事前確保し enqueue 部分のみを計測。各 200 回・ウォームアップ 30 回の median。Conv + BatchNorm + ReLU を 3 段重ねただけの小さなモデルで、GPU 実行区間では約 2.5 倍の差が出ました。
ただしこの数字の読み方には注意が要ります。
これは「PyTorch の全面 fp16 eager」と「fp32 入出力 + FP16 tactic を許可した TensorRT エンジン」を、事前の dtype 変換とバッファ準備を除いた GPU 実行区間で比べた結果です。
同一精度条件どうしの比較でも、パイプライン全体のエンドツーエンド高速化率でもありません。
それでも、同じモデル構造をそれぞれの実運用候補となる設定で実行したときにこれだけ差が出る、というのは第1部で挙げたレイヤ融合・カーネル選択・実行時オーバーヘッド削減などの効果と整合的な結果です。
比較は median で見ます。
平均はときどき混ざる外れ値に引きずられます。
また、計測中はその GPU を単独で占有してください。他の推論プロセスやデスクトップ描画が同じ GPU に乗っていると、数値は簡単に数十パーセント変動し、比較の意味がなくなります。
第4部: 最初の壁 ~ grid_sample の次元でビルドが落ちる
本命のモデルを変換し始めると、多くの方が最初にぶつかるのがこれです。モデルの中に 5 次元(volumetric)の `grid_sample` が入っていると、TensorRT のビルドが通りません。
3D ボリュームを変形させる処理は、医用画像のレジストレーション、NeRF 系、3D 特徴のワーピングなど、いろいろな領域で使われています。再現用の最小モジュールは次のとおりです。
import torch.nn.functional as F
class VolumetricWarp(nn.Module):
"""5D(volumetric)grid_sample を含む最小モジュール。
input: [N, C, D, H, W] / grid: [N, D, H, W, 3]"""
def forward(self, vol, grid):
return F.grid_sample(vol, grid, align_corners=False)
VOL_SHAPE = (1, 32, 16, 64, 64)
GRID_SHAPE = (1, 16, 64, 64, 3)
export とビルドは、ステップ 2〜3 と同じ道具立てでそのまま試せます。
warp_model = VolumetricWarp().eval().cuda()
vol = torch.randn(*VOL_SHAPE, device="cuda")
grid = torch.rand(*GRID_SHAPE, device="cuda") * 2 - 1
torch.onnx.export(
warp_model, (vol, grid), "volumetric_warp.onnx",
input_names=["vol", "grid"], output_names=["y"],
opset_version=20, dynamo=True,
)
# ONNX export は成功する。落ちるのは次の build のほう
build_engine("volumetric_warp.onnx", "volumetric_warp.plan", fp16=False)
実行すると、次のエラーで落ちます(実機のログそのままです)。
[TRT] [E] INetworkDefinition::addGridSample: Error Code 3: API Usage Error
(Parameter check failed, condition: input.getDimensions().nbDims == 4.
In addGridSample at /_src/optimizer/api/network.cpp:1803)
[TRT] [E] ModelImporter.cpp:138: While parsing node number 0 [GridSample -> "y"]:
[TRT] [E] ModelImporter.cpp:149: ERROR: ModelImporter.cpp:490 In function parseNode:
[6] Invalid Node - node_GridSample_0
input.getDimensions().nbDims == 4
つまり TensorRT 10.16 のネイティブ `GridSample` は 4 次元入力しか受け付けません。
5D の呼び出しは、パーサがノードを追加する時点で拒否されます。
これは一次情報とも一致します。onnx-tensorrt の operators ドキュメントは GridSample について "Input must be 4D input." と明記しており、5D 対応の要望は NVIDIA/TensorRT の issue #3890 として存在しますが、open / triaged のままネイティブ対応は提供されていません。
「ONNX の opset を上げれば通る」は誤りです
ここは混乱しやすいところなので明確にしておきます。
ONNX の標準仕様では opset 16 で 4D、opset 20 で 5D(volumetric)の grid_sample が定義されています。
ですから PyTorch からの export は opset 20 で問題なく成功し、ONNX ファイルの中に 5D の GridSample ノードがちゃんと生成されます。実際、上のエラーログは「export は成功したあと、ビルドの段階で落ちている」ことを示しています。
落ちているのは export ではなく、TensorRT 側の build です。ONNX に書けることと、TensorRT が食えることは別問題です。
現実解 — 通らないモジュールだけ PyTorch に残す
回避策のひとつは、TensorRT が持っていない演算を自分で CUDA カーネルとして書き、プラグインとして登録することです。5D grid_sample には既存の OSS 実装(grid-sample3d-trt-plugin など)があり、これを新しい CUDA / TensorRT 世代向けにビルドし直すアプローチが取れます。
ただしこれはエキスパート作業なので、本シリーズの後編で扱います。
プラグインを書き上げるまで移行が 1 ミリも進まない、というのは現実的ではありません。そこで有効なのがハイブリッド構成です。考え方はいたってシンプルで、TensorRT で通るモジュールだけ TensorRT にし、通らないモジュールは PyTorch のまま残すというものです。

# 構成イメージ(head_fp16.plan は「後段を TensorRT 化したエンジン」の意味の仮の名前です)
warp = VolumetricWarp().eval().cuda() # 5D grid_sample → PyTorch のまま
head = TRTRunner("head_fp16.plan") # 後段 → TensorRT エンジン
def forward(vol, grid):
with torch.no_grad():
feat = warp(vol, grid) # PyTorch が GPU 上に出力を作り
return head.run({"x": feat})["y"] # そのアドレスをそのまま TensorRT へ渡す
ステップ 4 で見たとおり、dtype・device・形状が一致していれば、PyTorch テンソルと TensorRT は同じデバイス・同じストリーム上でアドレスを渡し合えるので、両者の橋渡しに追加のコピーを発生させずに済みます。
私たちのケースでは、プラグインを一切書かずにこの構成だけで 2 倍近く改善し、TensorRT にクリーンに載るモジュールを順に置き換えていくと、プラグイン不要の範囲だけでパイプライン全体が 2 倍程度に到達しました。
「まず低リスクな範囲で確実に速くする。カスタムプラグインは別プロジェクトとして切り出す」
これが移行の現実的な進め方だと考えています。
まだ確かめていないこと
誠実に書いておきます。本記事の検証は「TensorRT 10.16・PyTorch 2.11 + cu128」という単一のソフトウェア構成で行ったものです。次の点は本記事では確認していません。
- 他の TensorRT バージョンでの再現性
特に 4Dgrid_sampleの挙動(中編で扱います)は、将来のバージョンで修正される可能性があります。また TensorRT 11 系は API が変わっており(strong typing 化)、本記事のコードはそのままでは動きません - torch.compile や CUDA Graphs を使った PyTorch との比較
本記事の比較対象は eager 実行のみです - int8 量子化との組み合わせ
本記事は fp16 / fp32 のみを扱っています - 可変形状(dynamic shape)でのビルド
本記事はすべて固定形状です。
optimization profile を使う場合の速度・精度は別途検証が必要です - 5D grid_sample プラグインを Blackwell 向けにビルドした場合の数値と速度
後編で扱う予定です
まとめ
一言でまとめると、
「Blackwell 移行は GPU の差し替えではなく、PyTorch と TensorRT の対応状況の更新に加えて、周辺ランタイムが実際にどの実行バックエンドで動いているかを再確認する作業であり、TensorRT 化はそこに乗せる別の投資判断である」
ということです
(まったく一言にならなかったですが(笑)
ちなみに、私たちの環境では onnxruntime の CPU フォールバックも発生しました)
さて、ここでまとめとして大事なポイントを再掲いたしますね。
- GPU の Compute Capability を最初に確認する
(RTX 50 系は sm_120。Blackwell の他製品群は別の値を持つので製品ごとに確認する) - PyTorch は cu128 世代
(安定版は 2.7.0 以降) - TensorRT は 10.8 以降を使う。
既存の TensorRT 8 エンジンは互換設定と対象 GPU を確認のうえ、原則として再ビルドする - TensorRT の CUDA 系列を torch に合わせる(cu12 にピン)
- onnxruntime が CPU に落ちていないか、バージョンと providers を実際に出力して確認する。落ちていれば最優先の TensorRT 化候補
- 最小モデルで自己診断してから本命へ進む
- エンジンの入出力 dtype に合わせてテンソルを渡す
(FP16 tactic を許可したビルドでも入出力は fp32 のことが多い) - execute_async_v3 や set_input_shape / set_tensor_address の戻り値を必ず確認する
(失敗しても例外は飛ばない) - 数値照合は速度計測より先に、export に使ったモデルと同じ重み・同じ入力で行う
- モデル内の
grid_sampleの次元を確認する
(5D はネイティブでは通らない。opset を上げても解決しない) - 通らないモジュールは切り出してハイブリッド化する
- 計測は GPU を単独占有して CUDA Event の median で見る。エンジン単体の比較ではバッファ準備を分離して enqueue 区間を測り、実運用の判断では dtype 変換・モジュール境界・同期を含むエンドツーエンド時間も別途はかる
次回予告(中編)~ ビルドが通っても、正しいとは限らない
次回は後編ではなく中編です。
今回の前編で扱ったのは、エラーで止まってくれる種類の問題でした。
移行作業で本当に怖いのはその先です。
私たちが実際に踏んだ「沈黙劣化」を扱います。
- ビルドも実行も成功していて、出力の形も値も「それらしい」。しかし演算の意味が別物にすり替わっているケース
(これは今回、公開できるダミーモデルで完全に再現できました) - ビルド成功・速度も良好、なのに実データを入れた瞬間に出力が壊れるモジュール
- 開発機で完璧に動いたエンジンが、同じ世代の別の GPU では静かにゴミを出す現象
- fp32 でビルドしたのに、実は fp32 ではなかったという話
- そして私たちが最も泣いたexport 時の引数取り違え事故
どれも「速くなったので OK」と報告した後に発覚すると非常に厄介なものばかりですので、お楽しみに~
それでは、また次回お会いしましょう!
参考資料(一次情報・主要ソース)
- NVIDIA TensorRT ドキュメント(Release Notes 10.8.0 — Blackwell 対応の記載)
- NVIDIA 公式フォーラム: Software Migration Guide for NVIDIA Blackwell RTX GPUs(CUDA 12.8 / PyTorch / TensorRT)
- PyTorch issue #159207: sm_120 is not compatible with the current PyTorch installation
- onnxruntime issue #27875: Blackwell 環境での CUDAExecutionProvider 初期化に関する報告
- NVIDIA/TensorRT issue #3890: 5D GridSample のネイティブ対応要望
- SeanWangJS/grid-sample3d-trt-plugin(5D grid_sample のカスタムプラグイン実装)
- ONNX Operators: GridSample(opset 16 / 20 の仕様)
- 当社関連記事: 2026年 NVIDIA GPU 一発検索ツール