☰
深入PyTorch内部机制:Tensor、Autograd与算子调度实战
2026/10/1 4:23:08 网站建设 项目流程

1. 为什么值得花时间啃PyTorch内部机制

很多人用PyTorch的路径都差不多:跟着教程搭个CNN,跑通MNIST,然后开始调包训练自己的模型。能跑就行,谁管它里面怎么转的?我一开始也是这个心态,直到有次训练loss突然变成NaN,查了两天没找到原因,最后发现是自定义算子里的梯度计算写错了——那一刻我才意识到,不懂内部机制,出了问题连排查的方向都没有。

PyTorch表面上是个深度学习框架,但它的本质是一个带自动微分能力的张量计算库。Tensor、Storage、Autograd、算子这四个东西构成了它的骨架。你写的每一行loss.backward(),背后都是一整条动态计算图在运转;你创建的每一个torch.randn(3, 4),底层都对应着一块连续内存的Storage。理解这些,不是为了炫技,而是为了在遇到显存泄漏、梯度异常、算子性能瓶颈的时候,能快速定位到问题出在哪一层。

这篇内容适合两类人:一类是已经能用PyTorch跑模型,但遇到报错就懵、只会到处搜答案的;另一类是想自己写自定义算子、做算子融合优化,但不知道从哪下手的。我会从Tensor和Storage的内存关系讲起,拆解Autograd的动态图机制,再深入到算子调度和GPU执行流程,最后给出一套排查常见问题的实操方法。全程用代码说话,能跑的都给你跑一遍。

2. Tensor与Storage:数据到底存在哪里

2.1 Tensor不是数据本身,它是一层视图

刚接触PyTorch的时候,我以为Tensor就是数据。后来写了一个测试才发现事情没那么简单:

import torch a = torch.randn(3, 4) b = a.view(4, 3) print(a.data_ptr() == b.data_ptr()) # True print(a.storage().data_ptr() == b.storage().data_ptr()) # True

a和b的shape不同,但它们的data_ptr()完全一样。这说明Tensor本身不持有数据,它只是一个描述如何解读一块内存的元信息结构。真正存数据的是Storage。

一个Tensor包含以下关键元信息:

  • Storage:指向底层内存块的引用
  • shape:每个维度的大小
  • stride:每个维度上移动一个位置需要跳过多少个元素
  • offset:从Storage起始位置偏移多少才开始读数据
  • dtype:数据类型(float32、int64等)

用生活化的类比来说,Storage就像一整条街道上的房子,Tensor就像一张地图,告诉你从第几号房子开始、每隔几栋取一栋、一共取几栋。同一排房子可以画出完全不同的地图,这就是view操作的原理。

2.2 stride的计算与内存布局

stride是理解Tensor内存布局的关键。对于一个连续的(contiguous)Tensor,stride的计算规则是从最后一维开始,每一维的stride等于后面所有维度大小的乘积:

x = torch.randn(2, 3, 4) print(x.stride()) # (12, 4, 1) print(x.is_contiguous()) # True

计算过程:最后一维stride=1,倒数第二维stride=4(最后一维大小),第一维stride=12(3×4)。

当你做转置操作时,PyTorch并不会真的搬动数据,而是修改stride:

y = x.transpose(0, 1) print(y.shape) # torch.Size([3, 2, 4]) print(y.stride()) # (4, 12, 1) print(y.is_contiguous()) # False

y的stride变成了(4, 12, 1),数据还在原来的位置,只是解读方式变了。这就是为什么转置操作几乎不耗时——它只改元信息,不动内存。

注意:非连续Tensor在某些算子中会触发隐式的.contiguous()调用,导致额外的内存拷贝。如果你在性能敏感的代码路径上频繁做转置后再卷积,建议显式调用.contiguous(),这样至少你知道拷贝发生在哪里。

2.3 view和reshape的区别

这两个函数经常被混用,但行为有本质区别:

  • view:要求Tensor是连续的,否则报错。它直接基于原Storage创建新视图。
  • reshape:如果Tensor连续,等价于view;如果不连续,会先拷贝一份变成连续的,再view。
x = torch.randn(3, 4) y = x.transpose(0, 1) # 非连续 # y.view(12) # 报错:RuntimeError: view size is not compatible... z = y.reshape(12) # 正常工作,内部做了拷贝 print(z.data_ptr() == x.data_ptr()) # False,新内存

我踩过的坑:在DataLoader的collate_fn里用view处理变长序列,结果因为前面做了transpose导致报错。后来统一改成reshape,虽然多了一次拷贝,但至少不会崩。

2.4 Storage的共享与内存管理

多个Tensor可以共享同一个Storage,这是PyTorch内存效率高的原因之一:

a = torch.arange(12) b = a[3:7] # 切片,共享Storage c = a.clone() # 拷贝,新Storage print(a.storage().size()) # 12 print(b.storage().size()) # 12,仍然是整个Storage print(c.storage().size()) # 4

注意b虽然只用了4个元素,但它的Storage仍然是12个元素。这意味着如果你从一个很大的Tensor里切一小块出来长期持有,整块内存都不会被释放。正确的做法是:

b = a[3:7].clone() # 显式拷贝,切断与原Storage的关系

这个细节在显存紧张的时候特别重要。我曾经在一个项目里从一个大特征矩阵里切了几百个小块存到列表里,结果显存直接爆了——因为每个小块都持有整个大矩阵的Storage引用。

3. Autograd:动态计算图是怎么运转的

3.1 计算图的构建过程

PyTorch的Autograd采用动态图机制,每次前向传播都会重新构建计算图。这和TensorFlow 1.x的静态图完全不同——你写的是Python代码,执行的时候图就自然建好了。

x = torch.tensor([2.0], requires_grad=True) y = x ** 2 + 3 * x + 1 z = y.sum() z.backward() print(x.grad) # tensor([7.]),即2x+3在x=2处的值

这段代码执行时,PyTorch在背后做了这些事:

  1. x被标记为需要梯度,它的requires_grad=True
  2. 每次对x做运算,都会创建一个新的Tensor,并记录一个grad_fn
  3. y的grad_fn是AddBackward,它的输入是PowBackward和MulBackward的输出
  4. z.backward()从z开始,沿着grad_fn链反向遍历,逐层计算梯度

你可以直接查看这些信息:

print(y.grad_fn) # <AddBackward0 object at ...> print(y.grad_fn.next_functions) # ((<PowBackward0>, 0), (<MulBackward0>, 0))

每个grad_fn就是一个节点,next_functions指向它的输入节点。整张图就是通过这些引用串起来的。

3.2 叶子节点与非叶子节点

理解叶子节点是掌握Autograd的关键:

  • 叶子节点:由用户直接创建的Tensor(如torch.tensor([2.0], requires_grad=True)),is_leaf=True
  • 非叶子节点:由运算产生的Tensor,is_leaf=False,带有grad_fn

默认情况下,只有叶子节点的梯度会被保留在.grad属性中,非叶子节点的梯度在反向传播后会被释放(为了省内存)。如果你想查看中间变量的梯度,需要调用retain_grad():

x = torch.tensor([2.0], requires_grad=True) y = x ** 2 y.retain_grad() # 保留y的梯度 z = y * 3 z.backward() print(y.grad) # tensor([3.]) print(x.grad) # tensor([12.]),即2x*3=12

实操心得:调试梯度问题时,retain_grad()非常有用。但不要在生产代码里随便加,每个保留的梯度都会占用显存。调试完记得删掉。

3.3 梯度累积与zero_grad的必要性

PyTorch的梯度是累积的,不是覆盖的。这意味着如果你不在每个batch开始时清零梯度,梯度会一直累加:

x = torch.tensor([1.0], requires_grad=True) for i in range(3): y = x ** 2 y.backward() print(f"step {i}, grad = {x.grad}") # step 0, grad = 2.0 # step 1, grad = 4.0 # step 2, grad = 6.0

这就是为什么训练循环里必须写optimizer.zero_grad()。但梯度累积也有它的用处——当你显存不够、想用更大的等效batch size时,可以故意累积几个batch的梯度再更新:

accumulation_steps = 4 for i, (inputs, labels) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

注意loss要除以accumulation_steps,否则梯度会放大对应倍数。

3.4 自定义Function与梯度检查

当你需要写一个PyTorch没有的算子时,需要继承torch.autograd.Function并实现forward和backward:

class MyReLU(torch.autograd.Function): @staticmethod def forward(ctx, x): ctx.save_for_backward(x) return x.clamp(min=0) @staticmethod def backward(ctx, grad_output): x, = ctx.saved_tensors grad_input = grad_output.clone() grad_input[x < 0] = 0 return grad_input

ctx.save_for_backward用来保存反向传播需要的中间变量。这里有个坑:不要用ctx.x = x直接存Tensor,因为这样不会正确处理版本计数,可能导致内存泄漏或梯度计算错误。必须用save_for_backward。

写完之后一定要用torch.autograd.gradcheck验证:

from torch.autograd import gradcheck func = MyReLU.apply x = torch.randn(3, 4, dtype=torch.float64, requires_grad=True) test = gradcheck(func, (x,), eps=1e-6, atol=1e-4) print(test) # True

gradcheck通过数值微分来验证你的解析梯度是否正确。注意输入必须是float64,因为数值微分对精度要求很高。

4. 算子调度:从Python调用到GPU执行的全流程

4.1 算子调用的分层结构

当你在Python里写torch.add(a, b)时,背后经历了一条很长的调用链:

  1. Python层:torch.add是C++扩展模块暴露的接口
  2. ATen层:PyTorch的C++张量计算库,负责算子分发
  3. Dispatch层:根据Tensor的设备类型(CPU/CUDA)和dtype,选择对应的kernel
  4. Kernel层:实际执行计算的函数,CPU上可能是OpenMP并行,GPU上是CUDA kernel
  5. 硬件层:CPU指令或GPU线程执行

这个分发机制叫dispatch,是PyTorch支持多后端(CPU、CUDA、MPS等)的核心设计。

4.2 Dispatch Key的作用

每个Tensor都携带一组dispatch key,决定了算子会被路由到哪里:

x = torch.randn(3, 4) print(x.device) # cpu # 对应的dispatch key包括:CPU, AutogradCPU, ADInplaceOrView等 y = x.cuda() # 对应的dispatch key包括:CUDA, AutogradCUDA, ADInplaceOrView等

当你调用一个算子时,dispatcher会根据所有输入Tensor的dispatch key集合,找到优先级最高的那个,然后调用对应的kernel。比如AutogradCPU的优先级高于CPU,所以需要梯度的Tensor会先经过Autograd层记录计算图,再转发给CPU kernel执行实际计算。

4.3 GPU执行的全流程

在GPU上执行一个算子的完整流程大致如下:

  1. Host端准备:Python调用进入C++,dispatcher选择CUDA kernel
  2. Kernel launch:Host端将kernel函数和参数(grid大小、block大小、数据指针)提交到CUDA stream
  3. 异步执行:CUDA stream上的操作是异步的,Host端提交完就返回,不等GPU执行完
  4. Device端执行:GPU按照grid/block配置启动线程,每个线程执行kernel函数
  5. 同步点:当你调用.item()、.cpu()或torch.cuda.synchronize()时,Host端才会等待GPU完成
import torch import time x = torch.randn(10000, 10000).cuda() y = torch.randn(10000, 10000).cuda() # 异步执行,这行代码几乎瞬间返回 z = x + y # 同步点,这里才会真正等待GPU计算完成 torch.cuda.synchronize()

理解异步执行很重要。如果你在计时的时候不调用synchronize(),测出来的时间只是kernel launch的时间,不是实际计算时间:

# 错误计时方式 start = time.time() z = x + y print(time.time() - start) # 可能只有0.0001秒 # 正确计时方式 torch.cuda.synchronize() start = time.time() z = x + y torch.cuda.synchronize() print(time.time() - start) # 实际计算时间

4.4 算子融合与性能优化

大量小算子的连续调用会导致kernel launch开销占比过高。举个例子,一个简单的y = a * b + c在PyTorch里会启动两个kernel:一个乘法,一个加法。如果a、b、c都很小,那么kernel launch的开销可能比计算本身还大。

PyTorch 2.0引入的torch.compile就是解决这个问题的:

import torch @torch.compile def fused_op(a, b, c): return a * b + c a = torch.randn(1000, device='cuda') b = torch.randn(1000, device='cuda') c = torch.randn(1000, device='cuda') result = fused_op(a, b, c)

torch.compile会把多个小算子融合成一个kernel,减少launch次数和内存读写。实测在中小规模Tensor上,加速比可以到2-5倍。

注意事项:torch.compile首次运行会有编译开销,适合在训练循环这种反复执行相同计算图的场景使用。如果每次输入shape都变,编译会频繁触发,反而变慢。

5. 常见问题排查与实操技巧

5.1 梯度为None或NaN的排查思路

梯度为None通常是因为:

  • 该Tensor的requires_grad=False
  • 该Tensor不是叶子节点且没有调用retain_grad()
  • 计算图中断,比如用了.detach()或with torch.no_grad()

梯度为NaN的常见原因:

现象可能原因排查方法
loss变NaN学习率过大降低lr,加梯度裁剪
梯度变NaN除零或log(0)检查loss函数,加eps
部分参数梯度NaN自定义backward写错用gradcheck验证
训练一段时间后NaN梯度爆炸加梯度裁剪,检查初始化

梯度裁剪的实操写法:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

这行代码放在loss.backward()之后、optimizer.step()之前。

5.2 显存泄漏的定位方法

显存泄漏通常是因为计算图没有被释放。最常见的原因是:

  • 把带grad_fn的Tensor存到了列表里,导致整张图无法释放
  • 在训练循环里累积了loss但没有detach
# 错误做法:累积loss时保留了计算图 total_loss = 0 for inputs, labels in dataloader: loss = criterion(model(inputs), labels) total_loss += loss # 每轮的图都被保留 # 正确做法:detach或使用item() total_loss = 0 for inputs, labels in dataloader: loss = criterion(model(inputs), labels) total_loss += loss.item() # 只保留数值

定位显存泄漏可以用torch.cuda.memory_summary()查看当前显存分配情况,或者用tracemalloc追踪Python端的内存分配。

5.3 非连续Tensor引发的隐式拷贝

前面提到过,非连续Tensor在某些算子中会触发隐式拷贝。这个问题在性能分析时经常被忽略。你可以用torch.profiler来检测:

with torch.profiler.profile(activities=[torch.profiler.ProfilerActivity.CUDA]) as prof: y = model(x) print(prof.key_averages().table(sort_by="cuda_time_total"))

如果看到aten::contiguous或aten::copy_占用了大量时间,说明有非预期的拷贝发生。解决办法是在数据预处理阶段就保证Tensor是连续的,或者在模型forward里显式调用.contiguous()。

5.4 自定义算子的调试技巧

写自定义CUDA算子时,最容易出错的地方是索引计算和边界处理。我的调试流程通常是:

  1. 先用小的输入shape(如(2, 3))在CPU上写一个Python参考实现
  2. 用gradcheck验证forward和backward的正确性
  3. 在CUDA实现中,先用printf打印关键索引,确认线程映射正确
  4. 用cuda-memcheck检查越界访问
cuda-memcheck --tool memcheck python test_custom_op.py

这个工具能检测出大部分显存越界和未初始化访问的问题,虽然会拖慢运行速度,但调试阶段非常值得。

5.5 版本兼容性速查

PyTorch和Python、CUDA的版本对应关系经常让人头疼。这里给一个快速参考:

PyTorch版本推荐Python推荐CUDA
2.0.x3.8-3.1111.7/11.8
2.1.x3.8-3.1111.8/12.1
2.2.x3.8-3.1211.8/12.1
2.3.x3.8-3.1211.8/12.1

安装时建议用官方命令生成器,不要自己猜。如果用的是WSL环境,注意CUDA驱动要装在Windows侧,WSL里只装CUDA Toolkit。

6. 从源码角度理解算子的注册机制

6.1 ATen的代码组织结构

PyTorch的C++源码里,aten/src/ATen是核心目录。算子的定义分散在几个地方:

  • native/native_functions.yaml:声明所有算子的签名和dispatch配置
  • native/cpu/:CPU kernel实现
  • native/cuda/:CUDA kernel实现
  • core/:Tensor、Storage等核心数据结构

native_functions.yaml是理解算子注册的入口。比如add算子的定义大致是这样的:

- func: add.Tensor(Tensor self, Tensor other, *, Scalar alpha=1) -> Tensor dispatch: CPU: add_cpu CUDA: add_cuda SparseCPU: add_sparse

这段配置告诉dispatcher:当输入是CPU Tensor时调用add_cpu,是CUDA Tensor时调用add_cuda。

6.2 代码生成的作用

PyTorch大量使用了代码生成来减少重复劳动。native_functions.yaml会被解析,然后自动生成:

  • 算子声明头文件
  • dispatcher注册代码
  • Python绑定代码
  • Autograd的forward和backward包装

这意味着你添加一个新算子时,只需要在yaml里声明,然后实现对应的kernel函数,剩下的注册和绑定工作都是自动的。

6.3 对算子开发的启示

如果你要为公司内部硬件(比如NPU)适配PyTorch,核心工作就是:

  1. 实现一套PrivateUse1的dispatch key
  2. 为每个需要的算子实现对应的kernel
  3. 注册到dispatcher

这个过程不需要改PyTorch的核心代码,只需要通过扩展机制注册。torch.utils.cpp_extension提供了Python端的扩展接口,适合快速原型验证:

from torch.utils.cpp_extension import load custom_op = load( name="custom_op", sources=["custom_op.cpp", "custom_op_kernel.cu"], extra_cuda_cflags=["-O2"], )

这种方式编译出来的扩展可以直接在Python里调用,适合调试阶段。生产环境建议用setup.py打包成独立的wheel。

7. 我个人的一些实操体会

理解PyTorch内部机制这件事,投入产出比最高的切入点就是Storage和stride。这两个概念搞清楚了,后面看Autograd的计算图、算子的内存访问模式、性能优化时的拷贝问题,都会顺畅很多。我建议你花一个下午,把view、transpose、permute、expand、contiguous这几个操作在不同shape下的stride变化全部打印出来,亲手算一遍。这比看十篇文章都管用。

Autograd部分,重点掌握grad_fn链的遍历和retain_grad的使用。遇到梯度问题时,先打印grad_fn看看图有没有断,再检查叶子节点的requires_grad,最后用gradcheck验证自定义Function。这套流程能解决90%的梯度异常。

算子调度这块,不需要一上来就读C++源码。先用torch.profiler看看你的模型里哪些算子耗时最多,有没有非预期的拷贝,然后再针对性地去查对应的kernel实现。带着问题去读源码,效率比从头啃高得多。

最后说一个容易被忽略的点:PyTorch的异步执行模型。很多人在GPU上测时间不调用synchronize(),测出来的数字完全没有参考价值。养成习惯,任何涉及GPU计时的代码,前后都加上同步。这个习惯能帮你避免很多误判。

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

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

立即咨询