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

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. 把序列长度范围改成 1 到 50 再跑。 补零到底的做法会浪费得更狠,动态图的优势会扩大到六成以上。
  2. 把分桶改成 8 个桶(每桶 5 步一个)再跑。 总步数会更接近动态图的 1065,但桶越多,批次越小,GPU 利用率反而可能下降。
  3. 把 rnn_static 的补零改成「复制最后一步」而不是补零。 结果还是对不上——任何「填」的做法都会污染状态,只有 mask 或动态图才干净。

常见误区

  1. 「补零是标准操作,大家都在用。」 补零必须配 mask 才正确。这次实测里,只补零不 mask,结果是错的。 很多教程只写补零、忘了 mask,读者照抄就会踩这个坑。
  2. 「动态图一定更快。」 动态图省的是「算了没用的步数」,但它没法做全局优化。 对固定形状的任务,静态图反而可能更快——因为能融合算子、提前分配内存。
  3. 「Chainer 失败了,所以 define-by-run 这条路走错了。」 恰恰相反。它停下来是因为 PyTorch 把这条路走通了,没必要再维护一份。 框架会死,范式会活。
自测三问(点开对答案) 1. **为什么「补零」会让结果变错?** —— **因为补进去的零不是「什么都没发生」,它是一次真实的输入。** **循环网络每步都会用这一步的输入更新隐状态,喂零进去,h 就被 tanh 又搅了一遍——状态变了。** 2. **mask 能保证结果对,为什么不能省算力?** —— **因为 mask 是在「算完之后」把结果丢掉,算这一步的工作已经做完了。** **要省算力,必须在「算之前」就知道不用算——那就是动态图。** 3. **分桶为什么是个折中?** —— **因为它把「补到 20」换成了「补到批内最长」,省了一部分,但批内还是有浪费。** **而且桶的边界是人定的,换个数据分布就得重调——这部分成本转嫁给了人。**

Chainer 的 define-by-run 和上一节 Caffe 的先定义后运行,正好是两个极端。 下一节的 TensorFlow 1.x 站在静态图那一端,而且是把它做到最重的一个。

相关笔记