📗 第7章 深度学习工具
📄 一句话概括

Theano 的核心是一句话:先记算式,再算数。 写 h = tanh(X 乘 W 加 b) 的时候,它一个数都不算,只把这句话记成一个节点。 记成图之后有两件好事。一是能自动求导:从输出往回走一遍, 每个节点按自己的规则传梯度——实测与数值差分的相对误差 2.62e-10。 二是能编译:把图生成成代码。这次只快了一成,因为图太小;但常量折叠把 5 个节点压到了 3 个。

📝 本节和前面几节的关系

上一节测了机器,从这一节开始看框架,第一个就是 Theano。 它 2017 年停止维护,但「符号图加编译」这套东西被完整继承了下来。 这一节的 Demo 是用 numpy 写一个迷你版:能建图、能求值、能自动求导、能编译。 三十行代码就能跑通,跑通了再回去看 TensorFlow 的计算图,会简单很多。

一、建图阶段:写算式不算数

numpy 是「写一行算一行」:y = x @ W 这一行执行完,y 就已经是数了。 Theano 不是。y = T.dot(x, W) 执行完,y 是一个符号变量,里面没有数。 这个差别看着别扭,但它换来了三件事:

能力 为什么非要先建图
自动求导 知道每个节点是什么运算,才能按规则往回传
编译优化 整张图摆在那儿,才能做常量折叠、算子融合
换后端 图是抽象的,同一张图能生成 C 代码,也能生成 CUDA
这三件事都是「全局信息」的产物。
算一行丢一行,就永远拿不到全局。

二、自动求导:倒着走一遍

手工推链式法则是这样的:z 对 W 求导,要先把 z 对 h 求导、h 对 tanh 的输入求导、 再对 XW+b 求导,一层层乘下来。 层数一多,手推就容易错。 符号图的做法是把这件事机械化:从输出节点出发,每个节点按自己的规则把收到的梯度变一下, 再传给它的输入。

节点 收到的梯度 g 变成什么
matmul 对 A 传 g 乘 B 转置;对 B 传 A 转置乘 g
add 原样传给两个输入
tanh 传 g 乘(1 减输出平方)
mul 对 A 传 g 乘 B;对 B 传 g 乘 A
sum 把标量 g 铺成输入的形状
这里有个必须踩一次的坑:不能一边递归一边累加。
如果一个节点被两条路径引用(比如 h 同时进了 h 乘 h 的左右两边),累加的梯度会被重复往下传,
结果就错了。
正确做法是先拓扑排序,再逆序传播——每个节点只处理一次。
实测对账:符号求导和数值差分的最大相对误差是 2.62e-10。
这个量级说明规则写对了。差值来自差分本身的截断误差,不是代码错。

三、编译:把图压成代码

图建好之后,每次求值都要递归走一遍、按 op 名字查分支。 编译就是把这棵树压成一段平铺的代码,以后直接顺序执行。 这次编译生成出来的是 Python 源码,长这样:

def _compiled(X, W, b):
    t0 = X @ W
    t1 = t0 + b
    t2 = np.tanh(t1)
    t3 = t2 * t2
    t4 = t3.sum()
    return t4

真实的 Theano 生成的是 C 代码,但省掉的东西一模一样:递归、查表、重复求值。 实测只快了一成(10.79 毫秒 对 9.82 毫秒)。 原因很实在:图只有 8 个节点,时间大头花在 Python 的函数调用上,省掉的那点查表开销不算什么。 但这不代表编译没用。真实网络的图有上万个节点,而且图一编译就能提前分配显存、 把算子融成一个 kernel——这些收益在执行阶段才显出来。

四、编译期优化:常量折叠

图摆在那儿,编译前可以先改一遍。最简单的一种是常量折叠。 两个常量之间的运算,编译时就能算完,不必留到运行时。

写法 节点数
X 乘全 1 矩阵,再加全 2 矩阵 5
先把两个常量加起来,再跟 X 相加 3
省两个节点看着微不足道,但真实的优化器做的远不止这些。
公共子表达式消除:同一个式子只算一次。
算子融合:三个小 kernel 合成一个大 kernel。
内存复用:中间结果用完立刻还回去。
这一整套「先看图、再改图、最后执行」的思路,后来成了编译器的标准套路。
今天你在 PyTorch 里听到的 torch.compile,做的还是这件事。

五、动手 Demo:用三十行 numpy 实现一个迷你 Theano

四个能力各写一小段:建图、求值、自动求导、编译。 图很小:X 是 8 乘 4,W 是 4 乘 3,最后求一个标量。 代码里 _order 加 grad 那一段是关键,它说明了为什么必须先排序。

class Var(object):
    u"""一个符号变量:要么是输入,要么是算出来的。"""
    __slots__ = ('op', 'args', 'val', 'name')


def T(op, *args):
    n = Var(op, op=op, args=args); _ALL.append(n); return n


def matmul(a, b): return T('matmul', a, b)
def add(a, b):    return T('add', a, b)
def tanh(a):      return T('tanh', a)
def total(a):     return T('sum', a)

h = tanh(add(matmul(X, W), b))     # 到这里一个数都没算
z = total(T('mul', h, h))

# 反向:必须先拓扑排序,再逆序传
def _order(n, seen, out):
    if id(n) in seen: return
    seen.add(id(n))
    for a in n.args: _order(a, seen, out)
    out.append(n)


def grad(z, g0, feed):
    seq = []; _order(z, set(), seq)
    out = {id(z): g0}
    for node in reversed(seq):
        g = out.get(id(node))
        if g is None: continue
        if node.op == 'matmul':
            a, b = node.args
            out[id(a)] = out.get(id(a), 0.0) + g @ evaluate(b, feed).T
            out[id(b)] = out.get(id(b), 0.0) + evaluate(a, feed).T @ g
        # ... 其余算子同理 ...
    return out

# 编译:把图生成成一段 Python 源码,再 exec 出来
fn, src = compile_fn(z, ['X', 'W', 'b'], {id(X): 'X', id(W): 'W', id(b): 'b'})

实跑输出:

--------------------------------------------------------------------
Theano:先建符号图、再编译成函数
--------------------------------------------------------------------
[1] Theano 的第一步不是算,是「记」
  下面这几行跑完,一个数都没算出来,只是把算式记成了一张图:
    h = tanh(X 乘 W 加 b)
    z = sum(h 乘 h)
  图里的每个节点只知道「我是什么运算、我的输入是谁」。
  这跟 numpy 正好相反:numpy 写一行算一行,Theano 写完还不算。

[2] 图有多大
  表达式                    节点数  说明
  X 乘 W 加 b               5       3 个输入加 2 个运算
  再 tanh 再平方求和        8       再加 4 个

  节点数是数出来的,不是估的。节点越多,编译和求值的开销越大。

[3] 自动求导:符号图能自己倒着走一遍
  给定 z = sum(tanh(XW+b) 的平方),手工推 dW 要写好几行链式法则。
  符号图的做法是从 z 出发,按节点的反向规则往回传:
  变量      形状      梯度范数
  X         (8, 4)    0.412363
  W         (4, 3)    1.723832
  b         (3,)      0.568059

[4] 梯度对不对:拿数值差分当裁判
  符号求导与数值差分的最大相对误差:2.62e-10
  这个量级说明图的导数规则写对了。

[5] 第二步:编译
  只建图不编译,每次求值都要递归走一遍图、按 op 名字查分支。
  编译就是把图「压」成一段普通代码,以后直接调。
  编译出来的源码是这样(这是真的 exec 出来的那段):
    def _compiled(X, W, b):
        t0 = X @ W
        t1 = t0 + b
        t2 = np.tanh(t1)
        t3 = t2 * t2
        t4 = t3.sum()
        return t4
  做法                        1000 次耗时     相对
  每次重新走图                10.90 毫秒      1.00 倍
  调用编译好的函数            9.55 毫秒       1.14 倍

  编译把「递归查表」变成了「一行行顺序执行」,这次只快了一成。
  原因很实在:图只有 8 个节点,时间大头花在 Python 的函数调用上,
  省掉的那点查表开销根本不算什么。
  真实网络的图有上万个节点,差距才会显出来——这一点是推断,本次没测。
  但即便只快一成,Theano 也必须先编译:它还得靠图去做显存分配和算子融合。

[6] 编译期优化:常量折叠
  写法                          节点数
  X 乘 全 1 矩阵 再加 全 2 矩阵 5
  先把两个常量加起来,再跟 X 相加3

  两个常量之间没有未知数,编译时就能算完,这叫常量折叠。
  Theano 会在编译前做一批这样的改写:折叠常量、合并重复子式、
  把能融成一个 kernel 的运算拼起来。这些改写不改结果,只改速度。

[7] 这套思想留下了什么
  Theano 2017 年停止维护,但「符号图加编译」这件事被继承了下来。
  TensorFlow 1.x 的计算图、PyTorch 的 TorchScript、
  ONNX 的导出格式,骨子里都是同一套:先把算式记成图,再优化,再执行。

evaluate 里的 cache 不能省。 同一个节点被两条路径引用时(h 同时进了 h 乘 h 的两边),没有 cache 就会算两遍, 白白浪费时间。 这就是「公共子表达式消除」,只不过这里是运行时版本。 grad 那个函数是本篇的重点。 第一版写成「一边递归一边累加」,梯度校验的相对误差是 1.50e+00——完全不对。 改成先 _order 排序、再 reversed 逆序传,误差降到 2.62e-10。 这个 bug 值得亲手踩一次:它不会报错,只会给你一个看起来合理的错答案。 编译那一段是 exec 出来的。 这类写法在生产代码里要慎用(拼字符串有注入风险), 但框架里天天在干这件事——TensorFlow 的 XLA、PyTorch 的 TorchScript 都在生成代码。

30 秒实验

  1. 把 grad 改回「一边递归一边累加」的写法,再跑一次梯度校验。 误差会从 2.62e-10 跳到 1.50e+00,这是最好的反面教材。
  2. 把图加深到 20 层再测编译的加速比。 加速比会明显上去,因为节点一多,递归查表的开销就压不住了。
  3. 把 tanh 换成 relu,补上它的求导规则。 只需要加一行 elif node.op == 'relu',这就是符号图最舒服的地方:加一个算子只改一处。

常见误区

  1. 「符号图就是延迟计算,没什么特别。」 延迟只是表象。 真正值钱的是「拿到全局结构」,自动求导、常量折叠、算子融合全都建立在这一点上。
  2. 「自动求导一定比自己手推准。」 不一定。这次第一版就因为「边递归边累加」写错了,误差 1.50e+00,而且不报错。 自动求导只是把规则机械化,规则写错了一样错。 所以任何自动求导的实现,都要拿数值差分对一次账。
  3. 「编译一定能让程序变快。」 这次只快了一成。图太小的时候,编译省下的开销抵不过额外的一层间接。 编译的收益随图的规模增长,不是无条件的。
自测三问(点开对答案) 1. **为什么自动求导必须先拓扑排序?** —— **因为一个节点可能被多条路径引用,梯度要等所有路径都传到了才完整。** **边递归边传的话,先到的那条路径会用一个不完整的梯度继续往下走,结果就错了。** 2. **常量折叠为什么能省时间?** —— **因为它把「运行时才算的运算」挪到了「编译时就算完」。** **常量之间的运算结果不随输入变化,算一次就够,没必要每次前向都重算。** 3. **Theano 2017 年就停止维护了,为什么还要学?** —— **因为它定下的范式活了下来。** **TensorFlow 1.x 的计算图、PyTorch 的 TorchScript、ONNX 的中间表示,骨子里都是「先记图、** **再优化、再执行」。** **学会这一套,换任何框架都能看懂它在干什么。**

符号图解决了「怎么算」。 下一个问题是「怎么描述一次实验」——下一节的 Pylearn2 给的答案是:把训练参数全写进配置文件。

相关笔记