结论:TinyJit 的调用是三阶段:cnt=0 直接执行原 Python 函数、cnt=1 捕获 LINEAR、cnt>=2 校验输入签名并重放。重放只执行已记录的设备调用,Python 条件分支、闭包常量与普通副作用在捕获时就被冻结;而输入签名并不等于完整 shape 校验——本机探针用裸一维 BUFFER 长度 4 捕获后以长度 8 重放,两者签名相同且都未抛 JitError,返回仍是长度 4 的 Tensor。以下为固定 tinygrad 归档与本机最小探针的静态结构分析,未运行依赖 NumPy 的完整测试,也不代表模型数值或车载时延已验证。
三阶段调用与关键函数
_prepare_jit_inputs() 收集顶层位置/关键字 Tensor 以及容器中一层 Tensor,拒绝虚拟或重复输入缓冲,并记录视图、符号变量、dtype 与设备;它按底层缓冲检查重复,非 Tensor 的 Python 标量不会自动成为运行时参数。_TinyJit.__call__() 按计数分派:cnt=0 执行原函数,cnt=1 捕获,cnt>=2 检查签名并重放;JIT=0 走原函数,但仍会在函数末尾递增 cnt。
| 函数 | 本版本实际行为 | 需要保留的契约 |
|---|---|---|
_prepare_jit_inputs() |
收集顶层与容器一层的 Tensor,拒绝虚拟或重复缓冲,记录视图、符号变量、dtype、设备。 | 视图可参与签名,但裸 BUFFER 长度未必被记录;非 Tensor 标量不自动成为运行时参数。 |
_TinyJit.__call__() |
cnt=0 执行原函数;cnt=1 捕获;cnt>=2 检查签名并重放。 |
JIT=0 仍递增 cnt;切换配置前应理解计数状态,有原函数时可用 reset() 重新捕获。 |
prune_linear() |
prune=True 时保留触及输入缓冲及其后续相关缓冲的调用,其余作为一次性列表在捕获时执行。 |
输入无关的初始化可被固定为捕获期结果,不能假定每帧重新运行。 |
jit_lower() |
把输入缓冲参数化,对非持有缓冲做内存规划,调用 compile_linear(),JIT<2 时尝试组成设备图。 |
须固定输入、临时缓冲、编译配置及图后端的完成/错误语义。 |
graph_split_rewrite()、GraphRunner.supports_uop() |
只有后端声明支持时才分组;默认单设备只支持同设备的 PROGRAM。 |
「JIT 已捕获」不等于所有调用都在单个 GPU graph 内,也不等于没有主机/设备同步。 |
输入签名、返回值与分支冻结
捕获后重放会比较名称列表及由输入视图/变量/dtype/设备组成的 expected_input_info;同一函数从位置参数改关键字参数或换视图偏移可能被拒绝,但不能把此签名当作完整 shape 校验。输入内容本身可以变,却不能让 Python 布尔开关或闭包常量每帧重新执行。
本机最小探针在默认 METAL 后端用裸一维 BUFFER 长度 4 完成捕获,再以长度 8 的同 dtype/设备 BUFFER 重放:两者的 expected_input_info 都是相同的 NOOP 视图、空变量、float 和 METAL,没有抛 JitError,返回的仍是捕获时长度 4 的 Tensor。这只证明该输入形态下签名未拦截长度变化,不证明所有 reshape/视图都绕过检查;仓库已有使用不同输入构造的 shape 变化用例,仍需运行和区分。对模型插件应在 JIT 之外显式检查形状、步长、字节长度和布局。
第二次调用才在 capturing 非空期间收集 LINEAR;嵌套 TinyJit 被拒绝,未捕获任何调用或返回含非 Tensor 的对象也会报错。_check_no_non_tensor_return() 允许 None、Tensor 以及递归包含它们的 tuple/list/dict。需要强调的是 CapturedJit 保存的是捕获时的返回对象,不是每次调用重新构造的 Tensor;仓库的 test_output_buffer_reuse() 明确断言后续重放会覆盖先前捕获结果,需要保留某帧输出时须另行复制或在下游完成消费后再复用。
Python 条件分支、闭包常量和普通副作用只在首轮直接执行及捕获轮执行,重放只执行已记录的设备调用。仓库已有把这种差异固定为回归用例的 test_python_constants_frozen() 与 test_conditional_branches_frozen()。对驾驶模型的驾驶位策略、道路模型的大/小模型选择或相机格式,不能只改变 Python 变量而继续复用同一捕获图,必须确认模型实例与捕获输入契约是否重新建立。
与 openpilot 模型的实际连接
load_pickle() 反序列化已捕获的模型产物,并用分块复制恢复权重缓冲。modeld.ModelState.__init__() 对道路模型及 warp 的 captured._linear 做 lower_and_compile();ModelState.run() 先上传打包输入,再跑 warp 与模型,最后读回输出。由于 CapturedJit.linear 是 cached_property,这条替换路径依赖首次 linear 访问发生在替换之后;当前构造函数顺序符合该前提,但未来插件若提前读取 captured.linear,随后仅修改 _linear 不会自动刷新已缓存链接结果。
dmonitoringmodeld.ModelState.run() 则缓存指向相机缓冲的 from_blob Tensor,运行座舱 warp 和模型。两条模型路径都需要保证图捕获输入地址、相机缓冲生命周期和输出消费顺序;但座舱路径不能直接套用道路模型构造函数里的显式预编译步骤。
验证边界
| 调用轮次 | cnt | 行为 | 返回 |
|---|---|---|---|
| 第一次 | 0 | 直接执行原 Python 函数 | 实现的 Tensor |
| 第二次 | 1 | 捕获 LINEAR |
捕获结果 |
| 第三次及以后 | >=2 | 校验签名并重放 | 捕获时保存的返回对象 |
| 长度 4→8 重放 | >=2 | 签名相同、未抛 JitError |
仍是长度 4 的 Tensor |
本机还运行了不依赖模型文件的 JIT 探针:第一次调用后 cnt=1 且未捕获,第二次后 cnt=2 且已捕获,第三次后 cnt=3;捕获轮与重放轮返回同一 Tensor 对象。优先继续验证相机 ringbuffer 复用、输出复制时点、设备图异常及重连后的重新捕获。最小探针不能替代实际道路/座舱模型的逐帧数值对比、时延上界及安全退出验证。
常见问题
TinyJit 第几次调用才是捕获?
第二次。源码按计数分派:第一次(cnt=0)直接执行原 Python 函数,第二次(cnt=1)捕获 LINEAR,第三次及以后(cnt>=2)校验签名并重放。
重放返回的 Tensor 是每帧新建的吗?
不是。CapturedJit 保存的是捕获时的返回对象,仓库 test_output_buffer_reuse() 明确断言后续重放会覆盖先前捕获结果,所以需要保留某帧输出必须另行复制。
改了 Python 里的开关,重放会跟着变吗?
不会。Python 条件分支、闭包常量与副作用只在执行轮与捕获轮运行,重放只执行已记录的设备调用。仓库的 test_conditional_branches_frozen() 与 test_python_constants_frozen() 把这一差异作为回归用例。
输入签名能保证形状正确吗?
不能当作完整 shape 校验。本机探针用长度 4 的裸 BUFFER 捕获后以长度 8 重放,两者签名相同且未抛 JitError,返回仍是长度 4 的 Tensor。插件应在 JIT 之外显式检查形状、步长、字节长度与布局。
延伸阅读
- tinygrad 与 Torch 的 PrivateUse1/ATen 别名设备 —— 设备抽象与别名如何影响缓冲归属。
- tinygrad 的 dtype 降级与仿真 —— 捕获图中算子如何落到目标后端。
- 进程重放校验:输出有效性与 schema 迁移 —— 重放结果与输入契约的校验思路。
- openpilot 的 100 Hz 控制回路 —— 每帧重放的输出顺序如何进入控制节拍。
参考资料
- 项目 02 ——
openpilot-research/TINYGRAD_JIT_CAPTURE_REPLAY_DEEP_DIVE.md(三阶段调用、输入签名探针、输出缓冲复用与分支冻结)。 - 项目 02 ——
openpilot-research/同目录张量调度与执行引擎专题(jit_lower与图后端作为相邻边界参考)。
说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。
更新记录
首次发布 2026-10-11 最近核对:2026-10-11
发表回复