detach与clone:为什么保存的张量还会变
把张量detach后放进列表,后来记录里的数值仍然变化,先检查共享存储。detach切断当前计算图关系,不会自动复制数据;clone复制数据,却仍然可以参与梯度传播。要保存一份不受原张量后续修改影响、也不继续连接原计算图的数值记录,通常需要同时解决这两个问题。
文章目录
这里回答“detach和clone有什么区别,为什么detach不是快照”,用存储修改和停止梯度两个互相独立的反例解释。示例不会把Python数组冒充PyTorch执行,也不会把停止梯度后的目标函数当作原函数继续求导。Read in English。
先画两条关系,不只看requires_grad
PyTorch 2.8的detach定义明确指出,返回张量脱离当前图、不需要梯度,但与原张量共享存储;任意一侧的原地修改都可能被另一侧看到,也可能触发正确性检查错误。新的Tensor对象不等于新的底层数据。
clone定义则返回副本,同时该操作可微,梯度仍能流回输入。这两项定义应画成两条线:一条连接计算图上的依赖,一条连接底层存储。detach处理前一条,clone处理后一条,不应根据一个requires_grad值推断另一条线已经断开。
这篇只讨论普通实数张量的数值与梯度关系,不涉及所有特殊布局、设备同步和序列化实现。给张量换一个变量名、复制一个保存它的Python容器,也都不能单凭对象数量证明已经得到独立数值。
保存对象引用,不等于保存当时的值
假设生产者不断复用一块缓冲区,将中间结果写入其中。消费者每一步保存一个脱离图的视图或别名,稍后才把记录写到磁盘。如果底层缓冲区继续被覆盖,消费者看到的可能是较新的值,而不是保存引用那一刻的值。是否记录梯度,与缓冲区是否复用是两项独立条件。
下面用Python的array和memoryview做存储类比:source初值为[2,6],shared指向相同数据,snapshot单独复制。把source第一项改成9后,shared也读到9;再经shared把第二项改成12,source也变化。snapshot始终保持[2,6]。这验证的是这段Python示例的别名行为,PyTorch语义由上面的官方定义支持。
因此日志、可视化缓存或保存的目标值应先定义所有权:它们是继续追踪当前缓冲区,还是冻结当时的数据?只要需求是后者,就需要独立存储或完成等价的持久化,而不是只停止梯度。实际快照还要考虑异步设备操作是否完成,这个标准库例子没有验证CUDA流或跨设备拷贝。
clone为什么还会把梯度传回去
复制数值不需要改变数值函数。例如y=3x,再令z是y的可微复制,z在数学上仍等于3x。取L=z²,则dL/dx=2z×3,在x=2时为36。新增一份存储并不让链式法则自动失效。
这解释了“为了防止原地覆盖而clone”和“为了不训练上游而detach”为什么不能互换。前者是数据保存或所有权需求,后者是优化目标的一部分。若上游本应参与训练,只因担心共享存储而detach,会改变梯度路径;若只clone来冻结教师目标,又可能保留本来不希望出现的反向路径。
需要两项条件时,可以明确使用detach后clone的组合:先形成脱离原图的张量,再复制出独立数据。这里的目的只是在当前关系上断图与复制,不表示新值以后永远不可能参与另一个计算图,也不意味着它被强制变成只读对象。
停止梯度,改变的是哪一个导数
取x₀=2,目标t=3x₀=6,比较L=(x−t)²。若在这一轮求导中把t视为固定目标,dL/dx=2(x−t),在x₀处为−8。若把t也写作随x变化的3x,则函数成为(x−3x)²=4x²,导数在同一点为16。
严格说,固定t对应把t作为独立参数后的偏导,而随动t对应复合函数的全导数。两条路径在x₀的损失都是16,梯度方向却相反。损失值相同,不能证明优化过程相同。停止梯度本身不是简单的数值错误修复;它告诉求导系统,当前目标的这条生成路径不参与这一轮梯度。
有限差分对照尤其容易写错。若想验证把t固定后的导数,计算x₀+h和x₀−h时都应使用同一个t=6;若每次扰动都重新算t=3x,得到的就是另一个函数的导数。下面分别验证−8与16,避免拿后者去指责前者的反向结果。
再看一步更新:从x=2按固定目标梯度−8、步长0.1走到x=2.8,对旧目标6的损失从16降到10.24;若下一步重新生成目标3x=8.4,同一表达式的数值却是31.36。它并不矛盾,两次检查的目标不同。停止梯度可以是合理设计,但不能据此承诺每轮重新生成目标后的损失单调下降。
十项检查把存储与数学分开
标准库代码先验证数组别名与副本,再对两个标量目标做中心差分。最后检查可微复制对应的代数关系z=3x。当前环境未安装PyTorch,没有执行张量autograd、模型训练或设备测试。
# Python storage analogy and finite differences, not a PyTorch runtime test.
from array import array
from math import isclose
source=array('d',[2.,6.])
shared=memoryview(source)
snapshot=array('d',source)
assert list(shared) == list(snapshot) == [2.,6.]
source[0]=9.
assert shared[0] == 9.
assert snapshot[0] == 2.
shared[1]=12.
assert source[1] == 12.
assert snapshot[1] == 6.
x0=2.; fixed_target=3*x0
frozen=lambda x:(x-fixed_target)**2
moving=lambda x:(x-3*x)**2
def derivative(f,x,h=1e-5):
return (f(x+h)-f(x-h))/(2*h)
assert frozen(x0) == moving(x0) == 16.
assert isclose(derivative(frozen,x0),-8.,rel_tol=1e-8)
assert isclose(derivative(moving,x0),16.,rel_tol=1e-8)
# Independent storage alone does not alter the algebraic identity z=3*x.
cloned_expression=lambda x:(3*x)**2
assert isclose(derivative(cloned_expression,x0),36.,rel_tol=1e-8)
assert list(snapshot) == [2.,6.] and list(source) == [9.,12.]
print('10 storage and derivative checks passed')
有限差分这里只是简洁的标量核验,不是对任意模型的梯度检查器。真实模型要控制随机性、可微条件与数值精度,再比较项目实际需要的梯度路径。存储部分也没有模拟PyTorch版本计数或原地修改错误,只展示为何“同一数据的另一个对象”仍会变化。
原地错误不能靠detach一概消除
Autograd机制说明讨论了原地修改与版本计数:反向可能需要前向保存的值,修改这些值会影响正确性检查。因此,detach得到的别名并不是修改原数据而不影响求导的安全通道。
若遇到原地修改错误,先定位谁写了哪块存储、反向是否仍需要原值,再决定复制、调整计算顺序或改变设计。不能先把detach当作“关闭报错”开关。也不要通过绕开检查来证明导数正确;需要对目标函数和实际数据依赖给出正面证据。
这和此前的view与reshape存储分析可以连起来看:形状操作可能保留别名,断图操作也可能保留别名。对象名字、形状、是否需要梯度,分别不足以描述完整的存储生命周期。
把“记录”和“训练”写成两个验收条件
为日志或缓存写测试时,先保存数值记录,再有控制地修改生产者缓冲区,确认记录是否按设计变化。为训练写测试时,单独列出哪些参数应接收梯度、目标分支是否固定,以及对应的数学导数。一个测试不该用来代替另一个。
还应记录快照频率与保存范围:独立副本有实际存储成本,复制全部中间张量并不天然合适。只有少量统计量时,可以设计更小的记录;若需要复现完整数值,就明确保存完整数据。这里不提供未经测量的显存或耗时收益承诺。
如果这些别名反例与导数对照帮你定位了“记录会变”或梯度方向不对的问题,欢迎通过文末支持区自愿支持。能复用的成果是把存储所有权与优化目标分开验证的方法。
来源与补发说明
detach · clone · Autograd mechanics
按原计划时间前已有的PyTorch 2.8版本定义核对。示例、推导和图示为原创教学内容,执行的是标准库存储与有限差分检查,没有运行PyTorch。本次补发保留10月21日09:00北京时间文章日期,实际公开时间另行记录。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


