☰
PyTorch张量复制详解:clone、detach与copy_的实战避坑指南
2026/10/13 19:21:11 网站建设 项目流程

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 三者的选择决策树

实际写代码时,可以按这个顺序问自己:

  1. 我需要独立内存吗?不需要就用detach()或视图。
  2. 需要梯度吗?需要就用clone(),不需要就用detach().clone()。
  3. 我是要覆盖一个已有张量的内容吗?是就用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()) # False

a.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,追根溯源都和对内存模型的理解不到位有关。把复制这一块吃透,是跨过新手期的一道坎。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询