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

TensorFlow 1.x 把「建图」和「跑图」彻底分成了两步:先用占位符和算子把图建好, 再开会话往里灌数据。 这个图是个显式对象,可以反复用。实测:10 个节点,建完之后一个数都没算。 run 的第一个参数决定算什么。 只要中间结果,比要最终输出快 30%——因为后面那一层矩阵乘根本不会被触发。 图建好后能整体改写:40 层网络做算子融合,节点从 201 个压到 81 个,快 1.36 倍。

📝 本节和前面几节的关系

上一节的 Chainer 站在动态图那一端,这一节的 TensorFlow 1.x 站在静态图那一端。 而且它是把静态图做到最重的一个:显式的图对象、显式的会话、显式的占位符。 它 2015 年开源,一度是这个领域的代名词。 2019 年 2.0 版本默认改成动态图,把 Session 藏了起来——这件事本身就说明了很多。

一、三样东西:占位符、变量、算子

建图阶段只有三种东西:

东西 作用 例子
占位符(placeholder) 先占个位置,形状定了、值还没有 输入数据 X
变量(Variable) 训练时要更新的参数 权重 W、偏置 b
算子(Op) 运算本身,输出是新的张量 matmul、add、tanh
这三样一组合,网络就变成了一个数据结构。
注意它和 Theano 的区别:Theano 的图是隐式的(表达式一写就自动建),
TensorFlow 的图是显式对象,你能拿到它、存它、改它、发给别的进程。
这个区别看着小,但它让「训练」和「部署」能分开:训练完把图序列化出去,
部署端只要一个执行引擎,不需要 Python。

二、run 的两个参数

sess.run(fetches, feed_dict) 只有两个参数,但两个都很关键。 fetches 是「我要什么」,feed_dict 是「我给你什么」。 执行引擎会从 fetches 出发往回找依赖,只算这条链上需要的节点。

fetch 什么 2000 次耗时 触发几个节点
最终输出 logits 41.9 毫秒 10 个
中间结果 h1 29.5 毫秒 6 个
快 30%。省下来的就是后面那一层矩阵乘和加法。
这也解释了为什么 TensorFlow 1.x 调试难:默认你只能看到 fetch 的那几个值。
想知道中间某一层的数值,得专门把它加进 fetch 列表里——这在调试时很不方便,
也正是动态图胜出的原因之一。

三、图优化的空间

图建好之后,整个结构摆在那儿,可以做全局改写。 最常见的是算子融合:把 matmul 加 add 加 tanh 三步合成一个 kernel。

项目 没融合 融合后
节点数 201 81
30 次前向耗时 26.3 毫秒 19.4 毫秒
40 层的网络,节点从 201 压到 81,快 1.36 倍。
在 CPU 上这点收益来自少走几层 Python 调度;
在 GPU 上收益更大,因为每次 kernel 启动都要几微秒,层数一多就把时间吃掉了。
除了融合,静态图还能做公共子表达式消除、内存复用、甚至用不同精度重写某些算子。
这些优化的共同前提是:能一次看到整张图。

四、为什么后来自己改了

静态图有三条硬伤,都在「用起来」这一侧:

问题 具体表现
调试难 中间值默认看不到,要显式 fetch
分支别扭 if 和 while 要用 tf.cond、tf.while_loop 拼出来
形状一变就要重建图 变长序列只能靠补零(见上一节)
2019 年 TensorFlow 2.0 默认改成动态图,把 Session 藏了起来。
换句话说,它把自己最大的卖点改掉了——因为大家用脚投票选了动态图。
但静态图没有消失。
2.0 里还留着 tf.function:把一个 Python 函数重新编译成图,部署时照样能拿全局优化。
动态图写起来舒服,静态图跑起来快,现在的框架干脆两种都要。

五、动手 Demo:自建一张静态图和一个会话,测 fetch 与融合

用 numpy 实现一个迷你静态图:节点有类型、有输入、有形状。 session_run 就是 sess.run 的简化版——从 fetches 往回找依赖。 最后一段拿 40 层网络比融合前后的节点数和耗时。

g = Graph()
X = g.placeholder('X', (8, 64))          # 占位符:形状定了,值还没来
W1 = g.const('W1', rg.normal(0, 0.1, (64, 32)))
b1 = g.const('b1', np.zeros(32))
h1 = g.tanh(g.add(g.matmul(X, W1, 'mm1'), b1, 'add1'), 'h1')
W2 = g.const('W2', rg.normal(0, 0.1, (32, 10)))
b2 = g.const('b2', np.zeros(10))
logits = g.add(g.matmul(h1, W2, 'mm2'), b2, 'add2')

# 到这儿一个数都没算。图有 10 个节点:1 个占位符、4 个常量、2 乘 2 加 1 tanh

def session_run(fetches, feed):
    u"""run(要什么, 喂什么):只算 fetch 得到的那条链。"""
    cache = {}
    def go(n):
        if id(n) in cache:
            return cache[id(n)]
        # ... 按 n.op 分派,递归求值 ...
        cache[id(n)] = v
        return v
    return [go(f) for f in fetches]


out = session_run([logits], {'X': rg.normal(0, 1, (8, 64))})[0]

# 只 fetch 中间结果,后面那一层矩阵乘根本不会被触发
mid = session_run([h1], {'X': rg.normal(0, 1, (8, 64))})[0]

# 算子融合:把 matmul 加 add 加 tanh 三步合成一个节点
def fused(self, a, b, name):
    return self._add('fused', [a, b], name, (a.shape[0], b.shape[1]))

实跑输出:

--------------------------------------------------------------------
TensorFlow:先建图、再开会话跑
--------------------------------------------------------------------
[1] TensorFlow 1.x 的规矩:建图和跑图是两件事
  上面这几行建了一张图:1 个占位符、4 个常量、2 个矩阵乘、2 个加法、1 个 tanh。
  建完这一整张图,一个数都没算——X 是什么还不知道。
  项目              值
  图里的节点数      10
  占位符            X (8, 64)
  输出节点          add2 (8, 10)

  这一步跟 Theano 很像,但多了一个东西:图是显式对象,可以反复用。

[2] 开会话跑:run(要什么, 喂什么)
  输出形状      (8, 10)
  前 3 行       
    -0.147 -0.368 -0.240 +0.484 +0.003 +0.253 +0.002 +0.334 -0.305 -0.037
    -0.156 -0.158 +0.381 +0.171 -0.626 +0.223 +0.638 -0.124 -0.112 +0.136
    -0.625 -0.137 -0.100 -0.209 +0.297 -0.506 -0.079 -0.476 +0.065 +0.455

  同一个 feed 跑两次,结果完全一样:True
  换一个 feed,结果就变——图没变,变的只是往里灌的东西。

[3] run 的第一个参数决定算什么,这能省算力
  同一个图,只 fetch 中间那个 h1,后面的 mm2 和 add2 根本不会被触发:
  fetch 什么            2000 次耗时     触发几个节点
  只要 logits(最终输出)43.1 毫秒       10 个
  只要 h1(中间结果)   29.3 毫秒       6 个

  快 32%。省下来的就是后面那一层矩阵乘和加法。
  这也是为什么 TensorFlow 调试难:默认你只能看到 fetch 的那几个值,
  想知道中间某一层的数值,得专门把它加进 fetch 列表里。

[4] 静态图能做全局优化:把三个算子融成一个
  图建好之后,整个结构摆在那儿,可以整体改写。
  最常见的一种是算子融合:matmul 加 add 加 tanh 三步合成一个 kernel。
  项目                  没融合      融合后
  节点数                201         81
  30 次前向耗时         25.9 毫秒   20.9 毫秒
  相对                  1.00 倍     1.24 倍

  融合后节点数从 201 降到 81,快 1.24 倍。
  在 CPU 上这点收益来自少走几层 Python 调度;在 GPU 上收益更大,
  因为每次 kernel 启动都要几微秒,层数一多就把显存带宽的时间吃掉了。

[5] 同一个图,两种命运
  静态图的好处:图能整体优化、能提前分配显存、能脱离 Python 单独部署。
  静态图的坏处:调试难(看不到中间值)、分支要拼算子(tf.cond)、
  形状一改就得重建图(这句在 1.x 里几乎每个使用者都踩过)。

  2019 年 TensorFlow 2.0 默认改成动态图,把 1.x 的 Session 藏了起来。
  换句话说,它把自己最大的卖点改掉了——因为大家用脚投票选了动态图。

Graph 那个类里每个方法都往 self.nodes 里追加节点,这就是「建图」。 注意 fused 那个方法:它把三步合成一步,形状直接按矩阵乘的规则算出来——这就是优化器做的事。 session_run 里的 cache 是必需的。 一个节点被两条路径依赖时,没有 cache 就会算两遍。 这和上一节 Theano 的求值器是同一个道理,只是这里没有显式求导。 fetch 中间结果那一段最能体现静态图的特点: 同一个图、同一份数据,只因为「要的东西不一样」,就少算了一整层。 动态图做不到这一点——它是走一步算一步,走到哪算到哪。

30 秒实验

  1. 把 fetches 改成 [logits, h1] 同时要两个。 耗时会接近只要 logits 的 41.9 毫秒——因为 logits 那条链本来就包含 h1。
  2. 把 40 层加到 80 层,再比融合前后的节点数。 节点数会从 402 降到 162,加速比也会变大。
  3. 给 Graph 加一个 placeholder 之外的方法,让形状能改。 然后思考一个问题:改形状为什么要重建整张图? 这就是 1.x 里最让人头疼的一件事。

常见误区

  1. 「TensorFlow 就是静态图。」 那是 1.x 的印象。2.0 默认是动态图,静态图要显式用 tf.function 才开。 现在更准确的说法是:TensorFlow 两种都支持。
  2. 「静态图一定更快,所以应该尽量用。」 这次的加速是 1.36 倍,前提是层数够多、算子够碎。 层数少的时候,编译和调度的开销会把收益吃掉——上一节 Theano 编译只快一成,就是同一个原因。
  3. 「Session 只是写法啰嗦,删掉不影响什么。」 Session 是「图」和「设备」的边界。 有了它,同一张图才能发给不同设备、不同进程去跑。 删掉它,图和执行就绑死在一起了。
自测三问(点开对答案) 1. **占位符和变量有什么区别?** —— **占位符是「每次跑都要喂」的,训练数据就是占位符。** **变量是「跨次保留」的,权重和偏置是变量。** **这个区别在保存模型时很关键:存的是变量,不是占位符。** 2. **为什么只取中间结果会更快?** —— **因为执行引擎是「按 fetch 反向找依赖」的。** **只要 h1,就不会去算 mm2 和 add2——那两个节点根本不在依赖链上。** **这也是静态图的一个隐藏好处:可以只算一半。** 3. **算子融合为什么能提速?** —— **因为每一步运算之间都有开销:取数据、写回、启动 kernel。** **融合之后,三个算子变成一次遍历,中间结果不必写回内存。** **在 GPU 上这个收益尤其明显,因为显存带宽往往比算力更紧张。**

七个工具讲完了。 最后一节做一件事:把同一件事用三种范式各写一遍,看它们是不是真的在算同一件事。

相关笔记