☰
从零手写深度学习框架:Tensor、计算图与Autograd架构设计
2026/10/13 11:57:33 网站建设 项目流程

1. 为什么我要从零写一个深度学习框架

动手写一个深度学习框架这件事,我在脑子里盘算了很久。起因很简单:用现成的框架做模型训练,调包调了几年,model.fit()一按就跑,但真遇到梯度爆炸、loss 不收敛、显存莫名爆掉的时候,心里其实是虚的。你知道大概该调学习率、该加正则,但底层到底发生了什么,autograd 是怎么把梯度一层层传回去的,优化器里的动量到底怎么更新的,这些细节长期被封装在黑盒里。

所以这个系列我打算干一件事:从零开始,用纯 Python 加 NumPy,搭一个能跑通前向传播、反向传播、参数更新的迷你深度学习框架。这一篇是第 1 篇,只讲架构设计,不写具体实现代码,把整个框架的骨架、模块划分、数据流向先定下来。后面几篇再逐个模块填肉。

这篇文章适合谁看?如果你已经会用 PyTorch 或 TensorFlow 训练模型,但想搞清楚框架内部到底怎么运转的,那正好。如果你刚学完反向传播的数学推导,想找个工程落地的抓手,也合适。纯小白可能需要先补一下链式法则和矩阵求导,但我会尽量把每个设计决策背后的“为什么”讲清楚,让你知其然也知其所以然。

我先把结论摆出来:一个能用的深度学习框架,核心就四块——张量(Tensor)作为数据载体、计算图(Computation Graph)记录运算关系、自动微分(Autograd)反向求梯度、优化器(Optimizer)更新参数。把这四块的关系理顺,框架就立起来了。剩下的损失函数、层封装、数据加载,都是在这四块之上的应用层。

2. 框架整体架构与模块划分

2.1 四层架构的划分逻辑

我在设计的时候,把整个框架分成四层,从下往上依次是:基础层、计算层、网络层、应用层。这个分层不是拍脑袋定的,而是照着“数据怎么流、梯度怎么回、参数怎么更新”这条主线切出来的。

基础层放的是最底层的数学工具,比如矩阵乘法、广播机制、随机数生成。这一层不涉及任何“学习”的概念,纯粹是数值计算。计算层是框架的心脏,包含 Tensor 对象、计算图节点、自动微分引擎。网络层负责把计算层的算子组装成层(Layer)和模型(Model),提供forward接口。应用层就是损失函数、优化器、训练循环这些面向使用者的东西。

为什么要这么分?因为分层能让每一层的职责单一,测试和替换都方便。比如我想把底层的 NumPy 换成自己写的矩阵运算库,只要基础层接口不变,上面三层完全不用动。再比如我想加一个新的优化器,只在应用层加就行,不碰下面的计算逻辑。这种解耦在框架开发里特别重要,否则代码会迅速腐化成意大利面条。

提示:分层设计的一个实用判断标准是——如果修改某一层的代码导致另一层必须跟着改,说明分层边界没划对。我在早期版本里把优化器的更新逻辑写进了 Tensor 类里,结果每加一个优化器都要改 Tensor,后来拆出来才清爽。

2.2 核心模块清单与依赖关系

具体到模块,我列了一张清单,每个模块的职责和依赖关系如下表。这张表是整个框架的“施工图”,后面写代码基本就是照着它逐个实现。

模块名称所属层级核心职责依赖模块
Tensor计算层数据存储、形状管理、运算重载基础层
Function计算层定义前向/反向计算,构建计算图Tensor
Autograd计算层拓扑排序、梯度回传调度Function、Tensor
Layer网络层参数管理、前向封装Tensor、Function
Model网络层层的容器、参数收集Layer
Loss应用层损失计算及梯度起点Tensor、Function
Optimizer应用层参数更新策略Tensor
DataLoader应用层批次划分、数据迭代基础层

依赖关系是单向的,上层依赖下层,下层不知道上层的存在。这一点很关键。Tensor 不应该知道优化器是什么,Function 也不应该知道模型是什么。依赖单向保证了框架的可扩展性,你加新功能时只需要往上叠,不用回头改底层。

2.3 数据流向与梯度回传路径

整个框架的数据流可以概括成一句话:前向建图,反向回传,最后更新。

前向传播时,每个运算都会创建一个 Function 节点,记录输入 Tensor 和输出 Tensor,同时把节点挂到计算图上。比如y = x * w + b,这里会生成两个节点:一个乘法节点、一个加法节点,它们通过 Tensor 的creator指针串成一条链。反向传播时,从损失值出发,沿着计算图反向遍历,每个节点调用自己的backward方法,把上游传来的梯度乘以本地的局部梯度,再传给下游。

这里有个设计选择值得说:计算图是动态构建的,还是静态构建的。静态图(像早期的 TensorFlow)先定义再运行,动态图(像 PyTorch)边运行边建图。我选动态图,原因是调试友好——你可以在任意位置打断点看中间结果,出错了堆栈也清晰。静态图虽然理论上能做更多图优化,但对一个教学性质的框架来说,可调试性比性能优化重要得多。

梯度回传的路径和前向路径正好相反,但节点顺序需要保证:一个节点必须等它所有的下游节点都把梯度传回来之后,才能计算自己的梯度。这就是为什么反向传播前要做一次拓扑排序。如果图里有分支(一个 Tensor 被多个运算使用),它的梯度是各分支梯度之和,这个累加逻辑必须在 Autograd 里处理干净,否则梯度会算错。

3. 核心模块的深度拆解与设计取舍

3.1 Tensor 设计:数据载体还是计算节点

Tensor 是整个框架里最纠结的一个类。它到底应该只是“一块带形状的数据”,还是同时承担“计算图节点”的角色?我最后的选择是让 Tensor 同时承担这两个职责,但把计算逻辑委托给 Function。

具体来说,Tensor 内部存三样东西:data(NumPy 数组,存数值)、grad(梯度,形状和 data 一致)、creator(指向创建它的 Function 节点)。当两个 Tensor 做运算时,__add__、__mul__这些魔术方法被触发,它们不直接算,而是调用对应的 Function 实例,由 Function 完成计算并返回新的 Tensor,同时把新 Tensor 的creator指向自己。

为什么不让 Tensor 自己算?因为把计算逻辑和数据结构分离,能让反向传播的实现更统一。每个 Function 只需要实现forward和backward两个方法,Autograd 引擎遍历图时统一调用backward,不用关心具体是什么运算。如果计算逻辑散落在 Tensor 的各个方法里,反向传播就得写一堆 if-else 判断运算类型,维护起来是灾难。

注意:Tensor 的grad默认是 None,只有在反向传播经过它时才会被分配。这个懒分配策略能省不少内存,尤其是中间激活值,很多根本不需要梯度。

3.2 计算图构建:动态图的节点管理

计算图的节点就是 Function 实例。每个 Function 在 forward 时接收若干输入 Tensor,产出输出 Tensor,并把输入存下来供 backward 使用。节点之间的连接靠 Tensor 的creator指针和 Function 的inputs列表双向维护。

我举个具体例子说明节点怎么串起来。假设做z = relu(x @ w + b),运算顺序是:先x @ w得到t1,再t1 + b得到t2,最后relu(t2)得到z。对应的节点链是:MatMul节点产出t1,Add节点产出t2,ReLU节点产出z。z.creator是 ReLU 节点,ReLU 节点的输入是t2,t2.creator是 Add 节点,以此类推,一直回溯到叶子节点(也就是参数和输入数据)。

反向传播时,从z出发,先调 ReLU 的 backward,算出t2的梯度;再调 Add 的 backward,算出t1和b的梯度;最后调 MatMul 的 backward,算出x和w的梯度。整个过程就是沿着 creator 链一路回溯。

这里有个坑我踩过:如果同一个 Tensor 被用了两次,梯度必须累加而不是覆盖。比如y = x + x,x 的梯度应该是 2 而不是 1。解决办法是在 backward 里用+=而不是=,并且在每次反向传播开始前把图里所有 Tensor 的 grad 清零。这个清零操作放在优化器的zero_grad里做,和 PyTorch 的设计一致。

3.3 Autograd 引擎:拓扑排序与梯度调度

Autograd 引擎的核心任务就两个:确定反向遍历的顺序,以及调度每个节点的 backward。顺序问题用拓扑排序解决,调度问题用递归或迭代实现。

拓扑排序的思路是:从损失 Tensor 出发,做一次深度优先搜索,记录每个节点的完成时间,最后按完成时间逆序排列,就得到反向传播的顺序。为什么是这个顺序?因为拓扑排序保证了一个节点在它所有依赖节点之前被处理,而反向传播要求下游先于上游,所以逆序正好。

我用一个简单的例子说明。图是a -> b -> c,正向是 a 算出 b,b 算出 c。拓扑排序从 c 出发,DFS 访问 b,再访问 a,a 完成、b 完成、c 完成,逆序就是 c、b、a,正好是反向传播需要的顺序。

调度实现上,我选迭代而非递归。递归写起来简洁,但图深了会爆栈,尤其是深层网络。迭代版本用一个列表当栈,手动管理访问状态,虽然代码长一点,但稳。这个取舍在框架开发里很常见:教学代码可以递归,生产代码必须迭代。

提示:拓扑排序时要注意处理“一个节点被多次访问”的情况。用一个 visited 集合记录已处理的节点,避免重复入栈,否则图有共享子结构时会做无用功。

3.4 参数管理与优化器接口设计

参数在框架里就是“需要梯度的 Tensor”。我在 Tensor 上加了一个requires_grad标志,只有为 True 的 Tensor 才会在反向传播时被分配梯度。Layer 在初始化时创建参数 Tensor,并统一注册到一个参数列表里,Model 再把所有 Layer 的参数收集起来,交给优化器。

优化器的接口设计我参考了主流框架的约定,核心就三个方法:zero_grad清空梯度、step更新参数、state_dict保存状态。step里做的是param.data -= lr * param.grad,如果是带动量的优化器,还要维护一个速度变量。

为什么把优化器和参数分开?因为同一个优化器可以管多组参数,同一组参数也可以换不同优化器。这种灵活性在调参时很有用,比如你可以给不同的层设不同的学习率。如果把更新逻辑写死在参数里,这种灵活性就没了。

优化器更新公式适用场景
SGDp = p - lr * g简单任务、需要精细调参
Momentumv = m * v - lr * g; p += v震荡严重的损失面
Adam一阶二阶动量自适应大多数默认场景

4. 实操:从零搭建框架骨架的完整流程

4.1 环境准备与目录结构

动手之前先把环境理清楚。我用的就是最朴素的组合:Python 3.10 加 NumPy,不装任何深度学习相关的库,这样才能保证框架是“从零”的。NumPy 只用来做底层矩阵运算,autograd 和计算图全部自己写。

目录结构我按模块划分,每个模块一个文件,方便后续逐个实现和测试:

mini-dl/ ├── core/ │ ├── tensor.py # Tensor 类 │ ├── function.py # Function 基类及各类算子 │ └── autograd.py # 自动微分引擎 ├── nn/ │ ├── layer.py # Layer 基类 │ ├── linear.py # 全连接层 │ └── model.py # Model 容器 ├── optim/ │ ├── sgd.py # SGD 优化器 │ └── adam.py # Adam 优化器 ├── loss/ │ └── mse.py # 均方误差 └── tests/ └── test_autograd.py

这个结构的好处是每个文件职责单一,测试时能单独 import。比如我想验证 autograd 对不对,直接跑test_autograd.py,不用把整个框架跑起来。

4.2 关键接口定义与参数计算

在写实现之前,我先把几个核心接口的签名定下来,这样后面填代码时不会来回改。

Tensor 的构造函数接收 data 和 requires_grad,内部把 data 转成 NumPy 数组并记录形状。Function 基类定义forward和backward两个抽象方法,__call__里负责调用 forward、包装输出 Tensor、设置 creator。Autograd 引擎暴露一个backward函数,接收损失 Tensor,内部做拓扑排序和梯度调度。

参数初始化这块有个细节值得算一下。全连接层的权重我用 Xavier 初始化,公式是std = sqrt(2 / (fan_in + fan_out))。为什么用这个?因为如果权重初始化太大,前向传播时激活值会逐层放大,最后溢出;太小则逐层缩小,梯度消失。Xavier 让每一层的输出方差和输入方差保持一致,信号能稳定传下去。我实测过,用 Xavier 初始化的网络比用纯随机初始化的收敛快不少,尤其是层数多的时候。

import numpy as np def xavier_init(fan_in, fan_out): std = np.sqrt(2.0 / (fan_in + fan_out)) return np.random.randn(fan_in, fan_out) * std

4.3 前向传播与反向传播的联调

骨架搭好后,第一件事是验证前向和反向能不能对上。我的做法是用数值梯度校验解析梯度:对一个简单函数,分别用 autograd 算梯度和用有限差分算梯度,两者应该非常接近。

数值梯度的公式是(f(x+h) - f(x-h)) / (2h),h 取 1e-5 左右。这个校验方法在框架开发里是标配,每次加新算子都要跑一遍。我踩过的坑是:如果 h 取太大,截断误差大;取太小,浮点误差大。1e-5 是个经验值,实测下来对 float64 精度够用。

联调时我建议从最简单的运算开始,先验证加法、乘法,再验证矩阵乘法,最后验证完整的线性层。每加一个算子就测一个,不要攒一堆再测,否则出了问题很难定位是哪个算子的锅。

注意:数值梯度校验只在 float64 下可靠,float32 精度不够,误差会大到无法判断。所以测试时统一用 float64,训练时再切回 float32。

4.4 一个最小可跑通的训练闭环

骨架验证通过后,我搭了一个最小闭环:一个线性层加 MSE 损失,用 SGD 优化,拟合一个简单的线性关系。这个闭环虽然简单,但把前向、反向、更新三个环节全串起来了,能跑通就说明框架的基本逻辑没问题。

训练循环长这样:取一批数据,前向算预测值,算损失,反向传播,优化器更新,清空梯度。这五步是所有深度学习训练的通用骨架,后面加再多花哨的东西,本质还是这五步。我建议你把这个循环背下来,调任何模型都是这个套路。

跑通之后我观察了一下 loss 曲线,前几十步下降很快,后面趋于平缓,这是正常的收敛形态。如果 loss 不降反升,八成是学习率太大或者梯度符号搞反了;如果 loss 几乎不动,可能是初始化有问题或者梯度没传回去。这些排查经验后面会专门讲。

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

5.1 梯度相关问题的排查思路

梯度问题是框架开发里最高频的坑,我整理了一张速查表,遇到问题按表排查能省不少时间。

现象可能原因排查方法
梯度全为 0计算图断了,某节点没设 creator打印每个 Tensor 的 creator 是否为 None
梯度爆炸学习率太大或初始化太大打印梯度范数,看是否逐层放大
梯度消失激活函数饱和或初始化太小检查激活值分布,换 ReLU
梯度形状不对backward 里维度没对齐对比 grad 和 data 的 shape
梯度累加错误共享 Tensor 的梯度没累加检查是否用了 += 而非 =

排查梯度问题我有个笨办法但很管用:在 backward 里加打印,把每个节点的输入梯度、输出梯度、局部梯度都打出来。虽然输出多,但一眼就能看出哪一层开始不对劲。等定位到问题再删掉打印。

还有一个隐蔽的坑:broadcasting 的反向传播。前向时如果发生了广播(比如 (3,4) 和 (4,) 相加),反向时梯度需要沿着广播的维度求和还原。这个逻辑如果写错,梯度形状就对不上,报错信息还特别难懂。我的建议是广播的反向单独写测试,别和普通运算混在一起测。

5.2 计算图构建的典型陷阱

计算图这块我踩过两个印象深刻的坑。第一个是原地操作。如果我在 Tensor 上做x += 1这种原地修改,会破坏计算图记录的前向值,导致反向传播时用的值和前向时不一致,梯度算错。解决办法是所有运算都返回新 Tensor,绝不原地修改。这个约束在框架里要严格执行,宁可多占点内存。

第二个是图的重复构建。如果我在训练循环里忘了清空计算图,每次迭代的节点会不断累积,内存越吃越多,最后 OOM。动态图框架一般靠 Python 的垃圾回收自动清理,但前提是没有循环引用。我在 Function 里存输入 Tensor 时用的是弱引用,避免节点之间互相持有导致回收不掉。

提示:调试计算图时,可以写一个visualize_graph函数,从损失 Tensor 出发遍历 creator 链,把节点类型和形状打印成树状结构。这个工具在排查“图断在哪”时特别好用。

5.3 性能与内存的优化经验

框架能跑通之后,下一步就是让它跑得快、吃得少。我总结了几个实用的优化点。

第一,减少不必要的 Tensor 创建。每次运算都新建 Tensor 是有开销的,尤其是小运算频繁调用时。我后来加了一个内存池,复用形状相同的 Tensor 的底层数组,实测能省 20% 左右的内存分配时间。

第二,反向传播时及时释放中间梯度。不是所有中间 Tensor 的梯度都需要保留,只有参数的梯度需要。我在 Autograd 里加了个标记,非参数的中间梯度算完就置 None,让 GC 回收。

第三,矩阵运算尽量批量做。框架层面的循环能避免就避免,把 batch 维度利用起来,让 NumPy 走底层优化过的 BLAS 库。我试过把逐样本的循环改成批量矩阵乘,速度差了几十倍。

这些优化不是一开始就要做的,先保证正确,再谈性能。我见过太多人一上来就抠性能,结果正确性都没保证,优化了半天全是错的。正确的顺序是:跑通、测对、再优化。

5.4 从教学框架到可用框架的差距

最后说点实在的。这个系列搭出来的框架,定位是教学和理解原理,不是替代 PyTorch。真要用到生产里,还差不少东西:GPU 支持、算子融合、自动混合精度、分布式训练、完整的算子库、成熟的调试工具。这些每一个都是大工程。

但理解了这个迷你框架,你再看 PyTorch 的源码就不会发怵了。它的核心机制——Tensor、autograd、计算图、优化器——和这里讲的是一回事,只是工程实现上做了大量优化和扩展。先在小框架里把原理吃透,再去读大框架的源码,这条路我觉得是最扎实的。

我自己在实际操作中的体会是,写框架最大的收获不是框架本身,而是对“梯度是怎么流动的”这件事有了肌肉记忆。以前调模型靠试,现在能根据 loss 曲线和梯度范数判断问题出在哪一层。这种直觉,光看文档是练不出来的,必须自己动手写一遍。后面几篇我会逐个模块把代码填上,从 Tensor 开始,一步步把这个骨架变成能跑的东西。

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

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

立即咨询