乐于分享
好东西不私藏

MicroGPT 源码剖析(三):自动微分引擎 Autogard

MicroGPT 源码剖析(三):自动微分引擎 Autogard

源码之前,了无秘密。

《STL源码剖析》 By  侯捷

MicroGPT 是 AI 大佬 Andrej Karpathy 纯手搓的极简 GPT(Generative Pre-trained Transformer)实现——用 200 行纯 Python(无任何外部依赖)完成了完整的 Transformer 训练与推理流程。

本源码剖析系列总共包含以下六个部分
本篇为第三部分,将逐行分析 MicroGPT 的自动微分引擎部分(对应源码第 30-72 行)
本篇是整个神经网络训练的”心脏”,也是整个训练系统的数学基础——它让程序能够自动计算梯度,而不需要为每个公式手动推导求导规则。
关于更多先导基础数学知识,读者可自行阅读:
上面的数学基础知识也可以选择跳过,本篇为表述完整也会对基本数学术语进行简单的再解释。
本篇目录结构如下:
  1. 什么是自动微分
  2. 为什么需要自动微分
  3. 全微分如何自动计算
  4. 自动微分引擎源码详解
  5. 总结
1、什么是自动微分(Automatic Differentiation)
微分,物理意义上指的是物体的瞬时变化率,几何意义上指曲线在某点的切线斜率,计算微分本质就是对函数求导
先简单回顾一下微积分里的求导概念:
常见计算函数的微分规则:
总的来说,微分是理解”变化”的数学语言。物理学上可以用来计算加速度;经济上可以用来计算边际成本;机器学习领域可以指导模型参数的调整方向与大小。
数学上的求导是通过数学公式推导。
计算机里如何求导的呢?
自动微分就是自动对函数进行求导
2、为什么需要自动微分

2017 年谷歌八位科学家联合发表一篇里程碑式机器学习论文《Attention Is All You Need》,提出 Transformer 架构,将残差连接(Residual Connection)从计算机视觉迁移到自然语言处理。

yabohe,公众号:yablog理解 LLM 的关键数学概念:残差 Residual
残差思想告诉我们:如果直接学习复杂的目标函数很难,那么学习目标函数的残差可能会容易很多,而且通过自动微分(梯度计算)可以将损失值做到极小,以使得预测值接近观测数据。
2.1 Loss(损失函数)
Transformer 架构是通过计算目标函数的残差(使其最小化)来实现最大概率预测的,在 LLM 中,残差(residual)是指”模型还没学会的那一部分”(即:残差 = 观测值 – 预测值)。
所以,残差可简单表示为:假设观测值为y,预测值为f(x)
residual = y - f(x)
训练的过程则是使得下面的目标函数值最小:
上面即 Loss(损失函数)最小。

注:Loss 表示评价残差的某种数学函数。想了解更多残差可前往:理解 LLM 的关键数学概念:残差 Residual

2.2 如何使得 Loss 最小?梯度指引方向

当时正处晚年的麦克斯韦正在撰写他的旷世巨著《电磁通论》(1873年出版),他需要向世人解释法拉第的电磁理论。但他遇到了一个具体问题:电势(标量)的分布,是如何决定电场强度(矢量)的?不久后麦克斯韦发现,哈密顿发明的那个算子简直是为这个问题量身定制的:当把这个算子作用于电势场时,得到的结果正好指向电势下降最快的方向,其大小就是电场强度。

于是他写道:对于空间中任意一点的标量函数,我们可以找到它增加最快的方向和速率,我提议将这个矢量称为该标量函数的梯度(Gradient)。

yabohe,公众号:yablog理解 LLM 的关键数学概念:梯度 Gradient

Loss 损失函数一旦确定,问题就确定了:需要计算出预测值与预训练学习时的实际数据的差值(即损失值),这个 loss 值需要越小越好,而且越快收敛越好。

根据梯度篇(理解 LLM 的关键数学概念:梯度 Gradient)可知:梯度代表增长最快的方向,那么梯度相反的方向则是 loss 函数值下降最快的方向。

于是,计算loss值就转变成寻找梯度(然后取反)。

下面以二元函数为例说明:假设 Loss 目标函数为二元函数 z=f(x,y),它总的微分(即全微分)是由各个变量的偏微分相加而成的:

如果把上图中的这个二元全微分方程在对自变量 x 和 y 的偏导数都拎出来,形成一个向量,记为 ∇f(x,y) :

这便是梯度的数学表示(谨记:梯度是一个向量)。

所以,全微分 dz 可以表示为:

dz = ∇f(x,y) · (dx,dy) 

再根据内积原理(理解 LLM 的关键数学概念:内积),当 (dx, dy) 移动方向与梯度 ∇f(x,y) 方向一致时,dz 的值最大。

dz = ∇f(x,y) · (dx,dy) dz = |∇f(x,y)|·|(dx,dy)|cos(θ)
当 θ 为 0,cos(θ) = 1 时,dz 最大。
如果换成计算下降的最大高度时,则当 (dx, dy) 移动方向与梯度 ∇f(x,y) 方向相反时,dz 的值最大(最小负数,绝对值最大)。
也就是说:对于 Loss 损失函数(即目标函数)而言,当自变量从 (x, y) 位置移动到 (x+dy, y+dy) 时,如果 (dx, dy) 与 梯度 ∇f(x,y) 方向相反时,函数值 Loss 下降最快。
模型训练的本质是:计算出”预测有多差”(损失值 Loss)后,需要知道”每个参数应该往哪个方向调整、调整多少”。
现在这个问题有了答案:计算梯度
  • 应该往哪个方向调整:梯度反方向
  • 调整多少:学习率 × 梯度(再加上优化器如 Adam 的缩放)

注:如果梯度是告诉我们往哪走,学习率决定走多远”。

3、全微分如何自动计算

下面继续以二元函数为例展开说明全微分自动计算过程。
首先,构造一个简单二元函数(即 Loss 函数
L(x,y) = (x*y +2
目标则为:计算梯度
∇f(x,y) = (fx, fy)

然后,将函数拆开

x, y ↓a = x * y ↓b = a + 2 ↓L = b²

对应的链式结构为:

x,y -> a -> b -> L

接着,前向计算(Forward)

# 假设:x = 3, y = 4#逐步算:a = x*y = 12b = a+2 = 14L = b² = 196

然后,反向传播(Backward)

于是目标梯度 (fx, fy) 值为 (112, 84) 计算成功。
注,以上推导基于链式法则:dL/da = dL/db · db/da
然后就可以用这个梯度向量值来更新模型下一步的参数((x_new, y_new):
假设学习率为0.01,则更新 x 与 y 为:
  • x_new = 3 – 0.01*112 = 1.88
  • y_new = 4 – 0.01*84 = 3.16
4、自动微分源码分析
梳理完了理论基础,下面以三元函数 L(a,b,c) 为例说明自动微分的源码逻辑:
L(a,b,c) = (a * b + c)²
其中,a, b, c 为自变量。
自动微分的核心思想是:把复杂的多元函数拆成一连串简单的原子运算,每个原子只需要会求自己那一小步的偏导,全局梯度交给链式法则串起来。
所以,对上面的三元函数 L 求解梯度(gard),不是一步到位的三元运算,而是被拆成了三步的二元/一元运算:
# 二元乘法d = a * b# 二元加法e = d + c# 一元幂(指数 2 为常数)L = e² 
也就是说 L 是三步原子运算的组合,每步前向计算时只看局部,反向传播时根据链式法则自动把三个偏导算出来。
源码中定义了六个原子运算以及三个派生运算,所有其他的复杂运算都可由这些原子运算组合而来:
然后只要每个原子运算知道自己的局部导数,任意复杂的函数都能自动求导。
由基础高数知识,我们很容易知道这六个原子运算的局部导数:
话不多说,下面我们直接看源码:
# =============================================================================# 计算图示例(贯穿全文的 L = (a·b + c)²,取 a=2, b=3, c=4)##      a(data=2)          b(data=3)          c(data=4)#          \                /                    |#     local=b=3      local=a=2              local=1#            \          /                        |#             d(data=6) ───── local=1 ───┐       |#                                        ├──► e(data=10)#                                              │#                                         local=2e=20#                                              │#                                              ▼#                                          L(data=100)## 前向:自底向上算 data# 反向:自顶向下传 grad(链式法则:child.grad += local_grad * v.grad)# =============================================================================# ---- 自动微分引擎:让每个数字"记住"自己是怎么被算出来的 ----# 整个类只做两件事:#   1. 前向时算出数值,顺手建立计算图(_children + _local_grads)#   2. 反向时沿图的拓扑逆序,用链式法则把梯度传回每个叶子class Value:    # Python 性能优化:禁用 __dict__,只允许这 4 个属性    # 好处:每个实例节省 ~100 字节内存 + 属性访问更快    # 代价:不能随意给实例添加新属性(如 self.label = "loss" 会报错)    # 训练时会创建几百万个 Value 节点,这个优化非常值得    __slots__ = ('data''grad''_children''_local_grads')    # data      : 前向计算出的标量值,如 d.data = 6    # children  : 计算图中本节点的直接依赖(子节点),如 d._children = (a, b)    # local_grads: 本节点对每个 child 的局部偏导 ∂self/∂child,如 d._local_grads = (b=3, a=2)    #              注意:这是局部导数,不是全局导数 ∂L/∂child!    #              局部导数在前向时就能算出来,全局导数要等反向传播才知道    def __init__(self, data, children=(), local_grads=()):        # 前向值(标量)。例:Value(6).data == 6        self.data = data        # 全局梯度 ∂L/∂self,初始化为 0        # 反向传播时通过 += 累加        # 每次训练 step 结束后必须手动清零(见 microgpt.py 第 182 行 p.grad = 0)        self.grad = 0        # 本节点的 children 元组。叶子节点(用户创建的参数)默认为空 ()        # 例:d = a * b 时,d._children = (a, b)        self._children = children        # 局部导数元组,长度必须与 _children 一致(backward 里用 zip 配对)        # 例:d = a * b 时,d._local_grads = (b.data, a.data) = (3, 2)        #     含义:∂d/∂a = b = 3,∂d/∂b = a = 2        self._local_grads = local_grads    # ======================== 二元运算 ========================    # 加法:y = self + other    # 数学:∂(a+b)/∂a = 1,∂(a+b)/∂b = 1    # 所以 _local_grads = (1, 1),加法的局部导数永远是 1    def __add__(self, other):        # 参数归一化:允许写 x + 1 而不必 x + Value(1)        # 如果 other 不是 Value,自动包装成叶子节点(children=(), local_grads=())        other = other if isinstance(other, Value) else Value(other)        # 返回新节点(不是 in-place 修改!保持计算图为 DAG)        # 参数1: 前向值 = self.data + other.data        # 参数2: children = (self, other),记录"我是谁算出来的"        # 参数3: local_grads = (1, 1),∂y/∂self = 1, ∂y/∂other = 1        return Value(self.data + other.data, (self, other), (11))    # 乘法:y = self * other    # 数学:∂(a·b)/∂a = b,∂(a·b)/∂b = a    # 注意 _local_grads 是 (other.data, self.data)——"交叉"存储!    # 因为 _children[0]=self 对应的局部导数是 ∂y/∂self = other,排第 0 位    #     _children[1]=other 对应的局部导数是 ∂y/∂other = self,排第 1 位    def __mul__(self, other):        other = other if isinstance(other, Value) else Value(other)        return Value(self.data * other.data, (self, other), (other.data, self.data))    # ======================== 一元运算 ========================    # 以下 4 行每行都是一个完整的运算定义    # 结构统一:Value(前向结果, (self,), (局部导数,))    # 注意 (self,) 的尾随逗号——是单元素元组的必要写法,没有逗号就不是元组    # 幂运算:y = self^other(other 必须是普通 Python 数字,不能是 Value)    # 数学:d/da (a^n) = n · a^(n-1)(幂法则)    # 例:e**2 的局部导数 = 2 · e^(2-1) = 2e = 20(在示例中 e=10)    # 约束:不支持 Value**Value(否则还需要 ∂(a^b)/∂b = a^b·ln(a),代码未处理)    def __pow__(self, other): return Value(self.data**other, (self,), (other * self.data**(other-1),))    # 自然对数:y = ln(self)    # 数学:d/da (ln a) = 1/a    # 要求 self.data > 0,否则 math.log 会抛异常    # 用途:交叉熵损失函数 -Σ y·log(ŷ) 中的 log    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))    # 指数函数:y = e^self    # 数学:d/da (e^a) = e^a(指数函数的导数等于自身)    # math.exp(self.data) 被计算了两次(前向值和局部导数),    # 可以优化为 e = math.exp(self.data); return Value(e, (self,), (e,))    # 但为了代码极简,保留了当前写法    # 用途:Softmax 中的 exp(x_i)    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))    # ReLU 激活函数:y = max(0, self)    # 数学:分段导数 —— self>0 时为 1,self≤0 时为 0    # float(True)=1.0, float(False)=0.0,一行搞定分段    # self=0 处严格不可导,这里按惯例取 0(次梯度 subgradient),工程上完全可用    def relu(self): return Value(max(0self.data), (self,), (float(self.data > 0),))    # ======================== 派生运算符 ========================    # 以下 7 个运算通过组合上面的原子运算实现,不需要独立推导梯度    # 自动微分的组合性保证:只要原子运算的局部导数正确,组合出来的梯度自动正确    # 取负:-a = a * (-1),复用 __mul__    def __neg__(self): return self * -1    # 反向加法:当 Python 执行 2 + x 时,先尝试 (2).__add__(x) 失败,    # 再尝试 x.__radd__(2);利用加法交换律直接返回 self + other    def __radd__(self, other): return self + other    # 减法:a - b = a + (-b),复用 __add__ + __neg__    def __sub__(self, other): return self + (-other)    # 反向减法:b - a(b 非 Value)= other + (-self)    def __rsub__(self, other): return other + (-self)    # 反向乘法:利用乘法交换律    def __rmul__(self, other): return self * other    # 除法:a / b = a · b^(-1),复用 __mul__ + __pow__(指数为 -1 是普通数字,合法)    def __truediv__(self, other): return self * other**-1    # 反向除法:b / a = b · a^(-1)    def __rtruediv__(self, other): return other * self**-1    # ======================== 反向传播 ========================    # 这 14 行是整个文件的心脏:沿计算图的拓扑逆序,用链式法则把梯度从 L 传回每个叶子    def backward(self):        # ---------- 第一步:拓扑排序(后序 DFS)----------        # 目的:生成一个节点列表 topo,满足"任何节点都排在它的父节点前面"        # reversed 后就变成"父节点先处理"——反向传播需要的顺序        topo = []           # 排序结果容器        visited = set()     # 防止 DAG 中一个节点被多个父引用时重复访问        def build_topo(v):            if v not in visited:        # 剪枝:已访问的节点跳过                visited.add(v)          # 标记已访问(用对象 id 做哈希,天然唯一)                for child in v._children:                    build_topo(child)   # 先递归处理所有子节点                # ★ 关键:append 放在递归之后(后序遍历)                # 保证所有子孙先入列,自己最后入列                # reversed 后自己就排在前面——反向时先处理                # 如果把 append 放在递归之前,反向时叶子会先被处理,                # 但那时叶子的 .grad 还是 0(种子还没传到),整个反向传播失效                topo.append(v)        # 从当前节点(通常是 loss)开始构建拓扑序        build_topo(self)        # ---------- 第二步:种子梯度 ----------        # 数学:∂L/∂L = 1(自己对自己的导数恒为 1)        # 这是反向传播的"第一块多米诺骨牌"        # 如果改成 2,所有梯度整体放大 2 倍(等效学习率翻倍)        # 如果删掉这行(.grad 默认 0),所有乘法都乘 0,反向传播完全失效        self.grad = 1        # ---------- 第三步:反向循环 = 链式法则 ----------        # 从 L 开始(reversed 后的第一个),沿拓扑逆序往源头走        for v in reversed(topo):            # 对当前节点 v 的每个 child,把 v 对 child 的"责任"分发下去            # zip 保证 child 和对应的局部导数一一配对            for child, local_grad in zip(v._children, v._local_grads):                # ★ 整段代码的灵魂——链式法则的代码形式:                #   child.grad(∂L/∂child,全局) += local_grad(∂v/∂child,局部) * v.grad(∂L/∂v,上游全局)                #                # 为什么是 += 而不是 = ?                #   因为一个 child 可能被多个父节点引用(如 y = a*a 中 a 出现两次),                #   每条路径各自贡献一份梯度,必须全部累加(多元链式法则的 Σ)                #   如果用 =,后一条路径会覆盖前一条,结果错误                #                # 示例(访问 d,d.grad=20):                #   d._children = (a, b), d._local_grads = (3, 2)                #   a.grad += 3 * 20 = 60   ← ∂L/∂a = ∂d/∂a · ∂L/∂d = b · 20 = 60 ✓                #   b.grad += 2 * 20 = 40   ← ∂L/∂b = ∂d/∂b · ∂L/∂d = a · 20 = 40 ✓                child.grad += local_grad * v.grad
阅读上面的代码注意区分局部导数与全局导数(即全微分/梯度):假设当前数据节点记为 v,children 为其孩子节点,L 为最终的梯度值

class Value 是一个普通的 Python 类。它同时扮演两个角色:

  1. 一个会算数的数字
    Value(3) + Value(5) 要能得到 Value(8)(靠 __add__)。
  2. 计算图上的一个节点
    :保存”我是谁算出来的”和”局部导数”。
为进一步加深印象,我们以三元函数 L(a,b,c) 过一遍拓扑排序与反向循环/链式法则的例子:
进入 L → 先递归 e → 先递归 d → 先递归 a → a 无 child → append(a)                              → 递归 b → append(b) → 回到 d,append(d)                  → 递归 c → append(c) → 回到 e,append(e)      → 回到 L,append(L)topo = [a, b, d, c, e, L]reversed(topo) = [L, e, c, d, b, a]   ← 从 L 开始往源头走,完美
可以看到通过递归得到的后序遍历,保证了”子节点先进 topo 列表”,reversed 一翻转就变成”父节点先被处理”,这正是反向传播需要的顺序。
5、总结

自动微分最厉害的地方在于它的“组合性”

只要每一种原子运算的局部导数是对的,任意深的、任意复杂的函数组合都能自动得到正确的全局梯度。

这个性质是链式法则给的数学保证。只基于六个原子运算便可以达到下面的效果:

  • 用 + 和 * 组装出矩阵乘法linear()
  • 用 ***-0.5+ 组装出 RMSNorm
  • 用 exp+/ 组装出 Softmax
  • 用 log- 组装出交叉熵
  • 把上面这些再组合起来,拼出 Attention、MLP、Transformer Block
  • 最终构建一个完整的 GPT

而 backward() 一个方法,就能把整张图的梯度一次性算出来。这点也印证了Karpathy 在文件开头所说的:”this file is the complete algorithm, everything else is just efficiency“.

相对于 PyTorch、JAX、TensorFlow 这些工业级的自动微分引擎实现,class Value 与它们的核心区别也只是:

  • 数据粒度:
    :它们的节点是张量(多维数组),Value 是标量
  • 底层
    :它们用 C++/CUDA,Value 是纯 Python
  • 优化
    :它们做算子融合、内存复用、并行调度,Value 只追求代码简洁

而原理则完全一致

也就是说理解了这 43 行代码,你就理解了 PyTorch 的核心原理。

这也是笔者分析与总结 MicroGPT 源码的意义所在。

本篇完。

下篇第四部分继续分析 MicroGPT的模型架构详解如果觉的有趣,欢迎讨论关注:)