1. 从一次显存爆炸说起:为什么复制张量值得单独拎出来讲
刚接触 PyTorch 那会儿,我写过一段至今想起来都脸红的代码。当时在做一个图像分类的小实验,想把一个 batch 的数据做数据增强,于是很自然地写了这么一行:
augmented = original_tensor augmented[:, :, :, ::-1] = augmented[:, :, :, ::-1]本意是想把通道顺序翻转一下,结果跑完发现原始数据也被改了,而且更诡异的是,后面几个 epoch 的 loss 曲线完全乱套。排查了大半天才反应过来——augmented = original_tensor这行根本没有产生任何新内存,两个变量名指向的是同一块显存。我改的其实是原始张量本身。
这件事让我意识到,PyTorch 里的"复制"远不是一句=就能搞定的事。它牵扯到内存布局、计算图、梯度传播、设备迁移等一连串问题。用错了复制方式,轻则数据被意外篡改,重则显存翻倍、训练中断,甚至在你以为"只是复制一下"的地方悄悄断开了梯度。
这篇内容就是围绕 PyTorch 张量复制这件事展开的。我会把常见的几种复制方式掰开揉碎讲清楚:它们各自在内存层面做了什么、什么时候该用哪个、哪些坑我亲自踩过。不管你是刚学 PyTorch 的新手,还是已经能跑通模型但总在数据操作上翻车的同学,应该都能从里面找到点有用的东西。核心关键词就一个——张量复制,但围绕它展开的细节,比想象中要多得多。
2. 浅拷贝、深拷贝与视图:三种"复制"的本质区别
在讲具体 API 之前,得先把概念理清楚。很多人把"复制"当成一个动作,但在 PyTorch 里,它至少分成三个层次,理解这三层的差异,后面所有 API 的选择都会变得顺理成章。
2.1 赋值操作符:最容易被误认为复制的操作
b = a这行代码,在 Python 层面只是给同一个对象又绑了一个名字。对于 PyTorch 张量来说,a和b共享同一份底层存储(storage)、同一个data_ptr、同一套元信息(shape、stride、dtype)。你通过b做的任何原地修改,a都会同步变化。
验证方法很直接:
import torch a = torch.randn(3, 4) b = a print(a.data_ptr() == b.data_ptr()) # True b[0, 0] = 999 print(a[0, 0]) # tensor(999.)data_ptr()返回的是张量首元素的内存地址,两个变量地址相同,说明它们就是同一块内存。这是最"廉价"的操作,零开销,但也最危险,因为它伪装成了复制。
2.2 视图(view):共享存储但重新解释形状
视图比赋值稍微"高级"一点。它创建了一个新的张量对象,有自己独立的元信息(shape、stride),但底层存储还是原来那块。view()、reshape()(在可能的情况下)、transpose()、permute()、切片操作a[:, 1:]等,产生的都是视图。
a = torch.randn(4, 6) v = a.view(2, 12) print(a.data_ptr() == v.data_ptr()) # True v[0, 0] = 888 print(a[0, 0]) # tensor(888.)视图的价值在于省内存——你不需要为新的形状分配新空间。但代价是,对视图的修改会"穿透"到原张量。什么时候视图会失效?当新形状在内存上无法用原存储的 stride 表达时,view()会报错,这时就得用reshape(),而reshape()在无法返回视图时会悄悄退化成复制。这个"悄悄"是很多 bug 的源头,后面会专门讲。
2.3 真正的复制:独立存储的诞生
真正的复制会分配一块全新的内存,把数据搬过去。此后两个张量彻底独立,改谁都不影响谁。PyTorch 提供了好几个做这件事的 API,它们的差异不在"是否复制",而在"复制什么、怎么复制、复制后和计算图什么关系"。
| 操作 | 是否新存储 | 是否保留计算图 | 是否保留梯度关系 | 典型用途 |
|---|---|---|---|---|
b = a | 否 | 是(同一对象) | 是 | 别名,慎用 |
a.view(...) | 否 | 是 | 是 | 变形,省内存 |
a.clone() | 是 | 是 | 是 | 需要独立副本且要梯度 |
a.detach() | 否 | 否 | 否 | 切断梯度,共享存储 |
a.detach().clone() | 是 | 否 | 否 | 独立副本且不要梯度 |
a.copy_(b) | 否(原地) | 保持 a 的图 | 保持 a 的图 | 原地覆盖数据 |
这张表建议存下来,遇到"我到底该用哪个"的时候对照一下,基本能覆盖八成场景。
3. clone、detach、copy_ 三兄弟的实战选择逻辑
概念讲完了,落到代码上,最常打交道的其实就是clone()、detach()和copy_()这三个。它们名字看着都像复制,实际行为差别很大,选错了要么浪费显存,要么破坏梯度,要么改错对象。
3.1 clone:保留计算图的完整副本
clone()是最"正统"的复制。它分配新内存,把数据拷过去,同时在计算图里注册一个CloneBackward节点,保证梯度能正确回传。
a = torch.randn(3, 3, requires_grad=True) b = a.clone() c = (b ** 2).sum() c.backward() print(a.grad is not None) # True这里a.grad能拿到值,说明 clone 没有切断梯度链路。什么时候必须用 clone?当你需要一个和原张量数值相同、但后续修改互不干扰、且还要参与反向传播的副本时。比如在自定义损失函数里,你想保留一份原始预测值用于对比,又不想影响主分支的梯度,clone 就是首选。
但 clone 有个代价:它会把整块数据完整复制一遍。如果原张量很大,显存占用直接翻倍。我见过有人在训练循环里对每个 batch 的 feature map 都 clone 一份存起来做可视化,结果显存直接爆掉。这种场景其实应该用detach()配合 CPU 搬运,而不是无脑 clone。
3.2 detach:切断梯度但共享存储
detach()的行为经常被误解。它返回一个新张量,这个新张量和原张量共享同一块存储,但requires_grad变成 False,且和原计算图断开。
a = torch.randn(3, 3, requires_grad=True) d = a.detach() print(a.data_ptr() == d.data_ptr()) # True d[0, 0] = 777 print(a[0, 0]) # tensor(777., grad_fn=<...>)注意看,改d会改到a,因为它们共享存储。所以detach()单独用的时候,它不是复制,只是"换了个不需要梯度的视角看同一份数据"。很多人以为 detach 之后就安全了,结果原地修改时把原张量也改了,梯度计算出错。
那 detach 的正确用法是什么?典型场景是:你想把中间结果拿出来做 logging 或可视化,不希望这部分操作被记录进计算图。这时候detach()就够了,因为它不复制数据,开销极小。但如果你后续还要修改这份数据,就必须再补一个clone()。
3.3 copy_:原地覆盖,目标张量的图保持不变
copy_()是原地操作,它把源张量的数据写进目标张量已有的内存里,不分配新空间。关键在于:它保持目标张量的计算图身份不变。
target = torch.zeros(3, 3, requires_grad=True) source = torch.randn(3, 3) target.copy_(source) loss = target.sum() loss.backward() print(target.grad) # 有梯度这里target的requires_grad和它在图中的位置都没变,只是数值被覆盖了。这个特性在参数更新、EMA(指数移动平均)、模型权重加载等场景非常有用。比如手动实现 EMA 时,你会用ema_param.copy_(decay * ema_param + (1 - decay) * param)这样的写法,而不是重新赋值,就是为了保持优化器对参数的引用关系。
注意:
copy_()要求源和目标形状可广播,且 dtype 兼容。如果 dtype 不同,PyTorch 会尝试转换,但可能带来精度损失,跨 dtype 复制时最好显式.to(dtype)。
3.4 三者的选择决策树
实际写代码时,可以按这个顺序问自己:
- 我需要独立内存吗?不需要就用
detach()或视图。 - 需要梯度吗?需要就用
clone(),不需要就用detach().clone()。 - 我是要覆盖一个已有张量的内容吗?是就用
copy_()。
把这几个问题串起来,基本不会选错。我自己的习惯是:凡是涉及"保存一份以后可能改"的数据,一律clone();凡是"只是看看、不参与训练"的,一律detach();凡是"更新已有 buffer"的,一律copy_()。
4. 那些年我在复制上踩过的坑
光讲 API 不够,真正让人长记性的是踩坑。下面这几个坑,有的是我自己踩的,有的是帮别人排查时遇到的,都很典型。
4.1 reshape 悄悄退化成复制导致的内存翻倍
前面提过,reshape()在无法返回视图时会退化成复制。问题在于它不报错、不提示,你根本不知道内存被悄悄多占了一份。
a = torch.randn(4, 4) b = a.t() # 转置,非连续 c = b.reshape(-1) # 这里会复制,因为 b 非连续 print(a.data_ptr() == c.data_ptr()) # Falsea.t()之后张量在内存上不连续,reshape没法用 stride 表达,只能复制。如果这个操作在训练循环里反复执行,显存会持续增长。排查这类问题的办法是养成习惯:对可能非连续的张量,先.contiguous()再操作,或者直接用view()让它报错,逼自己确认连续性。
判断连续性的 API 是a.is_contiguous()。我现在的习惯是,在性能敏感的代码路径里,凡是变形操作前都加一句断言,宁可多写一行,也不要让隐式复制偷偷吃掉显存。
4.2 用 = 复制后原地修改污染原始数据
这个坑在数据预处理里最常见。比如你想对一批图像做归一化,写了:
normalized = images normalized = (normalized - mean) / std这行其实没问题,因为减法产生了新张量。但如果是:
normalized = images normalized -= mean normalized /= std用了原地操作符-=和/=,就会直接改掉images。如果images后面还要用,数据就被污染了。这类 bug 特别隐蔽,因为程序不报错,只是结果不对。
我的经验是:只要一个张量后面还要用,就绝不用原地操作符去改它。要么用clone()先复制,要么用非原地版本(-而不是-=)。多一次复制换来的是逻辑清晰,值。
4.3 detach 后原地修改引发的梯度异常
这个坑更隐蔽。有人为了省内存,用detach()拿到一个"不需要梯度"的副本,然后对这个副本做原地修改,以为万事大吉。结果因为共享存储,原张量被改了,而原张量还在计算图里,反向传播时梯度就对不上了。
a = torch.randn(3, 3, requires_grad=True) b = a.detach() b.mul_(2) # 原地修改,a 也变了 loss = (a ** 2).sum() loss.backward() # 梯度基于被改过的 a 计算,结果错误正确做法是b = a.detach().clone(),先断梯度再复制,之后随便改都不影响原张量。这个组合我用了无数次,几乎成了肌肉记忆。
4.4 跨设备复制时的隐式同步
a.to('cuda')看起来像复制,实际上如果a已经在目标设备上,它直接返回原张量,不复制。这个行为本身没问题,但如果你依赖"to 一定产生新张量"来做逻辑判断,就会出错。
更麻烦的是跨设备复制会触发同步。CPU 到 GPU 的拷贝是同步操作,会阻塞当前流。如果在训练循环里频繁做小张量的跨设备复制,性能会被拖垮。我的做法是尽量批量搬运,或者用non_blocking=True配合 pinned memory 做异步拷贝,把同步开销藏起来。
cpu_tensor = torch.randn(1000, 1000).pin_memory() gpu_tensor = cpu_tensor.to('cuda', non_blocking=True)pinned memory 是页锁定内存,GPU 可以直接访问,配合non_blocking能实现真正的异步传输。这个技巧在处理大规模数据加载时特别有用。
5. 复制与计算图:梯度到底跟不跟着走
复制操作和自动微分系统的交互,是很多人模糊的地带。搞清楚"梯度跟不跟着复制走",能避免大量反向传播时的诡异报错。
5.1 计算图是怎么记录复制操作的
PyTorch 的自动微分基于动态计算图。每个张量如果有requires_grad=True,它就是一个图节点,记录了产生它的操作(grad_fn)。当你做b = a.clone()时,b的grad_fn是CloneBackward,它知道自己的输入是a。反向传播时,梯度从b流回a。
而b = a.detach()产生的b,grad_fn是None,它不在图里。反向传播到b就停了,不会再往a传。这就是"切断梯度"的含义。
copy_()比较特殊,它是原地操作,不产生新的图节点。目标张量在图里的位置不变,只是数值被覆盖。所以反向传播时,梯度还是按原来的路径走,但计算用的是覆盖后的数值。
5.2 什么时候需要保留梯度,什么时候必须切断
判断标准很简单:这份复制出来的数据,是否需要参与反向传播。
需要保留梯度的场景:自定义损失函数里的中间变量、多分支网络里的特征融合、需要做梯度裁剪的中间结果。这些地方用clone()。
必须切断梯度的场景:目标网络的更新(如 DQN 里的 target network)、数据增强后的输入、用于 logging 的中间值、对抗样本生成时的扰动。这些地方用detach()。
有个容易搞混的点:数据增强后的输入通常要 detach。因为增强操作本身不可导,而且你也不希望梯度回传到增强前的原始数据上。但如果你用的是 PyTorch 内置的可微增强(比如某些基于插值的操作),那就另当别论。
5.3 一个真实的反例:target network 更新
强化学习里有个经典操作:定期把在线网络的参数复制到目标网络。新手常写成:
target_net.load_state_dict(online_net.state_dict())这行本身没问题,但state_dict()返回的是张量的引用,load_state_dict内部会做复制。问题在于,如果在线网络还在训练,而目标网络需要"冻结",就必须确保目标网络的参数requires_grad=False,且更新时不建立梯度关系。
更稳妥的写法是逐个参数copy_并配合detach:
for target_param, online_param in zip(target_net.parameters(), online_net.parameters()): target_param.data.copy_(online_param.data)用.data访问底层数据,绕过自动微分,再用copy_原地覆盖。这样目标网络彻底和计算图脱钩,不会因为误操作产生梯度。这个写法在早期 PyTorch 代码里很常见,现在虽然有了torch.no_grad()上下文管理器,但理解.data.copy_()的原理依然有价值。
6. 性能视角:复制操作的显存与速度账
复制不是免费的。在大模型时代,显存就是硬通货,每一次不必要的复制都可能成为压垮训练的最后一根稻草。这一节算算复制的账。
6.1 复制的显存开销怎么估算
一个张量的显存占用 = 元素个数 × 每个元素的字节数。float32 是 4 字节,float16 是 2 字节,int8 是 1 字节。
比如一个[64, 3, 224, 224]的 float32 图像 batch,元素个数是 64×3×224×224 ≈ 963 万,占用约 38.5 MB。clone 一份就是多占 38.5 MB。看着不多,但如果在网络中间层,feature map 动辄[64, 256, 56, 56],那就是 205 MB,clone 一次翻倍。
估算公式记住就行:显存(MB) = 元素数 × 字节数 / 1024 / 1024。养成习惯,在写 clone 之前先心算一下,能省不少事。
6.2 什么时候复制是必要的开销,什么时候是浪费
必要的复制:需要独立修改的数据、需要保留的中间状态、跨设备传输、参数更新。这些复制换来的是正确性和逻辑清晰,值得。
浪费的复制:为了"保险"而无脑 clone、在循环里反复复制不变的数据、用 clone 代替 detach、复制后马上又做原地操作。这些是纯粹的显存浪费。
我见过最离谱的一个例子:有人在 dataloader 里对每个样本都 clone 三次,理由是"怕被改"。结果 batch size 只能开到别人的三分之一。后来把 clone 去掉,只在真正需要的地方保留一次,显存立刻宽松了。
6.3 用 in-place 操作省显存的前提条件
原地操作(in-place)能省显存,因为它不分配新空间。但用它的前提是:这个张量后面不再需要原始值,且不破坏计算图。
ReLU 的inplace=True参数就是典型例子。它直接在输入上做修改,省掉一份中间结果。但如果你后面还要用 ReLU 之前的输入,就不能开 inplace。
x = torch.randn(10, 10, requires_grad=True) y = torch.relu(x) # 非原地,x 保留 z = torch.relu(x, inplace=True) # 原地,x 被改反向传播时,ReLU 需要知道哪些位置是负的。非原地版本保存了输出,原地版本则依赖被修改后的输入。PyTorch 内部对 inplace ReLU 做了特殊处理,能正确回传梯度,但前提是你没有在别处引用 ReLU 之前的x。一旦引用,梯度就可能出错。
我的建议是:默认用非原地,只在显存确实紧张且确认安全时才开 inplace。省下的那点显存,往往不值得冒梯度出错的风险。
7. 几个容易混淆的 API 对照与速查
PyTorch 里和复制沾边的 API 不少,名字还都挺像,这里做个集中对照,方便查阅。
7.1 clone 与 detach().clone() 的取舍
这两个的区别就一个:要不要梯度。clone()保留梯度链路,detach().clone()切断。选择标准前面讲过,这里补充一个细节:detach().clone()比clone()稍微快一点点,因为它不需要注册反向节点。在不需要梯度的场景下,用detach().clone()更划算。
但要注意,detach().clone()是两步操作,中间那个 detach 出来的张量是临时的,会被垃圾回收。如果你写成b = a.detach(); c = b.clone(),那b会一直存在,共享着a的存储,可能造成意外的内存驻留。所以尽量写成链式调用。
7.2 contiguous 与 clone 的关系
contiguous()在张量已经连续时直接返回原张量,不复制;在非连续时才复制一份连续的。所以它有时候是复制,有时候不是。如果你需要一个"一定独立且连续"的副本,应该用clone().contiguous()或者contiguous().clone(),顺序无所谓,但两步都要有。
a = torch.randn(4, 4).t() # 非连续 b = a.contiguous() # 复制,b 连续且独立 c = a.clone() # 复制,但 c 可能仍非连续clone()会保留原张量的 stride 信息,所以如果原张量非连续,clone 出来的也非连续。要连续就得再补contiguous()。
7.3 常用复制 API 速查表
| API | 新存储 | 保留梯度 | 保留连续性 | 典型场景 |
|---|---|---|---|---|
clone() | 是 | 是 | 保留原 stride | 需要梯度的独立副本 |
detach() | 否 | 否 | 保留 | 只看不改,省内存 |
detach().clone() | 是 | 否 | 保留 | 独立副本,不要梯度 |
contiguous() | 视情况 | 是 | 强制连续 | 变形前确保连续 |
copy_(src) | 否 | 保持目标 | 保持目标 | 原地更新 buffer |
to(device) | 视情况 | 是 | 保留 | 跨设备搬运 |
type(dtype) | 视情况 | 是 | 保留 | 类型转换 |
这张表我贴在显示器边上过一段时间,用多了就记住了。核心就一句话:先问要不要梯度,再问要不要独立内存,最后问要不要连续。
8. 把复制用对:一份可复用的检查清单
讲了这么多,最后落到实操上。每次写涉及复制的代码时,我会过一遍下面这个清单,基本能挡住九成的问题。
第一,这个张量后面还要用吗?要用的,绝不用原地操作符改它,要么 clone 要么用非原地版本。
第二,需要梯度吗?需要就clone(),不需要就detach().clone()。别用裸detach()然后原地改,那是给自己埋雷。
第三,需要连续内存吗?需要就在复制后补contiguous(),或者直接用contiguous()触发复制。
第四,跨设备了吗?跨设备复制是同步的,频繁小批量搬运会拖慢训练,尽量批量处理,必要时用 pinned memory 加non_blocking。
第五,显存够吗?clone 之前心算一下大小,大张量能省则省,用 detach 或视图代替。
第六,是在循环里吗?循环里的复制要特别小心,一次复制看着小,累积起来就是显存泄漏。检查有没有在循环里反复 clone 不变的数据。
这六条看着简单,但每一条背后都是真实的坑。我自己到现在写代码时还会下意识地过一遍,尤其是第一条和第二条,几乎成了条件反射。
复制这件事,说到底是 PyTorch 内存模型的一个缩影。理解了存储、视图、计算图这三者的关系,不光复制能用对,很多其他操作(切片、变形、索引)的坑也能一并避开。我个人的体会是,PyTorch 里大部分"莫名其妙"的 bug,追根溯源都和对内存模型的理解不到位有关。把复制这一块吃透,是跨过新手期的一道坎。