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 秒实验
- 把
grad改回「一边递归一边累加」的写法,再跑一次梯度校验。 误差会从 2.62e-10 跳到 1.50e+00,这是最好的反面教材。 - 把图加深到 20 层再测编译的加速比。 加速比会明显上去,因为节点一多,递归查表的开销就压不住了。
- 把
tanh换成relu,补上它的求导规则。 只需要加一行elif node.op == 'relu',这就是符号图最舒服的地方:加一个算子只改一处。
常见误区
- 「符号图就是延迟计算,没什么特别。」 延迟只是表象。 真正值钱的是「拿到全局结构」,自动求导、常量折叠、算子融合全都建立在这一点上。
- 「自动求导一定比自己手推准。」 不一定。这次第一版就因为「边递归边累加」写错了,误差 1.50e+00,而且不报错。 自动求导只是把规则机械化,规则写错了一样错。 所以任何自动求导的实现,都要拿数值差分对一次账。
- 「编译一定能让程序变快。」 这次只快了一成。图太小的时候,编译省下的开销抵不过额外的一层间接。 编译的收益随图的规模增长,不是无条件的。
自测三问(点开对答案)
1. **为什么自动求导必须先拓扑排序?** —— **因为一个节点可能被多条路径引用,梯度要等所有路径都传到了才完整。** **边递归边传的话,先到的那条路径会用一个不完整的梯度继续往下走,结果就错了。** 2. **常量折叠为什么能省时间?** —— **因为它把「运行时才算的运算」挪到了「编译时就算完」。** **常量之间的运算结果不随输入变化,算一次就够,没必要每次前向都重算。** 3. **Theano 2017 年就停止维护了,为什么还要学?** —— **因为它定下的范式活了下来。** **TensorFlow 1.x 的计算图、PyTorch 的 TorchScript、ONNX 的中间表示,骨子里都是「先记图、** **再优化、再执行」。** **学会这一套,换任何框架都能看懂它在干什么。**符号图解决了「怎么算」。 下一个问题是「怎么描述一次实验」——下一节的 Pylearn2 给的答案是:把训练参数全写进配置文件。
- ☐ (本节完,下一节 7.3 Pylearn2)