1.5 从 Graph 到 Eager:两种执行模式的调度差异


文档摘要

1.5 从 Graph 到 Eager:两种执行模式的调度差异 本节摘要:TensorFlow 2.x 默认 Eager Execution——每行代码立即执行、立即返回结果;而 能把函数"跟踪"成静态计算图,换取整体优化后的执行速度。本节讲透两种模式的调度差异:Eager 像逐张签发的即席工单,图模式像一次排好全部工序的批量排程;并给出图模式的三大陷阱——跟踪次数、Python 副作用、动态形状。这是全册认知密度最高的一节,第 3 章的 fit 与第 5 章的自定义训练循环都建立在它之上。 读完你应当能做到 阅读完本节,你应当能够: 说清 Eager 与图执行各自的调度节奏,以及 2.x 为什么默认 Eager; 用 装饰函数并解释 ConcreteFunction 的生成过程;

1.5 从 Graph 到 Eager:两种执行模式的调度差异

本节摘要:TensorFlow 2.x 默认 Eager Execution——每行代码立即执行、立即返回结果;而 @tf.function 能把函数"跟踪"成静态计算图,换取整体优化后的执行速度。本节讲透两种模式的调度差异:Eager 像逐张签发的即席工单,图模式像一次排好全部工序的批量排程;并给出图模式的三大陷阱——跟踪次数、Python 副作用、动态形状。这是全册认知密度最高的一节,第 3 章的 fit 与第 5 章的自定义训练循环都建立在它之上。

读完你应当能做到

阅读完本节,你应当能够:

  1. 说清 Eager 与图执行各自的调度节奏,以及 2.x 为什么默认 Eager;
  2. @tf.function 装饰函数并解释 ConcreteFunction 的生成过程;
  3. 预判 Python 语句在图模式下会被如何转换或静默忽略;
  4. 诊断"图函数越跑越慢""循环里打印不生效"两类典型问题。

两种节奏:即席工单与批量排程

1.x 时代的 TensorFlow 只有图模式:先用 placeholder 搭好整张图,再开 Session 喂数据执行——写得痛苦,跑得飞快。2.x 推翻了这个默认:每一行立即执行,跟 NumPy 手感一致,调试时随时打印。代价是失去整体优化机会:每行代码都是一次独立的 Python 到运行时的调度开销,小算子密集的代码里,调度开销可能超过计算本身。@tf.function 是两种模式的合体方案:平时用 Eager 写与调试,性能敏感处加装饰器,函数体被自动登记成图。

import tensorflow as tf # Eager:立即执行,立即可打印 x = tf.constant([1.0, 2.0]) y = x * 10 print(y) # 输出:tf.Tensor([10. 20.], shape=(2,), dtype=float32) # 图模式:装饰后,第一次调用发生"跟踪" @tf.function def scale(z): print("tracking once") # 注意:这只在跟踪时执行 return z * 10 print(scale(tf.constant([1.0, 2.0]))) # 输出顺序: # tracking once # tf.Tensor([10. 20.], shape=(2,), dtype=float32) print(scale(tf.constant([3.0]))) # 只输出张量结果,不再打印 tracking once —— 复用已生成的图 # tf.Tensor([30.], shape=(1,), dtype=float32)

第二次调用没有打印 "tracking once",说明函数体根本没执行——执行的是第一次调用时登记好的图。这就是图模式的本质:第一次调用是登记,之后每次调用是按图执行。

图 6 两种模式下的调度时序对比

图 6 两种模式下的调度时序对比

跟踪机制:ConcreteFunction 从哪来

@tf.function 生成的是一个分派器:按输入的"签名"(形状与类型组合)缓存多份 ConcreteFunction。签名变一次,就重新跟踪一次:

@tf.function def add_one(z): print("new trace for", z.shape) return z + 1 add_one(tf.constant([1.0, 2.0])) # 签名 (2,) float32 → 跟踪一次 add_one(tf.constant([3.0, 4.0])) # 同签名 → 复用 add_one(tf.constant([1.0, 2.0, 3.0])) # 签名 (3,) → 再跟踪一次 add_one(tf.constant(1)) # 签名 () int32 → 又跟踪一次 # 输出中 "new trace for" 只出现在第 1、3、4 次调用

这解释了两类高频故障。其一,在循环里不断用新形状调用图函数,每个形状各留一份图,内存与首调耗时都暴涨。其二,输入是 Python 基本类型时按值生成签名,整型输入会被当作常量烧进图里——每次换值都触发重新跟踪:

@tf.function def n_times(n): return tf.range(n) * 2 print(n_times(3).numpy()) # [0 2 4],跟踪时 n=3 被烧成常量 print(n_times(5).numpy()) # [0 2 4 6 8],又一次跟踪,n=5 再烧进去 # 正确姿势:用张量签名 @tf.function(input_signature=[tf.TensorSpec(shape=(), dtype=tf.int32)]) def n_times_fixed(n): return tf.range(n) * 2 print(n_times_fixed(tf.constant(7)).numpy()) # 输出:[ 0 2 4 6 8 10 12] —— 一个签名,任意数值

Python 语句的图内命运

函数体里写的 Python 代码,跟踪时只有一部分会被转换(AutoGraph)。print 只在跟踪时跑一次;iffor 若条件或迭代对象是张量,会被转换成图节点;若两者是 Python 值,则在跟踪时被直接求值展开。副作用类语句最容易静默失效:

@tf.function def loop_python(n): # n 是 Python int 时 total = tf.constant(0.0) for i in range(n): # 跟踪时循环被展开:n 次迭代全部烧进图 total += tf.cast(i, tf.float32) return total @tf.function def loop_tensor(n): # n 是张量时 total = tf.constant(0.0) for i in tf.range(n): # tf.range 保留为动态循环节点,不展开 total += tf.cast(i, tf.float32) return total print(loop_python(4).numpy()) # 6.0 print(loop_tensor(tf.constant(4)).numpy()) # 6.0 # 两者结果一致,但 loop_python(10000) 会生成一万节点的巨图, # loop_tensor 只有几个节点。

经验法则:图函数体里,张量运算用 tf 系 API,循环与分支尽量以张量为条件;需要真正打印调试时,用 tf.print 而不是 print,前者会作为节点保留进图、每次执行都输出。

工程取舍:什么时候套装饰器

默认建议很朴素:先 Eager 跑通,再对热点路径套 @tf.function。收益最大的场景是批次小、算子碎、Python 调度占比高的代码;单次矩阵乘法就占满 GPU 几十毫秒的大算子场景,收益有限。另有一个隐性收益:Keras 的 fit 内部正是把每个训练步跟踪成图反复执行(3.3 节验证),所以即便你不手写装饰器,训练也已经在图模式下跑——理解本节,就是在理解 fit 的内部。

⚠️ 常见坑:图函数里修改 Python 列表或字典。跟踪时这些修改只发生一次,之后每次调用都沿用跟踪期的旧值——数据悄悄变成"常量",训练曲线诡异地走平,多半就是这个坑。

💡 关键直觉:把 @tf.function 读成"这个函数以后按排程单批量执行,别在里面写只该执行一次的 Python 副作用"。带着这句话读任何图模式代码,行为都变得可预测。

本节要点回顾

  • 默认 Eager:即席工单,手感好、易调试;@tf.function 换批量排程,省调度开销。
  • 首调即跟踪:ConcreteFunction 按签名缓存;签名变化触发重新跟踪。
  • Python 值会被烧进图:整型参数、range 循环都可能展开为常量;用张量与 tf.range 保持动态。
  • 副作用只执行一次:print 与 Python 容器修改是两大静默失效源,图内调试用 tf.print。
  • Keras fit 就是图模式:训练步被自动跟踪,本节机制每日都在被你使用。

下节认识图上唯一能被反复写入的东西——变量。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U