张量(Tensor)是 PyTorch 里所有计算的载体,GPU 加速(GPU Acceleration)则是让这个载体真正跑起来的那套机制。很多人学完 CPU 上的张量创建、切片、广播、矩阵乘,觉得挺顺,一到真机就懵了:device不匹配的报错、显存莫名其妙爆掉、明明换了显卡加速比却只有 1.2 倍、计时器测出来的时间是假的……这些问题在教程里往往一句话带过,实际干活时能耗掉你一整天。
我写这篇东西的出发点是:把“张量上 GPU”这件事从“知道要写 .cuda()”推进到“知道每一步为什么这么写、什么时候不该这么写”。它会讲清楚设备抽象是怎么设计的、数据迁移的三种写法差在哪、怎么测才算测准了、显存到底被谁吃了、训练循环里那几个参数为什么必须成对出现。适合刚上手 PyTorch 的同学,也适合已经能跑通模型、但没系统梳理过数据搬运成本的老手——尤其是那些被CUDA out of memory折磨过几次的人,看完应该能把排查顺序理清楚。
1. 先把“GPU 加速”想明白:它不是换个芯片那么简单
1.1 CPU 和 GPU 是两种性格完全不同的工人
刚接触的人容易有个误解,觉得 GPU 是“更快的 CPU”。这个理解偏差会直接影响你写代码的方式。CPU 是少数几个非常聪明的核心,主频高、分支预测强、缓存层级复杂,擅长处理“下一步做什么取决于上一步结果”这种逻辑密集的任务;GPU 是几千个相对简单的核心排成阵列,每个核心只会做加减乘除,但它能同一时刻让几千个核心干同一件事。
张量运算恰好是后者的天菜。矩阵乘法、逐元素加法、卷积、归一化,拆开看全是“对每个元素执行相同的公式”,元素之间几乎没有依赖。这种结构在 CPU 上只能一个核一个核地啃,在 GPU 上可以一次性铺开。所以同一个 4096×4096 的矩阵乘,CPU 上可能是秒级,GPU 上能压到几十毫秒——差的是两个数量级。
但这个优势有前提:数据必须已经在显存里。一旦涉及“把数据从内存搬到显存”,性质就完全变了,搬运本身成了瓶颈。这就是下一节要讲的事。
1.2 搬运的代价:为什么频繁往返会把加速比吃干净
设备之间的数据通道带宽是有限的。以常见的桌面平台为例,主板上的高速串行通道理论带宽在十几到三十几 GB/s 量级,实际有效吞吐还要打折;而显卡内部显存到计算单元的带宽是几百 GB/s 到数 TB/s 量级。两者差了一到两个数量级。
这意味着什么?我给你算一笔账。一个 1024×1024 的 float32 张量,元素数约 105 万,每个元素 4 字节,总共约 4 MB。走一次主机到设备,按 10 GB/s 的有效带宽算,大约 0.4 毫秒;设备回主机再来 0.4 毫秒。往返接近 1 毫秒。而 1024 阶矩阵乘在入门级独显上大概 0.4 毫秒。也就是说,如果你写完就算、算完立刻拿回 CPU 看结果,搬运时间比计算时间还长,加速比直接被腰斩。
更糟的是“循环里反复搬”。我见过不少新手代码长这样:在 for 循环里每次x = x.cuda()、算一步、x = x.cpu()、用 numpy 处理一下、再搬回去。这种写法跑起来可能比纯 CPU 还慢,然后就开始怀疑是不是显卡不行。实际是自己把最贵的那步放进了最热的循环里。
记住一条:设备间搬运是“批量做才划算”的操作,你要做的是把数据一次性搬上去,让尽量多的计算在显存里连续完成,最后再统一搬回来。
1.3 什么情况下不值得上 GPU
这条经验很多人不爱听,但确实能省你不少时间。下面几种情况,CPU 反而是更优解:
- 张量很小。比如几百乘几百的矩阵、几千个元素的向量。计算量小到搬运开销完全盖过收益,而且每次 kernel 启动还有固定开销(微秒级),小算子堆多了开销很可观。
- 逻辑分支密集。大量 if/else、动态索引、变长循环,这类代码在 GPU 上会严重串行化,甚至触发一次次隐式的设备同步。
- 数据预处理阶段。解码图片、分词、数据增强这类操作,CPU 多进程做得又稳又便宜,硬塞给 GPU 往往得不偿失。
- 显存本身不够。模型放不下就是放不下,硬上只会换来无穷无尽的 OOM。
判断标准很朴素:先用 CPU 跑通、跑对,再看耗时热点在哪。热点是连续的大规模数值计算,才轮到 GPU 上场。上来就全链路 GPU 化,通常是把调试难度放大三倍。
2. PyTorch 的设备抽象:device、cuda 与张量的归属
2.1 torch.device 这套设计到底解决了什么问题
PyTorch 里的每个张量都带一个device属性,标明它住在哪。这个设计的关键价值是“把设备选择变成一个可传递的变量”,而不是散落在各处的硬编码。你可以写一套代码,在有卡和没卡的环境里都跑得动。
import torch device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(device) # cuda:0 或 cpu x = torch.randn(3, 4, device=device) print(x.device)torch.device接受的写法有几种,含义差别不大但用途不同:
| 写法 | 含义 | 适用场景 |
|---|---|---|
torch.device("cpu") | 主机内存 | 兜底分支 |
torch.device("cuda") | 默认卡(通常是 0 号) | 单卡场景 |
torch.device("cuda:0") | 指定 0 号卡 | 多卡明确指定 |
torch.device("cuda", 1) | 同上,另一种写法 | 参数化生成 |
"cuda"字符串 | 大多数 API 会自动转换 | 快速脚本 |
实践中的做法是把device定义在配置层,模型、数据、损失函数全部引用同一个变量。不要在一个文件里出现x.cuda()、另一个文件里出现.to("cuda:0")、第三个地方又是.to(torch.device('cuda', 0))。看着都对,一旦要切到 CPU 调试或者换卡,你得改十几个地方,还容易漏。
2.2 三种迁移写法的差异和踩坑点
把张量搬到设备上,常见三种写法,行为不完全一样:
x = torch.randn(3, 4) # 写法一:硬编码,没卡直接报错 x1 = x.cuda() # 写法二:字符串简写 x2 = x.to("cuda") # 写法三:走统一的 device 变量(推荐) x3 = x.to(device)写法一的坑在于不可移植。你本地有卡,跑到一台只有 CPU 的机器上做小规模验证,代码第一行就崩,报的还是AssertionError: Torch not compiled with CUDA enabled这种让人摸不着头脑的信息。写法二的问题是分散,一旦有十几处,改设备就得全局搜。
真正需要警惕的是返回值这件事。x.to(device)不是原地修改,它返回一个新张量。很多人写:
x.to(device) y = x @ w # 报错:x 还在 cpu这里x压根没变,只是产生了一个被丢弃的副本。正确写法是x = x.to(device)。这个坑之所以常见,是因为nn.Module.to()是原地修改的——模型的参数会被就地替换成设备上的版本,方法返回self只是方便链式调用。两个.to()语义不同,很容易形成肌肉记忆错乱。
一个小技巧:如果你不确定某个张量在哪,直接
print(t.device)或者assert t.device == device。在调试阶段加一行断言,比事后翻报错快得多。
2.3 dtype、contiguous 与“隐性复制”
设备迁移经常和类型转换一起做,to()支持同时指定:
x = x.to(device=device, dtype=torch.float16)注意这里如果device和dtype都和原来一致,to()会直接返回原张量本身,不复制。这个行为对内存友好,但也意味着你不能靠to()强制拿一份拷贝——需要副本请用clone()。
另一个容易忽略的点是内存布局。GPU 上的某些算子(尤其是底层调用高度优化库的)要求输入是连续内存。从 CPU 搬上来的张量如果经过转置、切片等操作,stride 可能不连续,直接喂给这些算子会报view size is not compatible with input tensor's size and stride。解决办法:
x = x.to(device).contiguous()contiguous()会按行优先重新排布一份连续副本,代价是一次额外拷贝。在数据加载阶段做一次,比在热路径里反复触发划算。
还有一类隐性复制值得说:torch.tensor(某个numpy数组)会复制数据,torch.from_numpy(arr)则与 numpy 共享内存。后者转到 GPU 之后共享关系自然断开,但如果你的流程是“numpy 处理 → from_numpy → 上 GPU”,中间省掉的那次拷贝在小数据上无所谓,在 GB 级数据上就是实打实的时间。
3. 从零跑通一个能说明问题的 GPU 对照实验
3.1 环境自检:先确认你的卡真的被用上了
动手之前先做一次体检。很多“GPU 没加速”的问题,根源是代码压根跑在 CPU 上,只是没人发现。
import torch print("torch 版本:", torch.__version__) print("CUDA 可用:", torch.cuda.is_available()) print("设备数量:", torch.cuda.device_count()) if torch.cuda.is_available(): print("设备名:", torch.cuda.get_device_name(0)) print("计算能力:", torch.cuda.get_device_capability(0)) print("显存总量:", round(torch.cuda.get_device_properties(0).total_memory / 1024**3, 2), "GB")如果is_available()返回 False,按这个顺序查:
- 驱动是否正常装上。系统自带的显卡状态查询命令能列出设备信息,说明驱动层是通的。
- 装的是不是 CPU 版本的 PyTorch。
torch.__version__里带+cpu后缀就说明装错了包,需要重装对应的构建版本。 - 框架版本和驱动版本是否匹配。这类问题报错信息通常很直白,比如提示构建版本不兼容。
- 在容器里跑的话,宿主机有没有把设备透传进容器。
版本对齐这件事我要多嘴一句:torch.version.cuda显示的是编译时链接的运行时版本,nvidia-smi右上角显示的是驱动支持的最高版本。前者的主版本号不能高于后者,否则会出现“能 import 但一用就报错”的诡异状态。
3.2 计时方法不对,测出来的数全是假的
GPU 计算是异步的。你在 Python 里调用一个 kernel,函数立刻返回,真正的计算还在设备队列里排队。所以用time.time()包一段 GPU 代码,测到的往往只是“把任务塞进队列”的时间,几微秒而已。
正确的做法有两种。简单的是在计时前后加同步:
import time torch.cuda.synchronize() t0 = time.perf_counter() y = a @ b torch.cuda.synchronize() t1 = time.perf_counter() print(f"{(t1 - t0) * 1000:.2f} ms")更精确的是用 CUDA 事件,它直接在设备侧打时间戳,不受主机侧调度抖动影响:
def bench(fn, warmup=10, iters=50): for _ in range(warmup): fn() torch.cuda.synchronize() start = torch.cuda.Event(enable_timing=True) end = torch.cuda.Event(enable_timing=True) start.record() for _ in range(iters): fn() end.record() torch.cuda.synchronize() return start.elapsed_time(end) / iters # 毫秒这里warmup不是可选项。第一次调用某个算子时,框架要做内核加载、内存池初始化、算法选择等一堆准备工作,耗时可能是稳态的几十倍。不预热直接测,你会得到一个完全失真的数字。
3.3 一份可以直接抄的对照实验
下面这段代码把“纯计算”和“端到端(含搬运)”分开测,这是理解加速比的关键:
import torch, time device = torch.device("cuda" if torch.cuda.is_available() else "cpu") def bench_cpu(n, iters=20): a = torch.randn(n, n) b = torch.randn(n, n) torch.set_num_threads(8) for _ in range(3): a @ b t0 = time.perf_counter() for _ in range(iters): a @ b return (time.perf_counter() - t0) / iters * 1000 def bench_gpu_compute(n, iters=50): a = torch.randn(n, n, device=device) b = torch.randn(n, n, device=device) for _ in range(10): a @ b torch.cuda.synchronize() s, e = torch.cuda.Event(True), torch.cuda.Event(True) s.record() for _ in range(iters): a @ b e.record() torch.cuda.synchronize() return s.elapsed_time(e) / iters def bench_gpu_end2end(n, iters=20): a_cpu = torch.randn(n, n) b_cpu = torch.randn(n, n) for _ in range(5): (a_cpu.to(device) @ b_cpu.to(device)).to("cpu") torch.cuda.synchronize() t0 = time.perf_counter() for _ in range(iters): c = (a_cpu.to(device) @ b_cpu.to(device)).to("cpu") torch.cuda.synchronize() return (time.perf_counter() - t0) / iters * 1000我在一台普通台式机(8 核处理器 + 一张入门级独显,走的是 PCIe 4.0 x16 通道)上跑出来的结果,趋势大致如下。这些数字只反映量级关系,换硬件会变,别拿去当基准:
| 方阵规模 | CPU 耗时 | GPU 纯计算 | GPU 端到端(含往返搬运) | 端到端加速比 |
|---|---|---|---|---|
| 64×64 | 0.02 ms | 0.01 ms | 0.05 ms | 约 0.4 倍(更慢) |
| 256×256 | 0.4 ms | 0.03 ms | 0.35 ms | 约 1.1 倍(持平) |
| 1024×1024 | 20 ms | 0.4 ms | 1.3 ms | 约 15 倍 |
| 4096×4096 | 1500 ms | 35 ms | 50 ms | 约 30 倍 |
这张表最值得看的是第二列和第四列的差距。在 256 阶这个尺度上,纯计算 GPU 快 13 倍,但算上搬运之后只剩 1.1 倍。如果你只看纯计算数字,会以为自己的链路已经优化到位了,实际收益被搬运吃了个精光。
3.4 结果怎么读:三个容易被误判的情况
第一种误判是拿单次结果做结论。单次测量受系统调度、后台任务、显卡降频影响很大,抖动能有 20% 以上。至少要跑 20 次取平均,再跑几组不同规模,看趋势。
第二种误判是忘了 CPU 侧的多线程设置。默认线程数在有些环境里被设成 1,CPU 基准成绩被严重低估,显得 GPU 快得离谱。torch.set_num_threads(8)这一行在对照实验里不能省。
第三种误判是把不同的数据类型放在一起比。float32 和 float16 的计算吞吐本来就差一倍甚至更多,一边用 fp32 一边用 fp16 得出结论,没有意义。要比较就锁死 dtype。
还有一点:4096 阶那行的端到端时间 50 ms,主要花在哪?67 MB 的数据搬上去 7 ms,算 35 ms,结果 67 MB 搬回来 7 ms。这里计算占大头。但如果你做的是“小模型 + 大数据”,比例会反过来,搬运可能占到七成以上。这时候优化方向就不是换卡,而是减少往返次数。
4. 训练循环里的 GPU 加速:数据流水线与显存管理
4.1 pin_memory + non_blocking 这一对参数为什么必须成对出现
训练时最常见的搬运方式是每个 batch 从主机内存拷到显存。默认情况下这个拷贝是同步的:主机发起拷贝,然后阻塞等待,等拷贝完了才能继续。这段时间 GPU 是闲着的。
PyTorch 给了两个配套的开关。DataLoader的pin_memory=True会把 batch 放进页锁定内存,这种内存不会被操作系统换出到磁盘,GPU 的直存引擎可以直接访问,省掉一次中间拷贝;non_blocking=True让拷贝调用立刻返回,主机继续去准备下一个 batch,拷贝在后台完成。
from torch.utils.data import DataLoader loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, persistent_workers=True, ) for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) # 前向、反向照常写几个必须知道的细节:
non_blocking=True只有在源数据位于页锁定内存时才真正异步。普通内存上的张量,即使传了 True 也会退化成同步拷贝,白写。pin_memory=True会占用不可换出的物理内存。num_workers 多、batch 大的时候可能吃掉几个 GB,机器同时开别的程序容易卡。别把值设得太大。- 异步拷贝之后,如果立刻在主机侧读取该张量的内容(比如打印一个数),框架会隐式同步,异步的收益就没了。别在热路径里干这种事。
persistent_workers=True能省掉每个 epoch 重建进程的开销,配合pin_memory用效果更明显,但要注意数据集里如果有带状态的随机数生成器,得手动处理种子。
实测下来的感受是:在图像和文本任务上,这一对开关能把数据供给的等待时间压掉三到五成,具体取决于你的预处理有多重。如果你的数据加载本来就很快,收益就不明显——先 profile 再决定要不要开,别盲目抄配置。
4.2 显存不是硬盘:缓存分配器与 empty_cache 的正确用法
很多人第一次遇到 OOM 的第一反应是加torch.cuda.empty_cache(),加在循环里,然后发现又慢又没用。要理解为什么,得知道 PyTorch 的显存管理方式。
PyTorch 不会每次张量分配都向驱动申请,它有一个缓存分配器。第一次申请一大块显存,之后需要小张量就从这块里切,用完还回池子而不是还给驱动。好处是分配极快,坏处是memory_reserved会一直保持高位,看起来像“泄漏”。
三个查询接口要分清楚:
print(torch.cuda.memory_allocated() / 1024**2, "MB") # 当前张量实际占用 print(torch.cuda.memory_reserved() / 1024**2, "MB") # 分配器持有的总池子 print(torch.cuda.max_memory_allocated() / 1024**2, "MB") # 峰值,用来定 batch sizememory_allocated高才是真的用了这么多;memory_reserved高只是池子大,池子里的空闲块可以被后续分配复用。empty_cache()释放的是池子里的空闲块,正在用的张量它碰不了。所以在循环里调它,等于每隔几步就把池子清空、下一步再重新申请,白白增加开销,还容易造成碎片。
它真正该用的场景只有两个:一是你想给另一个进程腾地方,二是你刚删掉一个大模型、想确认显存确实还回去了。日常训练不要碰它。
4.3 batch size 怎么估:一个能落地的算法
显存估算这件事,与其瞎试,不如按公式推一遍再验证。以 Adam 优化器 + float32 训练为例,显存主要由四部分构成:
| 组成 | 计算方式 | 说明 |
|---|---|---|
| 参数 | P × 4 B | P 是参数个数 |
| 梯度 | P × 4 B | 与参数同规模 |
| 优化器状态 | P × 8 B | Adam 的一阶、二阶动量各 4 B |
| 激活值 | 与 batch、序列长度、层数相关 | 最难估,通常是大头 |
固定部分好算:一个 1000 万参数的模型,参数 40 MB,梯度 40 MB,优化器状态 80 MB,加起来 160 MB,这部分和你 batch 设多大无关。
激活值难算,是因为它正比于 batch size,而且前向过程中间结果要留着给反向用。经验上,卷积网络里激活值占用的量级可以用“batch_size × 单个样本的中间特征总元素数 × 4 B”粗估,再乘一个 1.3 到 1.5 的系数应对碎片和临时缓冲。更实用的办法是:先用一个肯定能跑通的小 batch 跑一步,读max_memory_allocated,再按线性关系外推,留 20% 余量。
torch.cuda.reset_peak_memory_stats() # ... 跑一步完整的 forward + backward + step ... peak = torch.cuda.max_memory_allocated() / 1024**3 print(f"单步峰值 {peak:.2f} GB,可用 {torch.cuda.get_device_properties(0).total_memory/1024**3:.2f} GB")注意:
max_memory_allocated统计的是当前进程,别的程序占用的显存看不到。估完了还是 OOM,先用状态查询命令看一眼卡上是不是有别的进程。
另外提醒一句,模型里如果有大量中间结果被存进 Python 列表、字典或者被闭包捕获,那部分显存不会释放,会随 step 数线性增长。这种情况只要 batch 一小就能暴露出来:batch 减半后显存还是越跑越高,基本可以确定是泄漏,不是配置问题。
4.4 混合精度:一次把显存和带宽都省下来
如果显存卡在临界点,混合精度是性价比最高的一招。核心思路是前向和反向用 float16 算,主权重和优化器状态保持 float32,避免梯度下溢。代码骨架:
scaler = torch.amp.GradScaler("cuda") for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda", dtype=torch.float16): out = model(x) loss = criterion(out, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler干的事是给损失乘一个动态系数,把 float16 表示不了的小梯度放大,更新前再除回来。不写这一步,训练很容易在某几个 step 之后梯度全变 0,loss 卡住不动,而且不报错——这是混合精度最阴的坑。
版本上有差异:老版本写法是torch.cuda.amp.autocast和torch.cuda.amp.GradScaler,新版本统一成了torch.amp.*并强制传设备参数。抄代码前先看一眼自己的版本,报TypeError: __init__() missing 1 required positional argument基本都是这个原因。
收益方面,显存能省三到四成,吞吐提升常见在 1.5 到 2 倍。但要注意:不是所有算子都适合半精度。数值范围敏感的损失(比如涉及 exp、log 的)、小 batch 的批归一化统计,都可能出问题。第一次上混合精度,务必加一段小规模对照,跑几百步看 loss 曲线和 fp32 基线是否吻合。
5. 常见报错与排查实录
5.1 CUDA out of memory:按顺序排查,别上来就改 batch
OOM 是最常见的报错,也是最容易查错的。我看到的大部分情况,第一反应都是“减 batch”,减了发现还爆,就慌了。按下面的顺序走,命中率很高:
| 顺序 | 检查项 | 判断依据 | 处理方式 |
|---|---|---|---|
| 1 | 卡上是否被别的进程占着 | 状态查询能看到其他进程 | 结束占用进程或换卡 |
| 2 | 验证/测试阶段是否漏了no_grad | 训练正常,验证爆显存 | 用with torch.no_grad():包住 |
| 3 | 是否有张量被意外持有 | batch 减半后仍逐步上涨 | 用.item()取标量,别存 loss 张量 |
| 4 | batch 本身太大 | 峰值稳定但不留余量 | 减半试,或开梯度累积 |
| 5 | 内存碎片 | 总量够但分配失败 | 重启进程,必要时设分配器配置 |
| 6 | 显存足够但报错在特定算子 | 单步峰值有尖峰 | 拆分大算子,分块计算 |
重点说第 3 条,因为它最隐蔽。下面这种写法非常常见:
losses = [] for x, y in loader: loss = criterion(model(x), y) losses.append(loss) # 每个 loss 都带着整个计算图! loss.backward()loss是一个带grad_fn的张量,它的计算图引用着中间激活值。你把它存进列表,等于把每一步的计算图都钉在显存里,step 越多占用越高,最后必然爆。改法是losses.append(loss.item()),只留一个 float。同理,日志里打印指标一律用.item()。
第 2 条也值得展开。验证阶段只做前向,但如果你忘了no_grad,框架会默默地把整条计算图建起来,显存占用和训练时差不多,甚至更高(因为 batch 往往更大)。加上@torch.no_grad()装饰器或者with上下文,显存立刻降下来,速度也能快两成。
5.2 device mismatch:把设备当成一个贯穿全局的约定
典型的报错长这样:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!这句话的字面意思很清楚,难的是定位是哪一个张量没跟上。常见来源我按出现频率排一下:
- 模型搬了,数据没搬。
model.to(device)写了,x、y忘了。 - 损失函数里混进了 CPU 张量。比如手动算了个权重矩阵、从 numpy 转过来的 mask、
torch.tensor(某个数)没指定 device。 - 创建张量时没给 device。
torch.zeros(...)、torch.arange(...)默认在 CPU,写的时候顺手加device=device。 - 从 checkpoint 加载时没指定映射。
torch.load(path)会把张量还原到保存时的设备上,用torch.load(path, map_location=device)一次解决。 - 多个模块设备不一致。主模型在 0 号卡,某个手工创建的辅助模块还在 CPU。
定位技巧:在报错的那一行前面,把所有参与运算的张量设备打印一遍。
print([t.device for t in (x, y, w, b)])如果是模型内部报错,从模型的第一个参数取设备,和输入比一比:
model_device = next(model.parameters()).device print(model_device, x.device)根治的办法是让所有创建张量的地方都显式带device,并且用一个项目级的device变量。这看起来啰嗦,但它把“设备”从隐式约定变成显式契约,后面加功能不容易出岔子。
5.3 关于.to()与优化器顺序的一个历史包袱
网上流传一个说法:“model.to(device)必须在创建优化器之前,否则优化器会失效”。这个说法来自很老的版本,当时.cuda()会重建参数对象。现在的实现是原地替换参数的存储,优化器的引用仍然有效,顺序无所谓。
但有一种情况确实要重建优化器:你写了model = model.to(device)这种重新赋值。如果to()返回的是同一对象,那没问题;但如果你自己写了model = MyModel().to(device)而优化器是基于旧对象建的,那优化器里持有的参数引用和模型里的新参数就对不上了,表现为训练完全不收敛、loss 一动不动,还不报错。
稳妥写法:先
model = MyModel().to(device),再optimizer = torch.optim.Adam(model.parameters(), lr=...)。这个顺序在任何版本下都不会出问题,别为了省一行去赌。
同样的道理适用于学习率调度器、EMA 之类的辅助对象,它们都持有参数引用,创建时机放在模型彻底就位之后。
5.4 一份贴在显示器边上的速查表
| 现象 | 最可能的原因 | 快速验证 | 处理 |
|---|---|---|---|
| 加速比只有 1.x 倍 | 测的是端到端,搬运占大头 | 分别测纯计算和端到端 | 增大批量,减少往返 |
| 计时结果只有几微秒 | 异步未同步 | 加torch.cuda.synchronize() | 用 CUDA Event 计时 |
| 第一次调用特别慢 | 内核加载、池初始化 | 对比首次和第二次 | 加预热轮次 |
| 显存持续增长 | 计算图被持有 / 指标存了张量 | 看memory_allocated曲线 | 用.item(),加no_grad |
| 总量够但分配失败 | 碎片化 | 重启后立即跑是否正常 | 重启进程,调 batch |
empty_cache后更慢 | 池子被反复清空 | 注释掉对比 | 移除循环内的调用 |
| 混合精度 loss 卡住 | 梯度下溢 | 关掉 autocast 对比 | 用 GradScaler 正确缩放 |
| 非连续张量报 stride 错 | 转置/切片后直接送算子 | 打印is_contiguous() | 加.contiguous() |
| 加载模型后全在 CPU | torch.load默认还原原设备 | 打印参数设备 | 加map_location |
最后再分享一个我一直在用的习惯:在训练脚本入口处加一段设备自检,把设备类型、名称、显存总量、当前进程已用显存打印出来,同时assert一下device.type和你预期的是否一致。这段代码不到十行,但它能在你跑了两小时发现结果不对的时候,第一时间告诉你“兄弟,你其实一直在 CPU 上跑”。踩过两次这种坑之后,我把它做成了所有项目的模板。