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 秒实验
- 把
fetches改成[logits, h1]同时要两个。 耗时会接近只要 logits 的 41.9 毫秒——因为 logits 那条链本来就包含 h1。 - 把 40 层加到 80 层,再比融合前后的节点数。 节点数会从 402 降到 162,加速比也会变大。
- 给
Graph加一个placeholder之外的方法,让形状能改。 然后思考一个问题:改形状为什么要重建整张图? 这就是 1.x 里最让人头疼的一件事。
常见误区
- 「TensorFlow 就是静态图。」
那是 1.x 的印象。2.0 默认是动态图,静态图要显式用
tf.function才开。 现在更准确的说法是:TensorFlow 两种都支持。 - 「静态图一定更快,所以应该尽量用。」 这次的加速是 1.36 倍,前提是层数够多、算子够碎。 层数少的时候,编译和调度的开销会把收益吃掉——上一节 Theano 编译只快一成,就是同一个原因。
- 「Session 只是写法啰嗦,删掉不影响什么。」 Session 是「图」和「设备」的边界。 有了它,同一张图才能发给不同设备、不同进程去跑。 删掉它,图和执行就绑死在一起了。
自测三问(点开对答案)
1. **占位符和变量有什么区别?** —— **占位符是「每次跑都要喂」的,训练数据就是占位符。** **变量是「跨次保留」的,权重和偏置是变量。** **这个区别在保存模型时很关键:存的是变量,不是占位符。** 2. **为什么只取中间结果会更快?** —— **因为执行引擎是「按 fetch 反向找依赖」的。** **只要 h1,就不会去算 mm2 和 add2——那两个节点根本不在依赖链上。** **这也是静态图的一个隐藏好处:可以只算一半。** 3. **算子融合为什么能提速?** —— **因为每一步运算之间都有开销:取数据、写回、启动 kernel。** **融合之后,三个算子变成一次遍历,中间结果不必写回内存。** **在 GPU 上这个收益尤其明显,因为显存带宽往往比算力更紧张。**七个工具讲完了。 最后一节做一件事:把同一件事用三种范式各写一遍,看它们是不是真的在算同一件事。
- ☐ (本节完,下一节 7.8 小结)