PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
章节目录
LEARNING NOTES / 02

Whole-program PLM

依赖、控制流与原调用处的结果交付。

先读 学习手册 的普通执行路径。可直接运行 五个边界例子。本章对应已经跑通的新 Guest,不是旧系统的通用 PLM 框架。学习顺序是:例子 → AST → Future → 错误/取消 → 测试。

1. 同一个价格例子,加一个独立读取

examples/order.py 在原 lookup 例子上加运费;保留两行版供第一次阅读:

price = tool("lookup", key=inputs["item"])
shipping = tool("lookup", key="shipping")
result = {"item": inputs["item"], "total": price * inputs["quantity"] + shipping}

Go Host 的价格表新增 shipping: 5。普通和 PLM 都返回 {"item":"book","total":47},只是工具启动位置不同。

go run ./cmd/demo -source examples/order.py
go run ./cmd/demo -source examples/order.py -plm -show-transformed

第二条命令会将 Guest 实际转换出来的 AST 文本 打印到 stderr,结果仍在 stdout。实际转换如下:

_spine_future = _spine_prepare(lambda: ('lookup', {'key': inputs['item']}))
_spine_future_ = _spine_prepare(lambda: ('lookup', {'key': 'shipping'}))
price = _spine_resolve(_spine_future, 'lookup', key=inputs['item'])
shipping = _spine_resolve(_spine_future_, 'lookup', key='shipping')
result = {'item': inputs['item'], 'total': price * inputs['quantity'] + shipping}

先读出两件事:两个 prepare 都在第一个 resolve 前;priceshipping 的赋值顺序没有交换。Python 的控制流仍在同一个私有 CPython 实例中运行,Go 没有替它执行一半 Python。

单个 lookup 也可以转换,但并没有另一个独立请求可以重叠。这里不报告加速比,不把 AST 变化当性能证据。

2. Prepare、Resolve 与 Future

Prepare 请求 Host 提前开始只读工作,立即返回一个整数 handle,而不是等待业务结果。Future 是 Host 保存“这次未领取结果”的小对象;Resolve 在原来的 Python 调用位置领取值或抛错。

这里没有将 result 交付移动到 Python 原调用之后的 Materialize 优化。名字只是简短操作,不代表保留了旧 PLM 的全部模式。

在 Go 中,调用者写:

runner, err := spine.New(ctx, wasm, tools, "lookup")
out, err := runner.RunPLM(ctx, source, inputs)

最后的 "lookup"Host 的显式承诺:本轮内读取的是稳定快照、没有外部写副作用、允许结果未使用而丢弃、允许并发,并能配合 context 取消。仅仅名为 lookup/read,不会自动获得提前执行资格。

当前 demo 的 map 是固定只读数据,满足这个规则;choose: "pen" 是依赖例子用的普通数据项。实时余额、会消费额度的读接口、写操作都不能机械标为可提前。New 不会自动快照工具闭包;实际工具接入时必须提供满足承诺的数据源。这是范围收缩,不是通过删版本检查冒充任意 freshness。

普通 Run 即使注册了可提前工具也不开启 prepare;不在 allowlist 的工具在原位置普通调用。

3. AST pass:只做看得懂的一小段

guest/plm.pytransform(source)。AST 是 Python 源码的语法树;例如赋值、调用、变量名各是一个 node。我们改树后直接 compile/exec,不把 patch JSON 发去 Host 审批。

3.1 data、argument、tool_call、block_ok

  • data 识别该小语言的数据表达式:JSON 来源的数据、标量、普通容器、算术/比较等。它不允许任意函数调用或对象方法。
  • argument 比 data 更窄:只允许常量、数据变量,以及字面量索引的下标读取。不提前计算任意 Python 表达式。
  • tool_call 只认 tool("固定名称", key=...) 这种直接调用,不支持别名、动态工具名、**kwargs 展开。
  • block_ok 检查整段程序是否落在这个小范围:简单变量赋值、if、有限 try/except/else/finally、pass/常量语句。不能重绑定 inputstool

任何位置不支持,就返回原 AST,整个程序照常执行。循环、函数定义、import、方法调用、属性读写、可变对象修改、反射等并不是禁止运行,而是不使用这个优化。

这层是一个内置 pass 的语法范围,不是 authority 证明链;没有 registry、certificate、canonical hash 或重复 decode。为什么要看整段程序?因为否则前面的任意 Python 可以把 inputs/变量换成有自定义 __getitem__ 的对象,使所谓“提前读参数”也执行用户代码。当前选择少支持语法,不引入对象/别名分析器。

3.2 rewrite:只跨相邻工具赋值

rewrite 找出一个 block 内连续的工具赋值组。普通数据赋值和其他语句是 barrier;if 的两个分支、try 的各区域分别处理,不从未选择的分支把 prepare 拉出来。

组内用小字典 definitions 记录最近一次变量赋值位置。每个调用的 prepare 放在其参数所依赖的最后一个赋值之后;没有组内依赖,则放在组开头。

例如:

key = tool("lookup", key="choose")
price = tool("lookup", key=key)
shipping = tool("lookup", key="shipping")

shipping 可以先启动;price 必须等 key 的 resolve/赋值完成后才 prepare。同一个变量被再次赋值时,以最近的定义为准。

原语句只将 tool(...) 换成 resolve,保留原赋值和位置。fresh 选择不会和用户变量或异常绑定重名的 helper 名;这是避免变量冲突,不是靠秘密名字建立安全边界。

3.3 为什么生成 lambda?

prepare_call 需要把参数读取本身包在 try 里面。例如 inputs["absent"] 在原位置应抛 KeyError,不能在前面另一个调用还没返回时提前抛出。

这里 lambda 是生成的短 thunk(一个立即调用的小函数),只装入已接受的简单参数表达式。它在 prepare_call 内马上执行,不是留给 Go worker 执行 Python。

若参数读取/JSON 编码失败,prepare 返回 0;resolve 的参数仍由 Python 在原位置重新求值,自然在原位置产生同样的错误。没有缓存/伪造 Python exception。准备成功后也重新求值原参数,Host 可以检查本次调用是否仍匹配。

4. Bootstrap 和 C:新入口仍然很薄

guest/bootstrap.py

  • decode:普通 call 和 resolve 共用 JSON value/error 交付。
  • prepare_call(thunk):获取参数、编码一次准备请求;失败返回 0,不提前交付参数错误。
  • resolve_call(handle, name, **args):在原位置发送当前参数和 handle,解码返回结果。
  • execute:PLM 模式下在 Guest 内 parse/transform/compile/execute,给 scope 放入两个 helper。普通模式直接编译原源码。Transformed 是学习用输出,不作为 Host 再次选择/批准的输入。

guest/runtime.c 的新增部分:

  • python_prepare:将 JSON bytes 交给 spine.prepare,返回整数 handle;0 明确表示没有准备。
  • python_resolve:传 handle 和当前请求,使用与普通调用相同的有界响应 buffer,将结果复制为 Python bytes。
  • methods 多注册 prepare/resolve,由 Go New 同步安装对应 import。

当前三个 Host 入口是独立 ABI:call、prepare、resolve。Resolve 直接查本轮 Future,不绕旧 Broker 或证书系统。当前请求在 ABI 边界解码一次;再次发送原位置参数是为了匹配这一次真实调用,不是内部层层重复验证同一对象。

5. Go Future:只跟踪本次 Run

future.go,再读 bridge.go 的 Host wrapper。

5.1 runState / future

runState 拥有子 context、cancel、handle 计数器、Future map 和 worker WaitGroup。每次 run 新建它;context.WithValue 只把这个 owner 传到 wazero 回调,不是全局 Future 仓库。

一个 future 只有请求、已编码响应、done channel 和 cancel。请求中的 RawMessage 是 Go 自己的字节,不是 Guest 内存视图,工具须将其当作只读参数;后台 worker 不读写 Wasm 内存。

5.2 prepare

检查当前模式、工具 allowlist 和 pending 上限后,分配本轮的新 handle,启动一个 goroutine 调用工具,编码值/错误,最后关闭 done channel。

goroutine 是 Go 的并发执行任务;不是再创建一个 Python 实例。done 是完成信号;写入 response 后关闭 channel,等待方看到关闭后可以安全读取结果,不需要反复轮询或 sleep。

map 只由当前 Guest 的同步回调线程读写;后台 goroutine 只填写各自的 response。WaitGroup 用来在 Run 结束时等待所有已启动任务,避免 goroutine 留在后台。

5.3 resolve

  • handle 为 0:没有准备,调用原工具。
  • handle 未知或已消费:返回错误,不自动重试。
  • 有 Future:先从 map 删除,保证单次消费。
  • tool/args 不匹配:取消旧的只读工作,用当前参数普通调用,不采用旧结果。
  • 匹配:等 done,直接返回原响应,包括原工具错误;不因失败重新调用工具。

参数比较是工具名 + 参数 JSON bytes,没有 hash/certificate。字节不同即不采用,即使某些不同编码在业务上等价,也只会少一次优化,不误领不同结果。

handle 每次 prepare 动态分配,不是“源码行号对应唯一 Future”。同样的整数在下一轮可能重新出现,但下一轮 map 不含上一轮对象,不能领取上一轮结果。

5.4 close:未领取的结果怎么办?

runState.close 先取消本轮 context,再 Wait 所有 worker。已消费、失配取消以及从未领取的任务都包含在 WaitGroup 中。Guest 结束、错误或取消时,Run 都不会留下仍在运行的配合取消的工具任务。

工具仍然是可信 Go 代码;不能强杀不响应 context 的函数。这个限制没有因为加 Future 消失。

6. 限制与不承诺的事

  • 每轮最多 64 个尚未消费的 Future,是当前演示资源选择。超出时 prepare 返回 0,在原位置正常调用;不是系统或论文给出的上限。
  • 任意调度先后不保证。例如两个 goroutine 都启动后,哪个先进入工具函数由 Go 调度决定。保证的是可以重叠,且结果按原 Python 调用顺序交付。
  • 不比较速度,不声称现实数据源自动满足快照语义。
  • 未使用的提前读取可能已经发生,但只能是 Host 显式允许丢弃的只读工作。
  • 诊断用 Transformed 当前随 PLM 响应返回,会占用已有 1 MiB 响应预算;本版是小程序讲解实现,不承诺接近上限的大程序仍有相同资源开销。
  • 不优化反射/跟踪程序,也不承诺内存耗尽、调度时间、内部临时变量和分配轨迹与普通路径相同。

7. 真实验证怎样证明语义?

plm_test.go 通过实际 Wasm 测试,不把本机 AST 单测当作 Guest 已跑通:

  • 重叠启动:第一个工具必须等第二个工具发出 channel 信号才返回。若仍串行启动就不能成功,不靠肉眼看耗时。
  • 错误交付:第二个工具提前失败,但 Python handler 能读取已经完成赋值的第一个结果;若提前抛错,变量会尚未定义。
  • 依赖/分支:等待依赖返回,不调用未选分支。
  • 参数错误:缺失 key 不越过前一个赋值提前抛出。
  • 取消与回收:第一个调用失败后,第二个未消费 worker 收到取消并退出,Run 才返回。
  • 领取边界:参数失配不复用,handle 不跨 Run、不能重复消费,普通模式不能 prepare,未 opt-in 的工具原位调用。
  • 资源上限:第 65 个 pending prepare 明确返回 0。
  • 未知语法:循环正常执行,转换文本为空。

共 13 个真实路径子测试;普通路径的 16 个子测试仍保留。新增并发路径使用 Go race detector 验证;它不是性能测量。guest/test_plm.py 的 6 个本机 AST 单测只用于快速检查语法变换和 helper 命名,不能替代真实 Wasm。

PYTHONPATH=guest python3 -m unittest guest/test_plm.py -v
go test ./... -count=1
go test -race -run TestPLMGuest -count=1

8. 下次学习时你来判断

  1. 为什么可以提前启动运费读取,但不能提前把失败抛给 Python?
  2. 如果运费依赖第一个工具返回的城市,prepare 应该移到哪里?
  3. 如果真实数据源每次查询都会变化,现有 earlyReads 承诺还成立吗?
  4. 如果想优化循环,你会先讨论每轮动态调用身份,还是复制一个循环专用 Future 系统?

这些是讨论题,没有替你实现。阶段 3 prefix 已复用这套 runState/Future,并在 tool/prepare_call 中增加已准备 handle 的领取入口。独立 RunPLM 不启用 prefix 缓存。可选创建路径现用 prepared full-copy 说明基线与隔离,非真实 COW;不另建 Future,也不推进 Linux 页共享实现。