我第一次把训练脚本从 CPU 挪到显卡上跑,是很多年前的事了。当时满心以为改一行.cuda()就能看到几十倍的提速,结果跑完一个 epoch,计时器上的数字比 CPU 版本还难看。后来花了整整一个下午才发现,问题根本不在显卡,而在我根本没搞清楚 PyTorch 里 Tensor(张量)和 GPU 加速之间的关系:哪些操作真的被搬到显卡上执行了,哪些数据还老老实实躺在内存里被反复搬运,哪些地方因为一个小小的设备不匹配在偷偷做隐式拷贝。这件事之后我养成了一个习惯——凡是涉及device的代码,一律当成一等公民来对待,而不是随手加个.to("cuda")了事。
这篇内容聊的就是 PyTorch 数据结构里最基础也最容易被轻视的一环:Tensor 的 GPU 加速。它不是什么高深话题,但真正把它用明白的人并不多。适合刚接触 PyTorch、想把模型跑到显卡上的新手,也适合已经能跑通训练、但总是被显存溢出和设备报错折腾的中级使用者。我会从“为什么快”讲到“怎么搬”,再讲到“显存这本账怎么算”和“精度怎么配”,最后给一段可复现的基准测试写法。全程都是我自己踩过坑之后总结的东西,能直接抄去用。
1. 先把“为什么快”这笔账算清楚
1.1 显卡不是“更快的 CPU”,而是“宽得多的 CPU”
很多人对 GPU 加速的第一印象是“显卡主频高”。恰恰相反,一张中端显卡的单核频率往往还不如同时代的桌面 CPU。它快的地方在别处:CPU 可能只有十几个核心,而显卡上有几千个计算单元。你可以把 CPU 想象成一个十几个人的精英小队,每个人都能独当一面、能处理复杂分支判断;显卡则是几千人的流水线工人,每个人只会做极简单的加减乘除,但胜在人多、同时开工。
这个差异直接决定了什么样的任务适合丢给显卡。矩阵乘法之所以是深度学习的核心,就是因为它天然可以拆成海量的、彼此独立的乘加运算——正好是流水线工人最擅长的活。而像if-else密集的逻辑、递归、字符串处理这类需要“聪明人”的活,显卡反而干得很差。我在实际项目里见过有人把数据预处理的一整段 Python 逻辑塞进显卡上跑,结果比 CPU 慢三倍,原因就在这:那段逻辑里全是条件分支,几千个工人挤在一起互相等,还不如十几个精英各干各的。
PyTorch 的 Tensor 之所以能吃到这个红利,是因为它把“数据”和“在哪个设备上算”绑定在了一起。一个张量带着自己的device属性,运算时框架会把整个 kernel 派发到对应设备上。所以 GPU 加速的本质不是“这段代码变快了”,而是“这段代码被换了一个执行引擎”。搞清楚这个前提,后面所有的坑才有解释的落脚点。
1.2 决定加速上限的两个硬指标:算力和带宽
真正限制你能拿到多少加速比的,是两个数字:浮点算力和显存带宽。前者决定“单位时间能做多少次乘加”,后者决定“单位时间能喂多少数据进去”。这两者经常是一起看的,因为深度学习 workload 大多不是纯计算瓶颈,而是“算一会儿、读一会儿数据”的混合型。
举个具体的估算例子。假设你手上一张 12GB 显存级别的消费卡,标称的 FP32 算力在十几到二十几 TFLOPS 这个量级,显存带宽在几百 GB/s 这个量级。而你机器上的 CPU,配上双通道内存,带宽大概在几十 GB/s。也就是说,光是把数据从显存搬到计算单元这件事,显卡的通道宽度就是 CPU 内存通道的好几倍。这就是为什么哪怕是一个纯内存搬运型的操作(比如大张量的逐元素加法),显卡也能快出好几倍。
但反过来,这个估算也告诉你加速比的天花板在哪。如果你的操作是“读一个数、算一下、写回去”,那它就是个带宽受限的操作,加速比基本被带宽比例锁死;如果是大矩阵相乘这种“读少量数据、做大量计算”的,才能逼近算力比例。我一般在做性能预估时,会先问自己一句:这个操作是算得多还是搬得多?答案不同,预期就该不同。
还有一个容易被忽略的约束:显存容量。算力和带宽决定“多快”,容量决定“能不能跑”。显存不够就是直接 OOM,没有降级方案。所以真正做项目时,我往往先看显存够不够,再看速度。这个顺序搞反了,会浪费大量时间在优化一个根本跑不起来的方案上。
1.3 有些张量操作天生吃不到加速红利
这是新手最容易产生的误解:以为张量一旦搬到显卡上,所有操作都会变快。事实是,一批操作在显卡上反而更慢。
第一类是极小的张量。一个形状为(4, 4)的矩阵乘法,在 CPU 上可能只要一两微秒,在显卡上却要付出一次 kernel 启动的固定开销,这个开销本身就有几微秒量级,加上主机到设备的调度延迟,整体反而更慢。我做过一个粗糙的对照:对 1000 个 8×8 的小矩阵做批量运算,放显卡上跑的时间是 CPU 的两倍多。这类场景的正确做法是尽量把小张量合并成大张量批量处理,而不是逐个丢给显卡。
第二类是频繁的主机与设备之间来回搬运。每一次.cpu()和.to("cuda")都是一次跨总线的拷贝,开销远高于张量本身的计算。我见过最典型的反面写法是在训练循环里对每个 batch 的每个字段单独.to("cuda"),一路搬十几次;改成在数据加载阶段一次性搬到显卡上,整体吞吐直接涨了一截。
第三类是强依赖 Python 控制流的操作。张量在显卡上算,但for循环、.item()取值、if判断这些是在主机侧执行的。每次.item()都会强制同步,把显卡的异步执行管线打断。这个坑我在写评估指标统计时踩过:循环里每算一个 batch 就.item()一次累加到 Python 变量,最后发现显卡利用率只有三成。改成在显卡上累加、最后一次性取回,问题就没了。
2. 把张量搬到显卡上的几条路径,代价各不同
2.1 四种写法看起来一样,语义上差得远
在 PyTorch 里把张量送上显卡,至少有四种常见写法:x.to("cuda")、x.cuda()、x.to(torch.device("cuda:0"))、torch.tensor(data, device="cuda")。新手往往随机挑一个用,实际上它们的差别不小。
x.cuda()是最老派的写法,硬编码了“去显卡”这件事,代码里到处是它,将来想切回 CPU 或者切到第二张卡就得全局替换。x.to("cuda")稍微好一点,字符串形式仍然硬编码了设备名。x.to(torch.device("cuda:0"))是我推荐的写法,设备对象可以当参数传、可以在配置里读、可以按环境切换,这是写出设备无关代码的基础。
至于torch.tensor(data, device=...),它是在创建时就直接在目标设备上分配,省掉了一次“先在 CPU 上建、再搬过去”的过程。这个差别在小数据上无感,但在创建大张量时能省下实打实的时间和一次峰值内存占用——因为“先建再搬”的瞬间,CPU 和显卡上会同时存在一份完整数据。我在处理大尺寸初始化(比如几亿元素的常数张量)时,一律用创建时就指定设备的方式。
还有一点必须强调:对 Tensor 调用.to()是返回新张量,不是原地修改。写x.to("cuda")而不用返回值接收,是新手最常见的“改了没生效”原因。而对nn.Module调用.to()则是原地修改模块内的参数,这个语义差异一定要记牢。
2.2 pin_memory 与非阻塞拷贝:让搬运和计算重叠起来
默认情况下,从主机内存到显存的拷贝是同步的,而且要求源内存是“可分页”的普通内存。这里有个隐藏成本:显卡的 DMA 引擎无法直接访问可分页内存,驱动需要先偷偷复制到一块临时的锁页缓冲区再传输。锁页内存(pinned memory)就是一块被锁定、不会被操作系统换出的内存,DMA 可以直接读。
于是就有了标准写法:DataLoader(..., pin_memory=True)配合tensor.to(device, non_blocking=True)。前者的作用是把 batch 数据放进锁页内存,后者是告诉框架“这个拷贝可以异步进行,别阻塞主机线程”。两者配合,就能实现“显卡在算当前 batch 的同时,下一个 batch 已经在路上”。
loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) for batch in loader: x = batch["x"].to(device, non_blocking=True) y = batch["y"].to(device, non_blocking=True) # 前向、反向这里有个我踩过的坑:non_blocking=True只在源内存是锁页内存时才真正异步。如果忘了设pin_memory=True,这个参数基本等于没写,甚至在某些情况下还会引发难以察觉的数据一致性问题——因为主机侧可能在拷贝完成前就修改了那块内存。所以这两个参数要么一起用,要么都别用,只写一半是很危险的组合。
另外,pin_memory=True不是免费的午餐。锁页内存会占用系统物理内存且不可换出,开太多 worker、batch 太大时,机器内存会被吃掉一大块。我一般会把 worker 数控制在 CPU 核心数的一半左右,batch 大小按显存反推,避免两边打架。
2.3 搬运时的形状、类型与连续性
设备搬家的同时,很多人还会顺手做类型转换,比如.to(device, dtype=torch.float16)。这个用法本身没问题,但要注意转换顺序带来的中间张量。.to()如果同时改变设备和精度,框架内部可能先生成一个中间结果再转,峰值显存会比预期高一些。
还有一个更隐蔽的点:内存连续性。经过transpose、permute、切片等操作之后,张量的逻辑形状和物理内存布局不再一致,此时它可能是“非连续”的。非连续张量搬到显卡上没问题,但某些底层 kernel 对连续性有要求,框架会在内部静默做一次.contiguous()拷贝。这个过程不报错、不打日志,但会额外吃一份显存。
我的处理习惯是:在数据管线的最后、送进模型之前,对确实需要连续性的张量显式调用.contiguous(),并且放在搬到显卡之前做。理由很实际——在主机内存上做连续性整理,出问题时更容易观察,而且不会在显卡上产生瞬时双份占用。等上了显卡再整理,一旦碰上显存吃紧的时刻,这一下就可能成为压垮的最后一根稻草。
2.4 一张表看清几种写法的适用场景
| 写法 | 是否原地修改 | 适用场景 | 主要风险 |
|---|---|---|---|
x.cuda() | 否,返回新张量 | 临时脚本、快速验证 | 硬编码设备,难以迁移 |
x.to("cuda") | 否 | 单卡固定环境 | 多卡下默认去 0 号卡,易踩坑 |
x.to(device_obj) | 否 | 正式项目、多卡、需切换 | 需要自己维护 device 变量 |
torch.tensor(data, device=...) | 不适用 | 大张量初始化 | 数据源仍需先存在于主机侧 |
module.to(device) | 是,原地改参数 | 模型迁移 | 之后再取参数引用可能失效 |
最后一行值得单独说一句。module.to(device)会原地替换模块内部的参数张量对象。如果你在调用之前保存了某个参数的引用(比如w = model.fc.weight),调用之后这个引用可能还指向旧对象。我在写自定义优化器逻辑时被这个坑过一次,表现是“优化器更新了一个已经不参与前向的参数”,loss 完全不动,排查了很久。稳妥的做法是:模型迁移设备的动作,放在构建优化器之前。
3. 把 device 当成一个变量来管理,而不是到处硬编码
3.1 一个 device 变量贯穿全流程
写过几个项目之后,我基本固定了一套写法:在程序入口处决定设备,然后把它作为参数往下传。判断逻辑也很固定——有可用显卡就用显卡,没有就退回 CPU,同时支持用环境变量强制指定。
import os import torch def pick_device(): forced = os.environ.get("FORCE_DEVICE") if forced: return torch.device(forced) if torch.cuda.is_available(): return torch.device("cuda:0") return torch.device("cpu") device = pick_device()这段代码看起来平淡,但它解决了一个非常实际的问题:同一份代码要在开发机、训练机、别人的机器上都能跑。硬编码.cuda()的脚本,一旦拿到没有可用显卡的环境上,会在第一行就崩掉,而且是那种带着长长堆栈的崩溃,对不熟悉的人非常不友好。
设备变量定下来之后,所有张量创建、模型迁移、损失计算、指标统计,全部围绕它走。我在 code review 时有个简单的检查标准:搜索整个仓库里.cuda()出现的次数,除了极少数确实需要指定具体卡号的地方,其他都应该为零。
3.2 多卡环境下“默认 0 号卡”的隐性坑
x.to("cuda")和x.cuda()在不指定编号时,都会去当前默认设备。这个默认设备可以通过torch.cuda.set_device()改变,也可能被外部框架改掉。于是就有了那种“明明指定了第二张卡,显存却还是溢出在第一张卡上”的诡异现象。
更麻烦的是张量与模型不在同一张卡上时的报错。典型信息长这样:Expected all tensors to be on the same device, but found at least two devices, cuda:1 and cuda:0!。看到这个报错,第一反应不该是去改那行报错的代码,而应该顺着数据流往上找:哪一步用了字符串形式的"cuda"而没带编号。
我的经验是,只要项目有可能用到多卡,从第一天起就全部用带编号的设备对象,绝不写裸的"cuda"。这条规矩看着死板,但省下的排查时间非常可观。尤其是当代码里混用了第三方库、每个库对默认设备的处理还不完全一致时,显式编号几乎是唯一可靠的做法。
3.3 加载权重时的设备错位
另一个高频场景是加载保存的权重。在显卡上训练、保存,然后在只有 CPU 的机器上加载,如果不做处理,会直接报“试图在无可用显卡的环境上反序列化显卡张量”的错误。
标准解法是torch.load(path, map_location=device)。map_location参数会在反序列化时把张量重映射到指定设备,避免“先建在显卡上再搬”的中间过程。它还可以接受一个函数,做更精细的逐张量映射,不过日常项目里直接传设备对象就够了。
这里有个细节值得注意:加载完之后,权重的设备由map_location决定,而模型的设备由model.to(device)决定,这两件事是独立的。我见过有人只做了一半,结果load_state_dict报错说键对不上——其实不是键对不上,是两边设备不一致导致的加载失败被包装成了别的错误信息。稳妥顺序是:先建模型、先model.to(device)、再加载权重。
另外还有一个便利写法:model.load_state_dict(state)之后如果想验证设备是否一致,可以取任意一个参数看它的.device,比逐个检查省事得多。我在每个训练脚本的启动阶段都会打一行日志,把模型参数的设备、dtype 和数量打出来,出问题时这行日志往往能直接定位。
4. 显存这本账:先会算,再会查
4.1 一个张量到底吃多少显存
显存占用的算法很朴素:元素个数 × 每个元素的字节数。float32 是 4 字节,float16 和 bfloat16 都是 2 字节,int64 是 8 字节,int32 是 4 字节,bool 是 1 字节。一个形状为(1024, 1024)的 float32 张量,元素数约 104 万,占用约 4MB。看着不多,但训练时的显存大头从来不是单个张量。
真正的账要这么算。假设模型有 1000 万个参数,用 float32 训练:
| 项目 | 计算方式 | 占用 |
|---|---|---|
| 模型参数 | 1e7 × 4B | 约 40MB |
| 梯度 | 与参数同尺寸 | 约 40MB |
| 优化器一阶动量 | 与参数同尺寸 | 约 40MB |
| 优化器二阶动量 | 与参数同尺寸 | 约 40MB |
| 中间激活值 | 与 batch、序列长度、层数强相关 | 常常是最大项 |
也就是说,光是参数加优化器状态,就已经是参数本身的四倍。而真正的显存杀手是激活值——它跟 batch size 和输入长度是线性关系,而且在前向过程中要保留下来给反向用。这就解释了为什么“模型参数量不大但就是 OOM”:问题往往出在 batch 或输入尺寸上,而不是模型本身。
我一般在动手调参之前,先按上表粗算一遍,得出“理论下限”,然后用实际运行时的占用去对比。两者差距过大,说明有额外的浪费;差距过小,说明显存已经绷得很紧,该考虑梯度累积或者降低 batch 了。
4.2 allocated 和 reserved 是两回事
PyTorch 的缓存分配器有两个关键指标:torch.cuda.memory_allocated()和torch.cuda.memory_reserved()。前者是当前真正被张量占用的字节数,后者是分配器向驱动申请下来、留在自己池子里的总字节数。
这个区别非常重要。我第一次看到reserved远大于allocated时,以为是显存泄漏,折腾了半天各种del,最后才明白:张量被释放后,分配器并不会立刻把内存还给驱动,而是留在池子里等下次复用。这是性能优化——避免频繁向驱动申请和归还。所以reserved高但allocated低,是完全正常的状态,不需要处理。
真正需要警惕的是allocated随迭代单调上升。这种情况通常意味着有张量被意外持有,比如:
- 在列表里持续
append中间结果而没有清理; - 在计算图里保留了带梯度的张量,导致整条计算图无法释放(典型如把 loss 累加进一个列表);
- 闭包或全局变量意外引用了大张量。
排查时我会在训练循环里每隔若干步打一次allocated和reserved,画个简单曲线,上升趋势一眼就能看出来。这个方法比任何静态分析都管用。
4.3 empty_cache 不是万能药,用错反而更慢
torch.cuda.empty_cache()的作用是把分配器池子里空闲的缓存还给驱动。注意“空闲”两个字:它不会释放任何正在被张量使用的显存。所以那种“OOM 了就调一下 empty_cache”的做法,绝大多数情况下毫无效果。
更要命的是性能代价。释放缓存之后,下一次需要显存时又得重新向驱动申请,这个往返有成本。如果在训练循环里每步都调一次,整体速度会明显下降。我在一个项目里见过有人在每个 batch 末尾都调,训练时间凭空多了两成。
我的使用原则很窄:只在“加载大模型之前”“切换任务之前”“确实观察到 reserved 长期远大于 allocated 且即将触发 OOM”这三种情况下调用。日常训练循环里,一次都不该出现。
顺便说一下碎片问题。长时间训练、反复分配不同尺寸的张量,会让缓存池出现大量小空洞,总空闲显存够但找不到连续的大块,于是 OOM。缓解手段包括:尽量固定 batch 形状(避免动态尺寸)、把不同尺寸的张量分配集中在训练开始前完成、以及在极端情况下重启进程。真正做长周期训练时,我会把输入尺寸固定住,就是为了规避碎片。
4.4 一次真实的显存增长排查链路
说个具体的。有次训练跑了大概两千步之后 OOM,前两千步一切正常,显存曲线缓慢上升。
第一步,我打印了allocated和reserved的差值,发现 reserved 基本平稳,allocated 在涨,说明确实是张量没释放,不是碎片。
第二步,检查训练循环里所有append和字典写入。发现指标统计那里写成了losses.append(loss)——而loss是带计算图的张量。这个列表持有 loss,loss 持有整条反向图,于是每一步的计算图都没被释放。改成losses.append(loss.item())之后,曲线立刻变平。
第三步,顺手做了个优化:.item()每次调用都会触发一次设备同步,在循环里频繁调用同样伤性能。所以最终改成在显卡上累加、循环结束统一取回:
total = torch.zeros((), device=device) for batch in loader: loss = compute_loss(batch) total += loss.detach() avg = (total / len(loader)).item()detach()是关键,它把张量从计算图上摘下来,不再持有反向所需的中间结果。这个写法既避免了显存泄漏,又避免了每步同步。
5. 精度与设备的组合,是报错最密集的地带
5.1 半精度的收益和边界
把张量从 float32 换成 float16 或 bfloat16,最直接的好处是显存占用减半、带宽需求减半。在带宽受限的操作上,这几乎等于免费提速。但半精度不是随便一转就完事。
float16 的动态范围窄,容易出现溢出或下溢;bfloat16 动态范围和 float32 一致但尾数少,精度略低但在训练中通常够用。我自己的选择习惯是:推理和存储用 float16,训练优先考虑 bfloat16(硬件支持的前提下)。这个取舍没有绝对答案,取决于具体任务对数值稳定性的敏感程度。
更重要的是,不能简单地把整个模型.half()了事。归一化层、损失计算、某些归约操作在半精度下容易出问题。稳妥的做法是用自动混合精度,让框架自己决定每个算子用什么精度,而不是手动一刀切。
scaler = torch.cuda.amp.GradScaler() for batch in loader: optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(dtype=torch.float16): out = model(batch["x"].to(device, non_blocking=True)) loss = criterion(out, batch["y"].to(device, non_blocking=True)) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是给损失乘一个缩放因子,避免梯度在半精度下下溢成零,更新参数前再除回来。这段代码我基本是原样复用的,唯一会调的是初始缩放因子——如果训练早期频繁出现“跳过更新”的警告,说明缩放因子在反复调整,可以考虑手动指定一个更合适的初值。
5.2 那些“只在显卡上出现”的类型问题
有一类报错很迷惑:同样的代码在 CPU 上跑得好好的,搬到显卡上就报类型不支持。常见原因有两个。
第一个是设备本身不支持某个 dtype。某些老一些的硬件对 bfloat16 的支持不完整,部分算子会退回或直接报错。这种情况下最省事的做法是先查一下当前设备的计算能力,再决定用哪种精度,而不是写死。
第二个是索引张量的类型。用张量做索引时(比如x[idx]),idx必须是整型。如果它是 float,CPU 上某些路径可能容忍,显卡上则直接报错。我在做数据增强时被这个坑过:一个坐标计算出来是浮点,忘了转long就去索引,CPU 上恰好没触发问题,显卡上每一步都崩。
第三个是设备不匹配。前面提过报错信息,这里补一个排查技巧:在报错位置的前一行打印所有相关张量的.device和.dtype。听起来笨,但比逐行推理快得多。我现在的习惯是在模型forward开头加一段只在调试模式下启用的断言:
if DEBUG: for name, t in {"x": x, "mask": mask}.items(): assert t.device == self.device, f"{name} on {t.device}"这几行代码上线时可以整体去掉,开发阶段能省掉大量来回。
5.3 常见报错与定位方向对照
| 报错关键词 | 最可能的原因 | 优先检查 |
|---|---|---|
found at least two devices | 张量分散在不同设备 | 是否有裸"cuda"未带编号 |
Torch not compiled with CUDA enabled | 安装的是纯 CPU 版本 | 环境里框架的构建版本 |
CUDA out of memory | 显存不足 | batch 大小、激活值、是否有泄漏 |
expected scalar type Half but found Float | 混精度的算子边界 | 归一化层、损失函数的输入 dtype |
index tensor must have dtype long | 索引用浮点 | 索引张量的.long()转换 |
这张表是我自己攒的,遇到报错先对一眼,能省掉不少搜索时间。需要说明的是,同一个报错可能有多种成因,表格给的是“最可能”,不是“一定”。真正定位还得靠打印和二分法。
6. 一段能复现的基准测试,以及加速比被稀释的真相
6.1 基准测试的三个必备动作
写性能对比代码时,有三个动作缺一不可,缺了任何一个,测出来的数字都不可信。
第一个是预热。显卡第一次执行某个 kernel 时要做上下文初始化、编译、加载,第一次的耗时往往是稳定状态的数倍。不预热直接计时,测到的是冷启动成本。
第二个是同步。显卡上的操作默认是异步派发的,主机侧发起调用后立刻返回,实际计算还在排队。如果不调torch.cuda.synchronize()就在主机侧读计时器,测到的只是“派发完成”的时间,不是“计算完成”的时间。
第三个是多次取中位数。单次测量受各种噪声影响,取多次的中位数比取平均更稳(平均值容易被个别异常值拉偏)。
import time import torch def bench(fn, warmup=10, repeat=50, device="cuda"): for _ in range(warmup): fn() if device.startswith("cuda"): torch.cuda.synchronize() start_evt = torch.cuda.Event(enable_timing=True) end_evt = torch.cuda.Event(enable_timing=True) times = [] for _ in range(repeat): start_evt.record() fn() end_evt.record() torch.cuda.synchronize() times.append(start_evt.elapsed_time(end_evt)) else: times = [] for _ in range(repeat): t0 = time.perf_counter() fn() times.append((time.perf_counter() - t0) * 1000) times.sort() return times[len(times) // 2]用事件计时比用time.perf_counter更准,因为它测的是显卡上的真实执行时间,不受主机侧调度抖动影响。这段代码我建议直接收进工具箱,后面所有性能对比都用它。
6.2 小张量上,显卡真的会输
拿矩阵乘法做对照,尺寸从 32 一路加到 4096,大致会看到这样的趋势:
| 矩阵尺寸 | CPU 相对耗时 | 显卡相对耗时 | 观察 |
|---|---|---|---|
| 32 × 32 | 1.0 | 3~5 | 显卡明显更慢 |
| 128 × 128 | 1.0 | 0.5 左右 | 大致打平 |
| 512 × 512 | 1.0 | 0.1 左右 | 显卡开始领先 |
| 2048 × 2048 | 1.0 | 0.02 以下 | 显卡碾压 |
(表中的倍数是相对值,具体数字随硬件差异很大,但趋势是稳定的。)
这个表解释了我开头那个“改到显卡上反而更慢”的经历。当时我的模型很小、batch 也很小,绝大部分时间花在 kernel 启动和跨设备搬运上,真正的计算量根本不值一提。这种情况下最优解可能压根不是显卡,而是把 CPU 上的并行度调好,或者干脆把小张量批量化。
6.3 端到端训练里的加速比,为什么总比基准测试低
很多人测完矩阵乘法,看到几十倍加速,兴冲冲去跑训练,结果端到端只快了三四倍。这不是显卡不行,而是被几个环节稀释了。
稀释点一:数据加载。如果数据预处理在 CPU 上做,显卡算得再快也得等。这时候要看的不是显卡利用率,而是数据加载的吞吐是否跟得上。开多 worker、把解码和增强做得更轻、或者把部分变换也搬到显卡上做(比如用显卡上的张量操作做归一化),都是常见手段。
稀释点二:主机与设备的往返。每个 batch 搬几次、有没有不必要的.cpu()取回、训练循环里有没有频繁.item(),这些都在偷偷吃掉加速收益。
稀释点三:同步点。日志打印、指标记录、学习率调度里如果有隐式或显式的同步操作,异步管线就被打断,显卡会有空转时间。
稀释点四:小算子过多。一层里几十个小算子,每个都要一次 kernel 启动,累加起来开销可观。这种情况下算子融合的收益比换硬件更直接。
我一般的排查顺序是:先看显卡利用率曲线,如果长期低于五成,基本可以断定瓶颈在数据侧;如果利用率高但加速比仍低,那就是算法本身的计算密度不够,属于“无解”,只能改方案。
6.4 两个容易被忽略的开关
最后补两个实践中的小开关。torch.backends.cudnn.benchmark = True会让框架在固定输入尺寸时自动搜一遍最快的卷积实现,代价是第一次会慢一点。输入尺寸固定、训练步数多的场景值得打开;输入尺寸每步都变的场景别开,搜索成本会把收益吃光。
另一个是矩阵乘法的精度模式。较新的硬件上默认会启用一种折中的浮点模式,用少量精度换显著速度。如果你的任务对数值非常敏感,可以显式关掉做对照实验;如果只是训练一个普通模型,保持默认通常没问题。这两个开关我都建议在正式训练前花十分钟做 A/B 对照,用数据决定开还是关,而不是凭感觉。
我个人在几个项目里反复验证下来,最值得投入时间的其实不是这些开关,而是前面那几件事:把设备管理收敛到一个变量、把数据搬运的路径理顺、把显存占用的账算清楚。这三件事做到位,加速比自然就上来了;这三件事没做好,再怎么调开关都是在漏水的桶里加水。至于具体的数字,不同硬件、不同模型差异太大,我更愿意在自己的机器上老老实实跑一遍基准,而不是照抄别人的结论——毕竟上一次照抄,我就多花了一个下午。