Chainer 提出的做法叫 define-by-run:图不是事先画好的,而是每次前向时走一步画一步。 对固定形状的输入,它和静态图算出来的结果一模一样。 差别出现在形状不固定的时候。 实测 100 条长度 1 到 20 的序列:静态图补零到底要跑 2000 步,而且结果还是错的; 补零加 mask 结果对了,但一步没省。 动态图只跑 1065 步,省了 46.8%。 工程上的折中是分桶:每批按批内最长跑,总共 1270 步,比补零省 36.5%,比动态图多花 19.2%。
前面四节的工具都是「先定义后运行」。 这一节换一个方向,也顺便解释了为什么 2019 年之后整个行业都转向了动态图。 这一节还带出一个更基础的问题:处理变长数据时,「补零」这件事本身就会引入错误, 不只是浪费算力。
一、图什么时候画
静态图:先声明整个网络,再往里灌数据。结构在跑之前就定死了。 动态图:每来一批数据,前向走一步记一步,走完图才完整。
| 对比项 | 静态图 | 动态图 |
|---|---|---|
| 图的形状 | 跑之前就固定 | 每次前向都可能不同 |
| 分支怎么写 | 要用特殊算子(如 tf.cond) | 直接写 if |
| 调试 | 看不到中间值,要显式取 | 随时 print |
| 优化空间 | 能全局改写、能提前分配内存 | 只能看到当下这一步 |
| 两边各有各的道理:静态图拿全局信息换优化,动态图拿灵活性换开发速度。 | ||
| 2015 年前后静态图占上风,2019 年之后动态图占上风——因为硬件快了,人的时间比机器的时间贵。 |
二、补零的三个版本
拿一个最简单的循环网络举例,序列长度从 1 到 20 不等。 静态图必须先声明步数,只能一律按 20 步跑:
| 做法 | 总步数 | 结果对上了吗 |
|---|---|---|
| 静态图,补零到底 | 2000 | 对不上 |
| 静态图,补零加 mask | 2000 | 对上 |
| 动态图,按实际长度跑 | 1065 | 基准 |
| 第一行对不上,这是个真问题,不是实现 bug。 | ||
| 补进去的零还在继续参与状态更新:h 一直在被 tanh 搅, | ||
| 跑完 20 步之后和只跑真实长度已经不是同一个值了。 | ||
| 所以静态图处理变长序列必须加 mask,把补出来的那几步的更新掐掉。 | ||
写起来是纯张量运算,静态图做得到:h = m[t] 乘 h_new 加 (1 减 m[t]) 乘 h。 |
||
| 可第二行的步数一步没少,还是 2000 步。 | ||
| mask 只保证结果对,不省算力。 | ||
| 真正省算力的只有动态图:1065 步,省 46.8%。 |
三、工程上的折中:分桶
静态图也不是只能一律补到 20。 把长度相近的放一批,每批按批内最长跑,这叫 bucketing,是静态图时代做翻译模型的标准做法。
| 长度区间 | 条数 | 补到 | 这批步数 |
|---|---|---|---|
| 1 到 5 步 | 23 | 5 | 115 |
| 6 到 10 步 | 27 | 10 | 270 |
| 11 到 15 步 | 23 | 15 | 345 |
| 16 到 20 步 | 27 | 20 | 540 |
| 四批加起来 1270 步:比一律补到 20 步的 2000 步省了 36.5%, | |||
| 但比动态图的 1065 步还是多花 19.2%。 | |||
| 这就是折中的本质:能省一大半,但省不到极致。 | |||
| 而且桶的边界得人自己定——换一批长度分布不同的数据,还得重新调。 | |||
| 这种「人工调参换性能」的活,恰恰是动态图让人省掉的。 |
四、代价与结局
动态图不是白拿的。每次前向都要重建图,这是额外开销。 实测 30 次前向:8 层 1.06 毫秒、30 层 3.59 毫秒,基本线性。 说明开销主要在真正的矩阵乘上,建图那点开销在 Python 里就是创建几个对象。 所以到了 PyTorch 时代,大家干脆都转了动态图。 Chainer 的结局很有意思:它 2015 年由 Preferred Networks 发布, 是最早把 define-by-run 做开的框架;2019 年停止开发,官方把用户引导到 PyTorch。 它输掉了框架竞争,却赢下了范式之争。 今天你在 PyTorch 里随手写的 if 和 for,都是它当年争来的。
五、动手 Demo:变长序列:补零、加 mask、动态图、分桶,四种做法对比
一个最简的循环网络:16 维输入、8 维隐状态、4 维输出。 100 条序列,长度在 1 到 20 之间随机。 四种做法算的是同一件事,所以结果必须能对上——对不上的就是 bug 或设计缺陷。
def rnn_static(seq, steps):
u"""步数写死,短的补零。补完的零还会继续参与状态更新。"""
h = np.zeros(DH)
for t in range(steps):
x = seq[t] if t < len(seq) else np.zeros(DIN)
h = np.tanh(x @ Wx + h @ Wh)
return h @ Wy
def rnn_masked(seq, steps):
u"""正解:补零再加 mask,把补出来的那几步的更新掐掉。"""
h = np.zeros(DH)
m = np.zeros(steps)
m[:len(seq)] = 1.0
for t in range(steps):
x = seq[t] if t < len(seq) else np.zeros(DIN)
h_new = np.tanh(x @ Wx + h @ Wh)
h = m[t] * h_new + (1.0 - m[t]) * h # 纯张量运算,静态图做得到
return h @ Wy
def rnn_dyn(seq):
u"""动态图:长度就是 len(seq),一步不多跑。"""
h = np.zeros(DH)
for x in seq:
h = np.tanh(x @ Wx + h @ Wh)
return h @ Wy
# 分桶:把长度相近的放一批,每批按批内最长跑
for lo, hi in ((1, 5), (6, 10), (11, 15), (16, 20)):
grp = [s for s in SEQS if lo <= len(s) <= hi]
mx = max(len(s) for s in grp)
outs = [rnn_masked(s, mx) for s in grp]
实跑输出:
--------------------------------------------------------------------
Chainer:define-by-run,图是每次前向现画的
--------------------------------------------------------------------
[1] 静态图和动态图,差别只在「图什么时候画」
静态图(Theano、Caffe、TensorFlow 1.x):先把整个网络画好,再往里灌数据。
动态图(Chainer、PyTorch):每来一批数据,前向走一步画一步。
对固定形状的输入,两者算出来的结果一模一样。
差别出现在「形状不固定」的时候。
[2] 这就是那个不该固定形状的例子:变长序列
一个最简单的循环网络,每步做 h = tanh(x 乘 Wx 加 h 乘 Wh)。
序列有多长,就该跑多少步。但静态图必须先声明步数。
项目 值
序列条数 100 条
最短 / 最长 1 / 20 步
总步数(真实) 1065 步
平均步数 10.7 步
[3] 三条路各跑一遍,数一数到底算了多少步
做法 总步数 耗时 结果对上了吗
静态图,补零到底 2000 11.24 毫秒 对不上
静态图,补零加 mask 2000 19.86 毫秒 对上
动态图,按实际长度跑 1065 6.13 毫秒 基准
第一行对不上,这是个真问题:补进去的零还在继续参与状态更新,
h 一直在被 tanh 搅,跑完 20 步之后和只跑真实长度已经不是同一个值了。
所以静态图处理变长序列必须加 mask,把补出来的那几步的更新掐掉——就是第二行。
可第二行的步数一步没少,还是 2000 步:mask 只保证结果对,不省算力。
真正省算力的只有第三行:1065 步,比 2000 步少 46.8%。
换算成乘加次数:静态 38.40 万次,动态 20.45 万次。
[4] 真实工程里的折中:按长度分桶
静态图也不是只能一律补到 20。把长度相近的放一批,每批按批内最长跑——
这叫 bucketing,是静态图时代做翻译模型的标准做法。下面真跑:
长度区间 条数 补到 这批步数 结果对上了吗
1 到 5 步 23 5 115 对上
6 到 10 步 27 10 270 对上
11 到 15 步 23 15 345 对上
16 到 20 步 27 20 540 对上
分桶后总共 1270 步:比一律补到 20 步的 2000 步省了 36.5%,
但比动态图的 1065 步还是多花 19.2%。
这就是静态图时代的典型折中:能省一大半,但省不到极致,
而且桶的边界得人自己定——换一批长度分布不同的数据,还得重新调。
[5] 代价:每次前向都要重新建图
深度 30 次前向耗时 相对 8 层
8 层 0.95 毫秒 1.00 倍
16 层 2.49 毫秒 2.62 倍
30 层 5.70 毫秒 6.00 倍
耗时随深度基本线性增长,说明时间花在真正的矩阵乘上。
动态图额外的开销是「建图」这一步——Python 里就是创建几个对象,
相比矩阵乘可以忽略。所以到了 PyTorch 时代,大家干脆都转动态图了。
[6] Chainer 的结局
它 2015 年由 Preferred Networks 发布,是最早把 define-by-run 做开的框架。
2019 年停止开发,官方把用户引导到 PyTorch。
理由是:动态图这套思想已经被 PyTorch 完整吸收,没必要再维护一份。
这一点很有意思——它输掉了框架竞争,却赢下了范式之争。
rnn_masked 里那行 h = m[t] 乘 h_new 加 (1 减 m[t]) 乘 h 是 mask 的标准写法。
它看起来啰嗦,但全是张量运算——静态图能表达,所以这是静态图的「正解」。
rnn_dyn 里没有任何 mask,因为它根本不需要:长度就是 len(seq)。
这就是 define-by-run 最直接的收益——一个本来要绕的弯,直接不存在了。
分桶那一段的关键是「每批用批内最长」,不是「全用 20」。
这一句话的改动,省掉了 36.5% 的算力,代价是要先按长度分组。
30 秒实验
- 把序列长度范围改成 1 到 50 再跑。 补零到底的做法会浪费得更狠,动态图的优势会扩大到六成以上。
- 把分桶改成 8 个桶(每桶 5 步一个)再跑。 总步数会更接近动态图的 1065,但桶越多,批次越小,GPU 利用率反而可能下降。
- 把
rnn_static的补零改成「复制最后一步」而不是补零。 结果还是对不上——任何「填」的做法都会污染状态,只有 mask 或动态图才干净。
常见误区
- 「补零是标准操作,大家都在用。」 补零必须配 mask 才正确。这次实测里,只补零不 mask,结果是错的。 很多教程只写补零、忘了 mask,读者照抄就会踩这个坑。
- 「动态图一定更快。」 动态图省的是「算了没用的步数」,但它没法做全局优化。 对固定形状的任务,静态图反而可能更快——因为能融合算子、提前分配内存。
- 「Chainer 失败了,所以 define-by-run 这条路走错了。」 恰恰相反。它停下来是因为 PyTorch 把这条路走通了,没必要再维护一份。 框架会死,范式会活。
自测三问(点开对答案)
1. **为什么「补零」会让结果变错?** —— **因为补进去的零不是「什么都没发生」,它是一次真实的输入。** **循环网络每步都会用这一步的输入更新隐状态,喂零进去,h 就被 tanh 又搅了一遍——状态变了。** 2. **mask 能保证结果对,为什么不能省算力?** —— **因为 mask 是在「算完之后」把结果丢掉,算这一步的工作已经做完了。** **要省算力,必须在「算之前」就知道不用算——那就是动态图。** 3. **分桶为什么是个折中?** —— **因为它把「补到 20」换成了「补到批内最长」,省了一部分,但批内还是有浪费。** **而且桶的边界是人定的,换个数据分布就得重调——这部分成本转嫁给了人。**Chainer 的 define-by-run 和上一节 Caffe 的先定义后运行,正好是两个极端。 下一节的 TensorFlow 1.x 站在静态图那一端,而且是把它做到最重的一个。
- ☐ (本节完,下一节 7.7 TensorFlow)