先读 Go 的值与内存 和 并发与取消。本章补的是理解优化所需的基础,不要求你先学编译原理。最后再接 真实 PLM 实现。
1. 先有一个普通程序
这里的 PLM 来自 Prepare–Linearize–Materialize:准备可提前的工作、在逻辑上接受某次调用的结果、把值或错误交给 Python。本版把后两部分集中到一个 resolve 入口,没有另做“延后交付结果”的优化。
“线性化”在并发理论里,是把多步骤操作看成在调用与返回之间的某个逻辑点发生,并用它解释操作顺序;它不是线性代数,也不是后面说的线性内存。这份小实现采用固定快照和请求匹配来约束结果采用,不声称实现了任意外部服务的完整线性一致性协议。先理解实际的 prepare / resolve 路径即可,不要把三个名字当成必须照搬的三套框架。
想象我们在算一本书加运费的总价:
price = tool("lookup", key="book")
shipping = tool("lookup", key="shipping")
result = price * 2 + shipping
这个完整输入可以交给本项目的 Python Guest 执行;它不是直接用系统 Python 运行的脚本,因为 tool 由我们的 bootstrap 提供。
普通执行顺序是:查单价并等结果 → 查运费并等结果 → 算总价。这里有两个问题:
- 查运费之前,真的需要知道单价吗?
- 如果能先查运费,是否也能把运费的错误提前抛出来?
第一个问题关乎依赖;第二个问题关乎程序行为是否被改变。PLM 不是把每一行都扔给一个线程,而是只调整一小类工具调用的启动时机。
2. 数据依赖:一个值从哪里来?
数据依赖是:后一件事需要前一件事产生的值。
查书价只需要键 book,查运费只需要键 shipping。它们不使用对方的返回值,所以这两个读取没有相互的数据依赖。但是计算总价需要两个返回值。
换一个程序:
key = tool("lookup", key="choose")
price = tool("lookup", key=key)
shipping = tool("lookup", key="shipping")
result = price * 2 + shipping
这里给三个读取起短名字:
- A:取得商品名
key。 - B:按这个商品名取得
price。 - C:取得
shipping。
B 必须等 A;C 不必等 A 或 B。用箭头写成 A → B,表示“B 依赖 A”,不是“数据正在通过某条网络线传输”。
从箭头到图,不需要先背算法
图(graph)是一组点和连接关系。这里点表示操作,带方向的箭头表示谁依赖谁。如果箭头不能沿着方向绕回起点,这种图叫有向无环图(DAG)。
满足箭头限制的一种排列叫拓扑顺序。这个小例子里,A、B、C 或 C、A、B 都满足 A 必须在 B 前;B、A、C 不满足。
这只是帮助你理解先后约束。本项目没有引入通用图调度器;plm.py 实际用当前语句组里的“最近定义位置”决定插入点。带循环的任意程序也不能简单画成这个小 DAG 就宣布可以优化;本版循环不优化。
3. 控制依赖:这件事到底会不会发生?
看这个程序:
if inputs["delivery"]:
shipping = tool("lookup", key="shipping")
else:
shipping = 0
result = shipping
运费读取不仅需要一个参数,还取决于 if 选择哪一边。这叫控制依赖:某个操作是否发生,受前面的控制选择决定。
我们的实现不会在 if 判断前就启动这个分支里的读取。准备操作留在所选分支内部。你可以把它理解为:允许把包裹提前送到已经选定的房间门口,但不能先替 Python 决定要进哪个房间。
类比到这里为止。真正负责选择分支的仍是 CPython,不是 Host 的 goroutine,也不是 AST pass。
4. AST:代码的结构,不是代码运行后的值
电脑不能只靠搜索字符串 tool( 判断代码能否优化。注释里也可能有这个字符串,函数名可能是变量的一部分,括号也可能跨行。
解析(parse)是按照语言规则识别源代码的结构。Python 的 ast.parse 返回抽象语法树(AST)。树是一种有层次的结构:有一个根,向下分出子节点;叶子是没有子节点的项。文件夹目录可以帮助建立这种直觉,但 AST 的节点表示语法,不是文件。
下面只是结构示意,不是运行输出:
模块
└─ 赋值
├─ 目标:price
└─ 调用
├─ 函数名:tool
├─ 位置参数:"lookup"
└─ 关键字参数 key:"book"
在 Python AST 中,它们分别会对应 Module、Assign、Name、Call、Constant 等节点。Name("price") 记录“这里写了名字 price”,它不包含运行时查到的价格 21。
**转换(transform)**是修改这棵树;**编译(compile)**是把最终结构变成 CPython 能执行的 code object;**执行(exec)**才让其中的程序行为发生。因此“读到了 AST 中一个函数调用”不等于“已经执行了这个调用”。
本项目在同一个 Guest 内完成 parse → transform → compile → execute,没有把改写后的树发去另一个服务审批。
5. Prepare、Resolve 和 Materialize 各指什么?
术语可以少记,但这两个时点必须分开:
- Prepare:允许的工具工作可以开始了。Host 启动本轮的任务,返回一个小编号 handle。
- Resolve:Python 到达原调用位置,现在可以等待并领取那一次任务的响应了。
Future 是一份“这个任务以后会有值或错误”的记录,不是结果本身,也不只是一个 channel。当前实现里,它包含参数、响应 bytes、完成信号和取消函数。
Materialize 常用来表示把结果变成调用者实际能使用的值。这里读取响应后,Python 的 JSON 解码器得到 int、dict 等对象。这个小实现将这件事与原调用处的 Resolve 放在一起,不支持把它再往后移动。
所以示意上的变化是:
原来:开始 A → 等 A → 开始 B → 等 B → 使用结果
现在:准备 A → 准备 B → 原位置领取 A → 原位置领取 B → 使用结果
不要把这幅示意误读为“B 一定比 A 先完成”或者“两件事一定同时占着两个 CPU 核”。完成顺序由真实任务决定;代码只允许它们的等待区间重叠。
6. 为什么错误不能跟着工作一起提前?
考虑这个输入:
try:
price = tool("lookup", key="book")
missing = tool("lookup", key="absent")
except RuntimeError:
result = price
如果第二个工具在 Prepare 阶段就失败,把错误立刻交给 Python,会让它在 price 赋值前进入 except。此时读取 price 还会出错。普通执行并不是这样的顺序。
正确做法是:Host 可以先记录第二个任务的错误,但等 Python 到第二个原调用点,才在那里抛出 RuntimeError。try/except 还是原来的 try/except。
这就是“启动时间改变,交付位置不变”。我们保留的是约定范围内的值、错误交付和 Python 控制流,不是宣称完整外部调用时间线一模一样——启动时间本来就是要改变的东西。
7. 提前计算参数,也可能改变行为
inputs["item"] 看着像一个简单取值,但 key 不存在时会产生 KeyError。key 这个名字还未定义时也会出错。
因此,不能只说“工具错误晚点抛就行”,还要处理参数求值。求值就是实际算出表达式的值。例如 price * 2 的结构是一棵表达式树,求值后才得到数值。
当前转换生成一个 lambda:它是没有单独名字的小函数。在这里它装着“怎样取得工具名和参数”。prepare_call 立即尝试调用它;如果参数此时还取不到,返回 handle 0,表示没有准备。原调用位置依然保留原参数表达式,再由 Python 正常求值和报错。
这个 lambda 不是让我们有权随意提前执行任意代码。f()、自定义对象的下标访问、方法调用都可能有可观察的行为。本版先限制整份可优化程序的语法和可提前参数的形式;不符合条件的程序正常执行,不优化。
要读具体规则,再去看 transform;不要先把这些限制当成“繁琐检查”删掉。
8. 只读、稳定、幂等,不是三个同义词
副作用指表达式或操作除了返回一个值,还改变了外部可观察状态。例如写文件、扣库存、发消息。读取也可能产生访问日志、计费或敏感查询记录,所以不能仅凭 HTTP 方法或函数名就决定可以提前。
只读表示不修改我们约定关心的业务数据。稳定快照表示本轮所读取的那份数据固定不变:比如演示中的价格表。只读但不断变化的实时价格,不自动满足稳定快照要求。
幂等表示在指定的状态模型中,重复相同操作的最终效果与只做一次相同。它不表示“永不失败”,也不表示“重复请求完全没有成本”。讨论它时必须说清楚观察的是哪些效果。
本项目的选择很窄:Host 明确注册允许提前的工具,并承诺本轮使用稳定、可丢弃的只读结果。它没有给任意网络 API 自动证明这些性质。你实现新工具时需要判断这个契约是否成立。
9. Handle、匹配和回退,不需要大串证书
handle 是当前 Run 内任务的编号。它只在当前 Run 的 Future 表中有意义,不是全局身份证,也不是授权令牌。
在原调用处还要匹配工具名和参数。这防止把“查 book 的结果”交给“查 pen 的调用”。当前实现比较序列化参数的 bytes;即使两种 JSON 写法在业务上等价,bytes 不一样也可能不采用,这是保守选择,不是通用 JSON 等价判断。
三种情况必须分开:
- 没有准备(handle 0):在原调用处正常执行。
- 已准备但不匹配:不使用旧响应,取消旧读取,按原调用执行。之前只允许可丢弃的读取提前,因此这不是任意写操作的重复执行许可。
- 匹配的任务失败了:把那次错误交付给 Python,不自动再试一次。
编号领取后就从表里删除,不能领取第二次。下一轮有自己的表,不会从上一轮拿旧值。更多细节对应 runState.resolve。
10. Prefix 不是预测后面的程序
Whole-program 是拿到完整源码后再处理。Prefix 则是在源码还在传来时,先检查已经完整收到的部分。
一个 chunk 只是一次收到的片段,不一定是一行,更不一定是一条完整语句。网络或调用者怎样切片,不应该替 Python 定义语法。
当前 prefix 只接受追加,不允许改写旧内容;只从完整、明确的开头工具赋值准备读取。它不执行任意 Python,不预测未选分支。最终仍由 CPython 执行完整程序,并在同一个 Run/Future 表中领取已准备结果。
如果半句最终没补齐,最后编译会失败;已经允许启动的只读任务要取消和回收。这里“回收”不是倒带让外部请求从未发生,而是停止未完成工作、等待任务结束,不再保留本轮资源。
11. 读真实代码时只带三个问题
- 这一处在改变“工作何时开始”,还是“结果何时交付”?
- 参数和结果现在归谁保管,生命周期到哪里结束?
- 不满足条件或失败时,是不优化、正常调用、交付错误,还是取消?
下一步读 五个真实例子,再读 PLM 实现 与 prefix 实现。不用先背完 AST 类名,也不用先读旧论文 runtime。
本章四段 Python 输入已在当前真实 Wasm 路径验证;给 delivery 传 JSON true 时,依次得到 47、11、5、21。把其中一段保存成自己的文件后,可在仓库根目录运行(这里的文件名需要替换成你实际保存的名字):
go run ./cmd/demo -source your-example.py -inputs '{"delivery":true}' -plm
-plm 启用优化;去掉它走普通执行。这是功能验证,不是性能对照实验。