把 Stable Diffusion 模型往 TensorRT 引擎转的过程里,最让人血压升高的一类报错不是显存爆了,而是那一行红字:Expected all tensors to be on the same device, but found at least two devices, cpu and cuda:0! (when checking argument for argument ...)。它不像 OOM 那样干脆利落地告诉你"显存不够",也不像算子不支持那样明确指认某个节点,它只是冷冷地告诉你:有两个张量不在同一个地方,你自己找吧。很多人第一反应是把 batch size 调小、把分辨率降低、重启一下 webui,甚至怀疑是不是 TensorRT 装错了——这些操作基本都没用,因为锅根本不在 TensorRT 那边。
这篇内容围绕 Stable Diffusion 转 TensorRT 时这个经典的设备错位报错展开,把报错文本逐字拆开、把常见触发场景列清楚、给出一套五分钟能定位到具体层的排查手法,再把从.safetensors加载一路到 ONNX 导出、TRT engine 构建的整条链路上"该在哪里钉设备"讲透。适合已经在跑 SD 本地部署、正在尝试 TensorRT 加速的玩家,也适合写导出脚本时被这行报错卡住的开发者。不管你是用整合包还是自己搭环境,下面这些方法都能直接用。
1. 先看清楚这行红字到底在说什么
1.1method wrapper_CUDA__这个前缀的来历
这行报错的措辞是 PyTorch 2.0 以后才有的。在此之前,老版本给的是RuntimeError: Expected object of device type cuda but got device type cpu for argument #2 'mat2',或者Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!。新版本把 dispatcher 的内部结构暴露到了错误信息里,于是你看到了wrapper_CUDA__linear这种看起来像天书的前缀。
拆开读就很好理解:wrapper是 dispatcher 给算子套的壳,CUDA表示这次调用已经按 CUDA 后端分发下去了,__后面跟的是算子的真实名字。所以wrapper_CUDA__linear翻译成人话就是——PyTorch 已经认定这次linear调用应该走 CUDA 实现,结果在参数检查阶段发现里面混进了一个 CPU 张量。
顺着这个思路,不同后缀指向的位置完全不同,这也是定位问题的第一条线索:
| 报错中的算子后缀 | 大概率出问题的位置 | SD 里的具体含义 |
|---|---|---|
wrapper_CUDA__linear/wrapper_CUDA__addmm | 全连接层权重或输入 | CLIP 文本编码器的投影层、UNet 注意力里的 QKV 线性层 |
wrapper_CUDA__embedding | 嵌入表或索引 | 文本编码器第一层的 token embedding |
wrapper_CUDA__native_layer_norm | LayerNorm 的 weight/bias | 注意力块前后的归一化层 |
wrapper_CUDA__index_select | 索引张量 | embedding 查表时的输入 id |
wrapper_CUDA__conv2d | 卷积核或特征图 | UNet 下采样/上采样卷积 |
记住一句话:报错里的算子名就是地图。看到embedding就别去翻 UNet 的卷积层,直接去查文本编码器。
1.2 "argument for argument" 后面那个词才是真凶
完整报错里通常还会跟一句(when checking argument for argument weight ...)或者... for argument mat2 ...。这个argument后面的名字,指的是第一个被检查出来不在同一设备上的张量参数名。
这非常关键,因为它能帮你分清两种截然不同的故障模式:
- 如果指的是
weight、bias、running_mean这类名字,说明模型参数没搬全,是模型侧的问题。 - 如果指的是
input、mat1、mat2、other、index、src这类名字,说明调用方传进来的张量在 CPU 上,是数据侧的问题,最常见的就是导出脚本里的 dummy input 创建时忘了写device=。
这两类的修法完全不一样。前者要遍历整个nn.Module把漏掉的层搬到 GPU,后者只需要在构造输入张量的地方补一个device='cuda'。很多人上来就无脑加.cuda(),结果加错地方,报错从weight变成mat2,白白浪费一晚上。
1.3 为什么 TensorRT 的转换日志里会蹦出 PyTorch 的异常
这是最容易被误解的一点。整套转换流程其实分成五段:
- 从
.safetensors/.ckpt加载权重到 PyTorch 模型; - 把 UNet、VAE、CLIP 等子模块拼装成可导出的结构,挂上 LoRA、ControlNet、自定义 VAE;
- 用
torch.onnx.export做一次 trace,生成 ONNX 计算图; - 对 ONNX 图做常量折叠、算子简化;
- 交给 TensorRT 的 builder 编译成
.plan/.engine。
第 1 到第 4 步全程跑在 PyTorch 和 ONNX 里,TensorRT 只参与最后一步。设备错位这个异常只可能来自前三步,因为 TRT builder 拿到的是已经序列化好的图,它压根不关心你原来的张量在哪个设备上。
所以当你在转换脚本日志里看到这行红字时,正确的心态是:暂时把 TensorRT 忘掉,把它当成一个纯粹的 PyTorch 模型导出问题来处理。我见过太多人把 TensorRT 卸了重装三遍,问题还在原地——因为问题从来就不在 TensorRT 上。
2. 三类最容易出事的设备错位场景
2.1 权重加载时张量留在 CPU 上
这是占比最高的一类。典型写法长这样:
import torch ckpt = torch.load("model.safetensors") model.load_state_dict(ckpt["state_dict"]) # 忘了 model = model.to(device),或者只搬了一部分torch.load不带map_location时,会把权重还原到保存时所在的设备。如果你的 checkpoint 是在 CUDA 上保存的,而当前机器的 CUDA 编号对不上,或者压根没检测到 CUDA,PyTorch 会静默地落到 CPU 上。更隐蔽的情况是:checkpoint 保存时在cpu,你以为它会自动跟模型走,其实不会——load_state_dict只是把数据拷进已存在的参数里,不会改变参数原本的设备。
还有一种只搬了一半的写法,在 SD 里特别常见:
model.diffusion_model.to(device) # UNet 搬了 model.cond_stage_model.to(device) # 忘了搬 CLIP model.first_stage_model.to(device) # 忘了搬 VAESD 的结构是由好几个独立子模型拼起来的,diffusion_model(UNet)、cond_stage_model(CLIP 文本编码器)、first_stage_model(VAE)各有各的参数。只对最外层调一次.to()通常能递归下去,但如果中间有人用nn.ModuleList之外的方式持有子模块(比如塞进了一个普通 list、dict,或者挂在 Python attribute 上),.to()就递归不到,那部分就会永远停在 CPU。
2.2 示例输入忘了指定 device
这个坑踩的人最多,也最好修。导出 ONNX 必须给模型喂一组 dummy input 来 trace,而很多脚本里是这么写的:
sample = torch.randn(2, 4, 64, 64) timestep = torch.tensor([1]) encoder_hidden_states = torch.randn(2, 77, 768) torch.onnx.export(unet, (sample, timestep, encoder_hidden_states), "unet.onnx")torch.randn不带device参数,默认就是 CPU。而 UNet 已经被搬到cuda:0了。第一个卷积层一执行,报错立刻出现。
正确的写法是让输入跟模型保持绝对一致,不要靠记忆去写cuda:0,而是从模型里读:
device = next(unet.parameters()).device dtype = next(unet.parameters()).dtype sample = torch.randn(2, 4, 64, 64, device=device, dtype=dtype) timestep = torch.tensor([1], device=device) encoder_hidden_states = torch.randn(2, 77, 768, device=device, dtype=dtype)用next(model.parameters()).device去拿设备,好处是无论你后面改成cuda:1、改成 CPU 调试、还是改半精度,这段代码都不用动。这个小习惯我建议从第一天写导出脚本时就养成,能省掉后面无数次排查。
2.3 没注册成 buffer 的"野生张量"
这一类最阴,因为它不会在model.named_parameters()里出现,你用常规方法打印设备分布也查不到。典型的长这样:
class PosEmbed(nn.Module): def __init__(self, dim): super().__init__() self.pe = torch.zeros(1, 197, dim) # 普通 attribute,不是 buffer这里的self.pe只是一个挂在模块上的普通 Python 属性。model.to('cuda')会把parameters()和buffers()逐个搬运,但不会碰self.pe。于是模型在 GPU 上跑,self.pe还在 CPU,一相加就炸。
两种修法,选后者更稳:
# 写法一:注册成 buffer,随 .to() 自动迁移 self.register_buffer("pe", torch.zeros(1, 197, dim)) # 写法二:干脆不存,在 forward 里按输入设备现场生成 pe = torch.zeros(1, x.size(1), x.size(-1), device=x.device, dtype=x.dtype)还有一个更隐蔽的变体:safetensors.torch.load_file()和load_model()返回的张量永远是 CPU 张量,不管你当前模型在哪儿。如果你用 safetensors 加载 LoRA 权重,然后直接拿去做F.linear(x, lora_up),x在 cuda、lora_up在 cpu,报错必现。这一点后面第 4 章还会细讲。
把这三类整理成一张对照表,排查时可以按图索骥:
| 场景 | 报错里的 argument 名 | 最快的验证方式 | 最小修复 |
|---|---|---|---|
| 权重没搬全 | weight/bias | 打印各子模块设备分布 | 对根模块调.to(device),或递归搬运 |
| dummy input 在 CPU | mat2/other/input | 检查导出脚本里张量创建处 | 补device=device |
| 野生张量 / LoRA 权重 | mat2/other | 用 forward hook 打设备集合 | 注册 buffer 或显式.to() |
3. 五分钟定位法:把出问题的层揪出来
3.1 打印全模型的设备分布
排查第一步永远是先看一眼"现场"。写一个十几行的审计函数,比瞎猜快一百倍:
import torch from collections import Counter def audit_devices(model, tag="model"): stat = Counter() for _, p in model.named_parameters(): stat[str(p.device)] += 1 for _, b in model.named_buffers(): stat[str(b.device)] += 1 print(f"[{tag}] device stats: {dict(stat)}") # 把不在目标设备上的层名打印出来,最多 30 条 target = "cuda:0" leaked = [(n, str(p.device)) for n, p in model.named_parameters() if str(p.device) != target] leaked += [(n, str(b.device)) for n, b in model.named_buffers() if str(b.device) != target] for n, d in leaked[:30]: print(f" leaked: {d:<10} {n}") print(f"[{tag}] leaked total: {len(leaked)}") return leaked这个函数一跑,如果device stats里同时出现cuda:0和cpu,基本就锁定是权重没搬全了。而且leaked列表会直接把层名和它所在的设备打出来,前三十条足够你看清规律——是集中在某个子模块,还是散落各处。
我自己的习惯是在模型拼装完之后、torch.onnx.export之前各调一次audit_devices。两次结果一对比,如果第二次多出泄漏项,说明中间某段代码又创建了新张量。
3.2 用 forward pre-hook 抓第一现场
设备分布看着没问题,但一跑还是崩?那问题多半出在 forward 内部动态生成的张量上。这时候named_parameters()那套就不够用了,得用 hook 在每一层执行前检查。
def install_device_guard(model, target="cuda:0"): def make_pre(name): def pre_hook(module, args): devs = {str(t.device) for t in args if torch.is_tensor(t)} devs |= {str(p.device) for p in module.parameters(recurse=False)} devs |= {str(b.device) for b in module.buffers(recurse=False)} if len(devs) > 1: print(f"[MISMATCH] {name} -> {sorted(devs)}") return pre_hook for n, m in model.named_modules(): m.register_forward_pre_hook(make_pre(n))挂上去之后跑一次前向(导出脚本里那次 trace 就够了),日志里第一个[MISMATCH]就是第一现场。因为报错是按执行顺序触发的,第一个不匹配的层往往就是真正的病灶;后面跟着刷屏的那些,多半只是它连带的受害者。
用recurse=False是有意为之:只看这一层自己持有的参数和 buffer,不看子模块的。否则父模块会把子模块的参数全算进去,永远判定为不匹配,日志就没法看了。
3.3 打开 CUDA_LAUNCH_BLOCKING 和 C++ 栈
PyTorch 的报错栈有时候会指向一个跟你写的代码完全无关的位置,比如报在torch/nn/modules/module.py的_call_impl里。这是因为 CUDA 是异步执行的,Python 侧早就跑到后面去了,错误才在 GPU 上爆发出来。
加两个环境变量能大幅改善这一点:
CUDA_LAUNCH_BLOCKING=1 TORCH_SHOW_CPP_STACKTRACES=1 python export_onnx.pyCUDA_LAUNCH_BLOCKING=1强制每个 CUDA 调用同步,报错会精确落在真正执行它的那一行。TORCH_SHOW_CPP_STACKTRACES=1会把 C++ 侧的调用栈也打出来,能直接看到是at::native::linear还是at::native::embedding触发的。
注意:
CUDA_LAUNCH_BLOCKING=1会明显拖慢速度,只在排查阶段开,定位到了立刻关掉。
3.4 二分法排除:按子模块单独跑
如果上面三招还是没头绪,就用最笨但最有效的办法——把模型拆开,一个个跑。
with torch.inference_mode(): # 先单独测 CLIP ids = torch.randint(0, 49408, (2, 77)).cuda() clip_out = sd_model.cond_stage_model(ids) print("clip ok", clip_out.device) # 再单独测 UNet x = torch.randn(2, 4, 64, 64).cuda() t = torch.tensor([1]).cuda() unet_out = sd_model.model.diffusion_model(x, t, context=clip_out) print("unet ok", unet_out.device) # 最后测 VAE latent = unet_out.cuda() img = sd_model.decode_first_stage(latent) print("vae ok", img.device)哪一段先崩,问题就在那一段里。这个方法的另一个好处是——它顺手帮你确认了每个子模块的接口签名,对后面写 ONNX 导出脚本很有帮助。
4. 从 .safetensors 到 ONNX:整条链路逐段对齐设备
4.1 权重加载阶段:map_location 的正确写法
先明确一个原则:加载阶段统一落到 CPU,之后显式搬到目标设备。不要图省事直接map_location="cuda"。
# 推荐写法 ckpt = torch.load(ckpt_path, map_location="cpu") model.load_state_dict(ckpt["state_dict"], strict=False) model = model.to(device=target_device, dtype=target_dtype) audit_devices(model, "after-load")为什么先落到 CPU 再搬?三个理由。第一,如果 checkpoint 是在 8 卡机器上存的、你现在只有 1 张卡,直接map_location="cuda"会因为设备编号对不上直接抛异常。第二,CPU 加载对显存的瞬时压力更小,低显存机器上不容易在加载阶段就 OOM。第三,先 CPU 后搬的过程里,你有机会在搬之前做一些张量级的预处理,比如 dtype 转换、权重融合。
半精度的顺序也有讲究:先.to(device),再.half(),或者干脆一次写成.to(device=device, dtype=torch.float16)。顺序反了的话,在 CPU 上做半精度转换不仅慢,某些算子还会因为 CPU 缺少 FP16 实现而报另一种错。
对于 safetensors 格式,加载方式略有不同,但落点一样:
from safetensors.torch import load_file state = load_file("model.safetensors", device="cpu") # 需要显式搬运 state = {k: v.to(device=target_device, dtype=target_dtype) for k, v in state.items()}safetensors.torch.load_file从 0.4 版本起支持device=参数,但默认值是cpu。这就是前面提到的第一类隐蔽坑:模型已经在 GPU 上,你以为加载出来的权重也会在 GPU 上,实际上不会。
4.2 模块拼装阶段:补齐 LoRA / ControlNet / VAE 的搬运
SD 转 TRT 时,真正麻烦的是后挂载的组件。LoRA 是权重差分、ControlNet 是额外 UNet 分支、自定义 VAE 是替换掉first_stage_model——这三样都有各自独立的加载路径,而且都容易在搬运上漏掉。
LoRA 融合的典型形态是:
lora_up = load_file("lora.safetensors")["lora_up.weight"] # CPU 张量 w = base_weight + alpha * (lora_down @ lora_up) # base_weight 在 GPU,炸修法很直接,把加载出来的张量统一下沉到目标设备和 dtype:
def to_target(t, device, dtype): return t.to(device=device, dtype=dtype, non_blocking=True) lora_up = to_target(load_file(path)["lora_up.weight"], device, dtype)non_blocking=True在固定内存配合下能加速拷贝,但前提是源张量锁页;不锁页时加了也无害,只是不生效。
ControlNet 的情况是:它本身是一个完整的网络,加载完之后必须整体.to(device),同时它的 conditioning 输入(比如边缘图、深度图)也要跟它同设备。我见过有人把 ControlNet 权重搬了,但 hint 图还是 CPU 上来的 numpy 转换结果,一样炸。
自定义 VAE 替换更容易被忽略,因为它经常是在 webui 启动参数里指定的(比如--no-half-vae、外挂的 VAE 文件),替换逻辑散落在不同位置。替换完之后一定要再audit_devices一遍。
4.3 导出阶段:dummy input、dynamic_axes 与 dtype 的一致性
torch.onnx.export阶段要同时保证三件事一致:设备一致、dtype 一致、shape 一致。设备不一致就是本文这个报错,dtype 不一致会报expected scalar type Half but found Float,shape 不一致会在 TRT 构建阶段报维度冲突。
用torch.set_default_device可以从根上减少第一类的发生概率:
import torch torch.set_default_device("cuda:0")这行之后,所有torch.randn、torch.zeros、torch.tensor不带device参数时都会默认建在cuda:0上。PyTorch 2.0 开始支持,还有对应的上下文管理器写法:
with torch.device("cuda:0"): sample = torch.randn(2, 4, 64, 64)提醒:
set_default_device是个全局开关,某些第三方库内部依赖"默认在 CPU"这个前提,打开之后可能引入新的问题。用在导出脚本这种短生命周期的场景里最安全,别在整个 webui 进程里乱开。
一份能直接用的导出骨架大致是这样:
import torch unet = sd_model.model.diffusion_model device = next(unet.parameters()).device dtype = next(unet.parameters()).dtype unet.eval() audit_devices(unet, "before-export") with torch.inference_mode(): sample = torch.randn(2, 4, 64, 64, device=device, dtype=dtype) timestep = torch.tensor([1], device=device) ctx = torch.randn(2, 77, 768, device=device, dtype=dtype) dummy = (sample, timestep, ctx) torch.onnx.export( unet, dummy, "unet_fp16.onnx", input_names=["sample", "timestep", "context"], output_names=["out_sample"], dynamic_axes={ "sample": {0: "batch", 2: "height", 3: "width"}, "timestep": {0: "batch"}, "context": {0: "batch"}, "out_sample": {0: "batch", 2: "height", 3: "width"}, }, opset_version=17, do_constant_folding=True, )dynamic_axes里把 batch 和 H/W 标成动态,是为了后面能一次编译出支持多种分辨率的引擎。但注意:动态轴越多,TRT builder 优化空间越小,而且更容易在 shape 推导阶段触发隐藏的算子不支持。如果只是自己用,先把分辨率固定成 512x512 或 768x768 编译一个版本,跑通了再上动态轴,这个顺序能省很多时间。
4.4 SDXL 双文本编码器与多 GPU 的特殊处理
SDXL 有两个文本编码器(一个 CLIP-L、一个 CLIP-bigG),它们的输出在通道维度拼接之后才喂给 UNet。这里有两个高频问题:一是两个编码器只搬了一个;二是两个编码器的 dtype 不一致,一个 FP16、一个 FP32。
稳妥的做法是统一处理:
text_encoders = [sd_model.cond_stage_model, sd_model.cond_stage_model_2] for te in text_encoders: te.to(device=device, dtype=dtype).eval()多 GPU 场景下还有一层复杂度:如果你用device_map或者accelerate把模型分散到多张卡上,next(model.parameters()).device拿到的只是第一张卡,用它去构造 dummy input 会导致后续所有在其它卡上的层全部不匹配。这种情况下要么显式指定每段输入落哪张卡,要么老老实实把整段模型收敛到单卡上再导出。转 TRT 这个场景里,单卡是默认推荐——TRT engine 本身就是按单设备编译的,多卡分散反而增加不确定性。
5. 修好之后怎么验证不是"假绿"
5.1 ONNX 侧:onnxruntime 与 PyTorch 的数值对齐
脚本不报错了、ONNX 文件也生成了,不代表导出是对的。ONNX 导出最常见的失败模式是"图能跑,数值不对",尤其是 FP16 导出时。所以必须做一次数值对齐:
import numpy as np import onnxruntime as ort import torch with torch.inference_mode(): ref = unet(*dummy).cpu().numpy() sess = ort.InferenceSession("unet_fp16.onnx", providers=["CUDAExecutionProvider"]) inputs = { "sample": dummy[0].cpu().numpy(), "timestep": dummy[1].cpu().numpy(), "context": dummy[2].cpu().numpy(), } out = sess.run(["out_sample"], inputs)[0] diff = np.abs(ref - out) print("max abs diff:", diff.max(), "mean:", diff.mean()) np.testing.assert_allclose(ref, out, rtol=1e-2, atol=1e-2)容差定在1e-2是针对 FP16 的经验值。FP32 导出应该收紧到1e-4左右。如果 max diff 在 1 以上,说明图里有算子被错误替换了,或者某个节点被常量折叠折坏了,这时候要回去检查do_constant_folding和 opset 版本。
提示:ONNX Runtime 和 PyTorch 的卷积实现在不同硬件上会有微小差异,即使 FP32 也不可能逐位相同。看到 1e-5 级别的差异不用担心。
5.2 TRT 侧:engine 构建与两次推理比对
引擎构建分两条路。命令行用trtexec最省事:
trtexec --onnx=unet_fp16.onnx \ --saveEngine=unet_fp16.plan \ --fp16 \ --minShapes=sample:1x4x64x64 \ --optShapes=sample:2x4x64x64 \ --maxShapes=sample:4x4x64x64 \ --workspace=4096如果模型有多个输入,--minShapes/--optShapes/--maxShapes必须把所有动态输入都列全,漏一个就会报 shape 不匹配。构建完之后,用 Python 侧的 TensorRT runtime 跑一次推理,跟 ONNX Runtime 的输出比对,这一次的容差可以放宽到2e-2——因为 FP16 的累加顺序在 TRT 里可能跟 ONNX Runtime 不一样。
两次比对都通过,才能说这次转换是干净的。
5.3 端到端:出图对比与常见精度容差
最后一步还是得看图。用固定 seed、固定 prompt、固定步数,分别用原始 PyTorch 路径和 TRT 引擎路径各出一张图,然后做像素级对比:
import numpy as np from PIL import Image a = np.asarray(Image.open("out_torch.png"), dtype=np.float32) b = np.asarray(Image.open("out_trt.png"), dtype=np.float32) print("mean abs diff:", np.abs(a - b).mean())正常情况下的差异应该在个位数级别(0-255 的尺度上),肉眼几乎看不出区别。如果画面上出现明显的色块、噪点或者结构错乱,说明某个阶段的精度损失已经越界了,这时候的排查方向是:先看是不是 VAE 用 FP16 导的(VAE 对精度特别敏感),再看 UNet 的 attention 层有没有被错误简化。
我自己的习惯是固定一组"验收用例"——一张 512x512、一张 768x768、一张带 ControlNet 的,三张图对着看。只要这三张稳定,基本可以放心用了。
6. 环境与版本里的隐性坑
6.1 PyTorch / CUDA / TensorRT 版本组合
这个报错的措辞本身就能反推版本:wrapper_CUDA__是 PyTorch 2.0+ 的格式。如果你的环境里 PyTorch 还是 1.13,看到的是另一套措辞,说明你可能在照着旧教程操作新版本,或者反过来。
一个被反复验证过的组合是这样的:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| PyTorch | 2.1.x / 2.2.x | 支持set_default_device,ONNX 导出对 opset 17 支持稳定 |
| CUDA | 11.8 / 12.1 | 11.8 兼容性最广,12.1 需要对应驱动 |
| TensorRT | 8.6 / 9.x | 8.6 生态最成熟,9.x 移除了部分旧 API |
| ONNX | 1.15+ | 低于 1.14 对 opset 17 的部分算子支持不全 |
| onnxruntime-gpu | 1.16+ | 与 CUDA 版本必须严格对应 |
版本错配最典型的症状不是本文这个报错,而是构建 engine 时提示某个算子不受支持、或者精度莫名其妙地崩。但有一种情况要注意:TensorRT 9.x 在某些版本下不再支持动态 shape 的某些组合,会迫使你回头改dynamic_axes,改的过程中如果顺手动了输入构造代码,就可能引入设备错位。所以改环境之前先把脚本备份一份。
6.2 onnxruntime 与 onnxruntime-gpu 的共存冲突
这是个经典陷阱:pip install onnxruntime和pip install onnxruntime-gpu装的是同一个 import 名onnxruntime,后装的会覆盖先装的。如果你验证时发现自己明明装了 GPU 版,跑起来却是 CPU 执行,用这一行确认:
import onnxruntime as ort print(ort.get_available_providers())输出里没有CUDAExecutionProvider,就说明当前生效的是 CPU 版。清理方式是先pip uninstall onnxruntime onnxruntime-gpu -y,再只装需要的那一个。
这事儿跟本文的报错有间接关系:如果你的导出脚本里恰好用 onnxruntime 做中间验证,而它跑在 CPU 上,你在 CPU 侧看到的张量当然都在 CPU——这会掩盖真实的设备问题,让你在错误的方向上排查。
6.3 半精度导出的边界与最小显存配置
FP16 导出能省一半显存,但有几个硬边界要知道。VAE 的解码器在 FP16 下容易出现 NaN 或者色偏,所以很多人的做法是 UNet 和 CLIP 用 FP16、VAE 用 FP32。这样做的时候,两个不同精度之间的衔接张量必须显式转换,否则会从设备错位变成 dtype 错位:
latent = unet_out.to(torch.float32) # 从 FP16 的 UNet 输出转回 FP32 喂给 VAE img = vae.decode(latent)显存方面,用trtexec构建 512x512 的 SD 1.5 UNet 引擎,--workspace给到 4096MB 一般够用;SDXL 建议直接给 8192MB。工作空间不足时 builder 会退化到更保守的策略,编译出来的引擎反而更慢,所以这个值别抠。
还有一点:--fp16只是允许用 FP16 做计算,不代表所有层都会用。构建完可以用trtexec --loadEngine=xx.plan --dumpLayerInfo看一下各层的精度,确认关键层没有被意外降精度。
7. 几个我踩过的边界情况
7.1 只在某个分辨率才炸:非 64 倍数
有一类特别迷惑的现象:512x512 导出得好好的,改成 520x520 就报设备错位。这跟设备其实没关系——真正的原因是 520 不是 64 的倍数,UNet 的下采样在某一层产生奇数尺寸,触发了某个不支持该尺寸的分支,而在那个分支里恰好有一段代码动态创建了 CPU 张量。
所以遇到"换个分辨率就炸"的情况,先别怀疑设备代码,先把分辨率调回 64 的倍数验证一下。SD 1.5 常用 512、576、640、704、768;SDXL 常用 1024、1152。如果你的dynamic_axes允许任意尺寸,最好在输入校验里加一道硬性检查:
assert h % 64 == 0 and w % 64 == 0, f"resolution must be multiple of 64, got {h}x{w}"7.2 换了显卡之后重新出现
换卡之后重装环境是常规操作,但有个细节容易被忽略:如果你之前生成的某些中间文件(比如已经转换好的 LoRA 缓存、预处理的 embedding 缓存)是用旧卡生成的,里面可能存着cuda:0的张量。新机器上用torch.load加载时会尝试还原到cuda:0,如果新卡的编号体系不一样(比如从单卡变双卡,或者 CUDA_VISIBLE_DEVICES 设过),就会落到 CPU 上然后引发错位。
修法是统一在加载时加map_location="cpu",这一条对任何缓存文件都适用,值得写进你的工具函数里。
7.3 webui 启动参数带来的副作用
跑在 webui 整合包里的转换脚本,会继承主进程的启动参数,这里面有几个会直接影响设备行为:
--precision full会禁用半精度,跟你在脚本里写的.half()冲突,导致部分层 FP32、部分层 FP16;--no-half-vae会让 VAE 保持 FP32,前面提到的衔接转换就必须加;--medvram/--lowvram会在运行期动态搬运模块,转换脚本执行到一半,某些层可能被挪走了;--skip-torch-cuda-test只是跳过检测,不会修复任何设备问题,别指望它。
所以我的建议是:做 TensorRT 转换时用独立的 Python 脚本,不要在 webui 进程里跑。模型文件、VAE、LoRA 都从磁盘直接加载,环境干净、设备状态可控、报错栈也短。整合包适合玩,不适合做这种对状态一致性要求高的操作。
最后分享一个我自己一直在用的小技巧:把设备审计函数封装成一个装饰器,加在任何导出函数头上,函数进来先审计、出去再审计。看着有点笨,但它帮我抓到过三四次"只在特定参数组合下才出现"的隐性泄漏,比事后追查便宜太多了。