标签: tinygrad

  • tinygrad HCQ 执行链:队列依赖、链接与提交生命周期

    结论:tinygrad 的 HCQ 执行链把 LINEAR 调用编成 GPU 队列批次,再用主机提交程序驱动:hcq_compile() 负责分批、依赖、命令编码与编译,hcq_link() 在链接期把占位缓冲变成真实 Buffer,exec_hcq() 在运行时填地址表并执行提交。一次逻辑 copy_from() 不保证只做一次物理 DMA,多队列完成也不代表某个 kernel 已结束。本文为固定源码快照 c8fb906 的静态阅读,未在 GPU 上运行。

    入口、分批和复制

    在普通非 JIT 执行链中,compile_linear() 完成内核编译后调用 hcq_compile()。get_enqueue_devs() 只考虑带缓冲参数的 PROGRAM 或 COPY 调用;候选设备类型受 HCQ_DEVS 限制,AMD 只有开启 HCQ2 才进入该集合,QCOM 的 COPY 被排除,留给 host 与统一内存路径。因此不能把所有 tinygrad 调用都理解成 GPU 队列提交。

    hcq_compile() 先在需要时把已有 BUFFER 参数化,再用 unwrap_call() 拆多设备 lane,用 pm_insert_copy_staging 处理复制。跨不同 peer group 且可用 RDMA NIC 时,split_rdma() 产生两段调用;不能映射源与目标时,stage_copy() 借用 host staging 缓冲并按两个 slot 分块;设备没有 copy queue 时,复制会被转成可编译的内核。它随后按连续可入队片段与 peer group 调用 sched_batches(),并把编码后的主机提交 SINK 交给 lower_and_compile()。

    这些分支决定真实的传输次数与等待点,所以不能从一个逻辑 copy_from() 直接推算物理 DMA 数量。

    分支条件 处理方式 含义
    跨 peer group 且有 RDMA NIC split_rdma() 产生两段调用 真实传输次数与网络路径相关
    源/目标无法映射 stage_copy() 借用 host staging 并按两个 slot 分块 多出主机中转与等待点
    设备无 copy queue 把复制转为可编译内核 复制变成 kernel 提交
    QCOM 的 COPY 被排除,留给 host/统一内存路径 不进入 HCQ 入队集合

    队列依赖和时间线

    BatchCtx 收集每个设备的计算/复制队列、上一调用、peer 访问、需要 signal 的提交及 slot 占位。HCQDepsTracker._key() 以 (base, lane, byte offset/end) 标识访问范围,能在同一底层缓冲中区分不重叠视图;缓冲映射的基址、lane 与跨度一旦出错,会直接污染依赖判断。_wait_ins() 由读写冲突选出每个来源队列最近的依赖:同队列依靠 FIFO,跨队列生成 wait,并把被等待的提交标记为 signal。

    _start_ins() 与 _build_queues() 让队列首次使用时先等待本设备与 peer 的既有时间线,再插入依赖 wait、可选 timestamp、调用与 signal;收尾队列等其他队列后推进设备时间线。异步 COPY 与 compute 是否能正确重叠,取决于这套规则和设备驱动实现。

    函数或结构 具体职责 硬件适配边界
    BatchCtx 收集队列、上一调用、peer 访问、signal 与 slot 占位 新后端要提供队列、时间线与提交顺序能力
    HCQDepsTracker._key() 以基址、lane、字节跨度标识访问范围 映射基址或跨度错误会污染依赖判断
    _wait_ins() 读写冲突选最近依赖,跨队列生成 wait 异步重叠是否正确取决于驱动实现
    hcq_fence() 等上次调度完成以避免 slot 冲突 循环留有 timeout TODO,最坏等待须故障注入
    HWQueue.q() / encode_submit() 常量写命令字节,动态字段留 patch 新后端要实现命令编码、地址 patch 与错误回报

    编译缓存、链接所有权和运行时地址

    hcq_compile() 的模板缓存只在 cache=True 且提供输入 UOp 时启用,缓存键包含准备后的 LINEAR、profile 开关与 ALL2ALL>=1。较短调度(HCQ_CACHE_THRESH 默认 64)用运行期地址 patch,较长调度可带 lt_input 链接期输入,随后不能复用同一已烘焙链接。该缓存与内核编译缓存不是同一层。

    lower_call() 经设备 pm_encode/pm_lower 编码队列,把动态输入地址改写为地址表槽位,合并同类临时缓冲并以 128 字节对齐;HCQInfo 记录地址表位置、slot、被写缓冲、host 依赖与 profile 元数据。链接时 hcq_link() 把占位缓冲变成实际 Buffer,解析 GETADDR 并保持引用。allow_cache=True 且没有 lt_input 的可缓存链接会分配自有缓冲并缓存结果;含 lt_input 的一次性链接才借用 runtime ring 槽;allow_cache=False 的 JIT 保留链接分配的自有缓冲。设备专属 pm_bufferize 仍可能接管具体缓冲分配,因此不能仅凭 eager 或 JIT 名称推断所有权。

    每次 exec_hcq() 在有动态输入地址时先填地址表,再执行主机提交程序;它向内存所有者登记访问设备的 pending timeline,并仅在等待或性能采样条件下同步或读回时间戳。Compiled.synchronize() 等待本设备及 peer pending,超时路径调用 on_device_hang(),但这不自动证明提交程序内所有无超时等待都可恢复。

    硬件适配边界

    上车硬件适配须分别量化编译、链接、提交、COPY、GPU 完成与输出读回时间,并验证掉线或超时能传递为模型不可用状态,不能把主机提交返回当成推理成功。新后端不仅要能运行 kernel,还要实现命令编码、地址 patch 与错误回报。

    本机一项不接触 GPU 的 HCQDepsTracker 探针显示:在同一 UOp 缓冲写入 [0,4) 后,读取 [8,12) 没有依赖,读取 [2,6) 则返回原写依赖。这验证了区间冲突的一个样例,但不覆盖队列 signal、实际设备缓存一致性或 peer 内存。

    常见问题

    是不是所有 tinygrad 调用都会走 GPU 队列提交?

    不是。get_enqueue_devs() 只考虑带缓冲参数的 PROGRAM 或 COPY,且候选设备受 HCQ_DEVS 限制;AMD 需开启 HCQ2 才进入,QCOM 的 COPY 被留给 host 与统一内存路径。

    一次逻辑 copy_from() 就等于一次物理 DMA 吗?

    不等于。跨 peer group 且有 RDMA NIC 时会产生两段调用,源/目标无法映射时会借用 host staging 分块,设备没有 copy queue 时复制还会被转成内核。传输次数与等待点由这些分支决定。

    多队列完成就代表某个 kernel 已经结束了吗?

    不代表。源码指出多队列完成不等于某一 kernel 已结束,必须以最终 timeline 或回执为准。Compiled.synchronize() 等待本设备及 peer pending,超时路径调用 on_device_hang()。

    主机提交返回是不是就代表推理成功?

    不是。exec_hcq() 向内存所有者登记 pending timeline,并仅在等待或性能采样条件下同步。synchronize() 的超时恢复也不自动证明所有无超时等待可恢复,应将超时映射为模型不可用状态。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_HCQ_QUEUE_LINK_LIFECYCLE_DEEP_DIVE.md(取用了分批与复制分支、队列依赖与时间线、链接所有权与运行时地址、证据缺口)。
    • 项目 02 —— openpilot-research/TINYGRAD_MODEL_RUNTIME_DEEP_DIVE.md(关联的模型运行时专题,用于区分 HCQ 提交与模型集成链,本文未展开其实现)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • tinygrad AMD AM 驱动:七个 IP 启动顺序与部分重启生命周期

    结论:AMDev 的启动不是一条固定路径,而是先读 IP discovery 再分流:scratch7 等于 AMDev.Version 且未请求 AM_RESET 时才初步允许部分启动,scratch6 非零或 GC 保护故障寄存器非零通常退回完整启动,GC 9.5.0 的既有 AM 状态反而再次强制部分启动以避免完整启动损伤互连。完整启动按 SOC→GMC→IH→PSP→SMU 初始化,两条路径最终都重建 GFX 与 SDMA,VF 会跳过 PF 专属的 PSP 与 SMU。启动标记只表示 AM 自身的阶段完成,不代表模型已编译、GPU 队列已成功执行或已获车辆控制权。本文为固定归档 amdev.py 与 ip.py 的静态阅读,未打开 PCI/USB、未刷写固件、未碰 GPU/车辆/CAN。

    启动顺序与实际分支

    AMDev.__init__() 先处理 PCIe ASPM,映射 VRAM、doorbell 与 MMIO,判断 VF 并请求访问窗口;随后 _run_discovery() 从 VRAM 末端读取 IP discovery、harvested 实例与版本/基址,再由 _build_regs() 生成寄存器对象及可访问的 AID 集合。_run_discovery() 仅检查两类 signature,仍直接按表内偏移与数量做 ctypes 地址遍历,固件或设备损坏场景下需要台架注入验证长度边界,不能把它视为不可信数据解析沙箱。

    init_sw() 建内存管理器、固件目录和七个 IP 实例,按 SOC、GMC、IH、PSP、SMU、GFX、SDMA 顺序对每个组件调用 init_sw()。scratch7 匹配且未请求 AM_RESET 时,才初步允许部分启动;若 scratch6 非零或 GC 保护故障寄存器非零,通常退回完整启动。完整启动如检测到存活的 PSP/SMU,可能先做 mode1 reset;hive 多卡状态要求另行复位。之后完整启动按 SOC→GMC→IH→PSP→SMU 初始化,VF 跳过 PF 专属的 PSP/SMU;部分启动沿用底层状态,非 VF 会重新初始化 TMR;两路最终都初始化 GFX→SDMA。

    判定条件 走向 说明
    scratch7 == AMDev.Version 且未请求 AM_RESET 初步允许部分启动 沿用底层状态,非 VF 重新初始化 TMR
    scratch6 非零或 GC 保护故障寄存器非零 退回完整启动 可能先做 mode1 reset
    GC 9.5.0 的既有 AM 状态 强制部分启动 避免完整启动损伤互连
    VF 模式 跳过 PF 专属 PSP/SMU 经访问窗口获得并释放租约
    两路共同收尾 GFX→SDMA 配置功耗/时钟与 clock gating,写启动标记

    启动尾部对非 VF 配置功耗/时钟与 clock gating,再写 scratch5(TMR 大小)、scratch7(AM 版本)和 scratch6=1。reset_mode=True 的 hive 异常分支可提早返回,因此调用者不能仅以构造函数返回推断所有初始化阶段完成。

    七个 IP 的入口与职责

    子组件 主要入口 本层职责与风险边界
    AM_SOC init_sw()、init_hw()、doorbell_enable() 选 SoC/中断源元数据、设置 NBIO/doorbell;门铃可达不等于队列完成
    AM_GMC init_hub()、enable_vm_addressing()、flush_tlb() 计算 FB/MC/XGMI/VA 范围,初始化页表与 TLB;错误映射可能影响所有 GPU 内存访问
    AM_IH init_hw()、interrupt_handler()、drain() 启动 IH ring,解析 SQ/VM fault/RAS,设置 is_err_state;日志打印不是自动恢复
    AM_PSP init_hw()、_ring_submit()、_load_ip_fw_cmd() 启动 sOS、创建命令 ring、设置 TMR 并加载各 IP 固件;固件来源、哈希与响应状态是独立验收门
    AM_SMU _send_msg()、mode1_reset()、set_clocks() 电源/时钟/重置消息,按 IP 版本选协议;超时与电源策略不能由模型插件自行决定
    AM_GFX init_hw()、reset_mec()、setup_ring()、fini_hw() 初始化 GC hub/MEC,配置 MQD/HQD 与计算 ring;部分启动只重置 MEC
    AM_SDMA init_hw()、setup_ring()、fini_hw() 按 SDMA 版本配置 watchdog、doorbell、copy ring 与关闭路径;传输完成与计算完成不是同一事件

    内存侧还有一条必须保持的先后关系:AMMemoryManager.on_range_mapped() 在页表变更后依次请求 GC 和 MM TLB flush,AM_GMC.flush_tlb() 会先做 HDP flush,未初始化 hub 则返回,VF 与常规路径的 invalidation/ACK 不同。插件若要抽象 GPU 内存,必须保持映射、TLB、队列提交的先后关系,不能只替换分配器。

    停机、恢复与未验证项

    AMDev.fini() 先在 VF 需要时请求访问窗口,只对 SDMA/GFX 调用 fini_hw(),再调低时钟、处理 IH、写 scratch6 并释放 VF 租约;它不是逐一销毁七个 IP 或解除所有 BAR 与固件状态。recover() 只在错误态或 force=True 时处理 IH 并重置 MEC,不能等同于 PCI 热复位、全卡重新发现或固件重验。本专题没有运行 AMD/AM 单元测试或真机驱动,初始化寄存器表、PSP 固件加载、SMU 响应、IH 故障与内存一致性需要单独台架验证;AMDev.__init__() 的多阶段初始化没有包裹通用清理块,若中间异常,已映射资源、VF 租约和部分配置如何回退应通过故障注入确认。此外,VF 与 PF 模式、hive 多卡状态以及 GC 9.5.0 的强制部分启动分支,都不能用一块普通单卡的测试结果替代——它们各自有独立的复位路径与访问租约,验收时必须分别覆盖。

    自制车载硬件的插件边界

    应把 AMDev/IP 层限定为受信任的计算设备驱动,而不是任意动态插件。硬件适配需声明 PCI/USB 身份、BAR 布局、IP 发现表版本、固件哈希、PF/VF 模式、AID/XCC/harvest 信息、内存与 TLB 语义、队列和中断超时。验证顺序为只读发现与固件校验、独立台架冷/暖启动和异常回滚、GPU 队列与内存数值回归、车端只读观察;每一步单独上报设备在线、固件准备、队列可提交、任务完成与模型输出有效,任何状态都不得绕过车型 CAN 编码或 panda 安全钩子。

    常见问题

    AM 启动后 scratch6=1 代表什么?

    它只是 AM 自身的启动标记之一,与 scratch5(TMR 大小)、scratch7(AM 版本)一起写入。它不表示任何模型已编译、GPU 队列已成功执行或系统已获车辆控制权限,因此不能把标记写入当作端到端就绪。

    什么情况下会走部分启动,什么情况下退回完整启动?

    scratch7 等于 AMDev.Version 且未请求 AM_RESET 时才初步允许部分启动;scratch6 非零或 GC 保护故障寄存器非零通常退回完整启动。GC 9.5.0 的既有 AM 状态是例外,会再次强制部分启动以避免完整启动损伤互连。

    recover() 等于把 GPU 重新初始化一遍吗?

    不等于。recover() 只在错误态或 force=True 时处理 IH 并重置 MEC,它不能等同于 PCI 热复位、全卡重新发现或固件重验;AMDev.fini() 也只对 SDMA/GFX 调用 fini_hw(),并非逐组件销毁。

    为什么说队列可达不等于任务完成?

    因为各层完成点是分开的:AM_SOC 的 doorbell 可达、AM_SDMA 的传输完成与 GPU 计算完成都不是同一事件,IH 打印故障日志也不等于自动恢复。上报时应把设备在线、固件准备、队列可提交、任务完成与模型输出有效分开记录。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_AM_DEVICE_IP_BOOT_PARTIAL_RESET_LIFECYCLE_DEEP_DIVE.md(取用了启动顺序、完整与部分启动分支、七个 IP 入口、停机恢复与硬件插件边界)。
    • 项目 02 —— openpilot-research/TINYGRAD_AM_GMC_VM_PTE_TLB_COHERENCY_DEEP_DIVE.md(仅用于确认 TLB flush 与页表变更的先后关系)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • TinyJit 捕获与重放:三阶段调用、输入签名与输出缓冲复用

    结论: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 之外显式检查形状、步长、字节长度与布局。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_JIT_CAPTURE_REPLAY_DEEP_DIVE.md(三阶段调用、输入签名探针、输出缓冲复用与分支冻结)。
    • 项目 02 —— openpilot-research/ 同目录张量调度与执行引擎专题(jit_lower 与图后端作为相邻边界参考)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • tinygrad dtype:weak 提交、64 位整数拆字与低精度浮点模拟

    结论:tinygrad 的 dtype 语义分两段:前端 Tensor 只构造 cast/bitcast 与 weak 常量,真正定宽发生在编译期——由 pm_commit_weak 推导输入宽度、pm_lower_weak 消除弱类型计算节点、末端 pm_cast_const 提交裸露常量;当某类型不在 renderer 支持集或列入 EMULATED_DTYPES 时,才由 pm_dtype_decomps 用两个 32 位字或位型重写来模拟。EMULATED_DTYPES 是运行上下文配置,不等于物理设备确实支持或不支持该 dtype。

    从 API 到 UOp:三个阶段

    前端阶段,DTypeMixin.commit_dtype() 利用 UOp 值域确定 weakint 的具体整数类型;cast() 产生数值转换,bitcast() 拒绝 weak dtype 并产生位重解释。两者都先构造 UOp,并非当场完成设备计算。弱类型提交阶段由 commit_weak_srcs()、cast_weak_srcs()、lower_weak_node() 根据消费者、显式 CAST 和表达式边界定宽,最终 pm_cast_const 处理仍裸露的常量;弱类型图不是可直接交给任意设备的 ABI。

    降解插入点固定在 full_rewrite_to_sink():代码生成先整理 weak 类型和符号,再进行早期算子降解,随后将 pm_dtype_decomps 与弱常量提交一起执行,最后才进入后期降解和渲染。编译次序上,sym + indexing_simplify + pm_commit_weak 先达到定点;接着 pm_lower_weak + indexing_simplify 固定计算 dtype,特意不再组合 symbolic,以免 Invalid 门重新生成 weak 节点形成循环;dtype 模拟之后的最终重写再次提交新产生的 weak 常量,末端 pm_cast_const 处理剩余边。

    阶段 函数/组件 行为和边界
    前端 API commit_dtype() / cast() / bitcast() 确定 weakint 具体类型;产生数值转换或位重解释;均先构造 UOp
    弱类型提交 commit_weak_srcs() / cast_weak_srcs() / lower_weak_node() 按消费者、显式 CAST、表达式边界定宽;pm_cast_const 末端提交裸常量
    降解插入点 full_rewrite_to_sink() 先整理 weak 类型,再早期降解,随后执行 dtype 模拟与弱常量提交
    选择模拟 do_dtype_decomps() / pm_dtype_decomps 仅在类型不受支持或在 EMULATED_DTYPES 时重写;uint64 归并到 long 降解族

    64 位整数:两个 32 位字

    宽整数降解由 l2i()、split_l2i() 和 pm_long_decomp 实现:用两个 32 位字完成转换、加减乘、比较、移位和逐位除法,内存定义扩大为两个字,索引/存储拆分。ALU 地址空间的 Variable 明确不支持此降解——这是宽整数模拟最容易踩的边界。

    低精度浮点则由 f2f()、f2f_clamp()、f2f_load()、f2f_store() 通过位型重写 FP8/BF16/FP16 的载入、计算/转换与存回;模拟路径显式把非规格化数冲零,FP8 转换还涉及饱和及 FNUZ 非有限值约定。这些数值语义差异必须在逐算子数值测试中单独覆盖,不能只验证 API 层能否调用。

    目标类型 降解入口 关键语义与限制
    int64 / uint64 l2i() / split_l2i() / pm_long_decomp 两字实现转换、算术、比较、移位、逐位除法;ALU Variable 不支持
    FP8 f2f() 家族 位型重写;涉及饱和与 FNUZ 非有限值约定
    BF16 / FP16 f2f() 家族 位型重写载入/转换/存回;模拟路径非规格化数冲零
    内存定义 f2f_load() / f2f_store() LOAD/STORE 存储格式重写;长整型缓冲扩为两个字

    选择逻辑上,pm_dtype_decomps 仅收集 FP8、BF16、FP16、int64/uint64 等候选;当类型不在 renderer 支持集,或列入 EMULATED_DTYPES 时才重写。Renderer.supported_dtypes() 给出基础能力接口,具体 renderer 可以覆盖;do_dtype_decomps() 在处理完收集集后清空上下文,使后续编译遍历不把先前发现的类型当作当前结果。

    weak 类型规则的逐函数前提

    weak 类型是这套降解里最容易被误读的部分。commit_weak_consts() 与 derived_dtypes() 对已有具体 dtype 的广播算子求操作数共同类型及结果类型,再提交裸常量——但两者任一仍为 weak 时返回 None,不会强行选一个设备宽度。cast_weak_srcs() 与 pm_commit_weak 为显式 CAST 的 weak 表达式提供计算宽度下界,因此 float32 表达式再 cast 到 float16 不意味着内部计算可降到 float16,需保留范围允许的更宽 dtype。

    absorb_weak_src() 与 lower_weak_node() 吸收可重推导的 weak CAST,并给无法再推导的算子/常量定宽;这里从浮点经 weakint CAST 是数值转换,不能当纯注释删掉,且 WHERE 条件不参与分支宽度统一。pm_lower_weak 处理小缓冲的 gated long index、连续 weak 类型转换和 ALU 变量参数,索引缩窄只在缓冲最大元素数落入 int32 时适用。pm_uncast_const 在消费者类型推导完全不变时去掉常量上的强类型 CAST;pm_cast_const 最后提交仍裸露的常量边,其中 Invalid 不按普通常量提交。

    与 openpilot 集成的边界

    ModelState.pack_inputs() 的帧与辅助输入分别使用 uint8 和 float32 缓冲视图,这里必须先保持 dtype、shape、偏移和设备拷贝一致;后端内部低精度降解发生在编译阶段,不能拿它替代输入缓冲 ABI 检查。构建期 compile_model() 捕获并编译模型/warp 的 TinyJit 产物,实际节点是否包含某个被模拟 dtype,需要检查相应 LFS 模型图及编译产物。

    源码证据层面,本机补跑 test_dtype_weak.py 得 46 项 OK,含 1 项预期失败 test_changed_rows——预期失败明确表明部分弱标量提升矩阵尚未达到测试期待,不能把整套弱类型语义视为已完全收敛。后端小样例中,int64 两元素加法在普通及 EMULATED_DTYPES="long" 上均得到 [1099511627788, 18],强制模拟 BF16 后转回浮点得到 [1.5, -2.0]。自制硬件后端若声明支持新 dtype,应有逐算子数值、字节布局、端序、非有限值、饱和、非规格化数、跨设备复制与尾延迟证据。

    常见问题

    EMULATED_DTYPES 设了,是否说明设备真的不支持该类型?

    不等价。源文档明确 EMULATED_DTYPES 是运行上下文配置,不等于物理设备确实支持或不支持某 dtype。是否重写还取决于类型是否在 renderer 支持集内:pm_dtype_decomps 只有当类型不在支持集或列入 EMULATED_DTYPES 时才重写。

    float32 表达式再 cast 到 float16,内部计算会降到 float16 吗?

    不一定。源文档指出 cast_weak_srcs()/pm_commit_weak 只是为显式 CAST 提供计算宽度下界,float32 表达式再 cast 到 float16 不意味着内部计算可降到 float16,需保留范围允许的更宽 dtype,否则会丢失中间结果精度。

    64 位整数模拟在所有场景都能用吗?

    不是。宽整数降解用两个 32 位字实现,但源文档明确 ALU 地址空间的 Variable 不支持此降解;索引缩窄也只在缓冲最大元素数落入 int32 时才适用。超出这些边界时需要另行处理。

    测试通过是否代表 dtype 语义已经收敛?

    不代表。test_dtype_weak.py 的 46 项 OK 中含 1 项预期失败 test_changed_rows,源文档据此说明部分弱标量提升矩阵尚未达到测试期待。此外 test_dtype_alu.py 的定向运行因环境缺少 hypothesis 在导入阶段即停止,64 位除法/移位、FP8 特殊值和具体 GPU renderer 未经本轮动态验证。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_DTYPE_UOP_LOWERING_EMULATION_DEEP_DIVE.md(取用了前端 API、weak 类型规则、宽整数与低精度浮点降解、编译次序及与 openpilot 集成的边界)。
    • 项目 02 —— openpilot-research/TINYGRAD_DTYPE_PROMOTION_STORAGE_CONTRACT_DEEP_DIVE.md(仅用于确认基础 dtype 提升与存储契约的上下文)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • tinygrad 内核生成链:优化、降级与两级编译缓存

    结论:tinygrad 的内核生成是一条「优化 → 降级 → 线性化 → 渲染 → 编译」的多阶段重写链,而不是一次简单的源码翻译。apply_opts() 决定弱轴与访存形态,full_rewrite_to_sink() 负责展开、降级、屏障与控制流,最后才由 do_render()、do_assemble() 与后端编译器产出二进制。编译成功不等于数值一致,缓存命中也不等于本次重新做了重写诊断。本文为固定源码快照 c8fb906 的静态阅读,未运行目标设备推理。

    入口与阶段顺序

    道路模型的 ModelState.__init__() 加载已捕获图,分别对 _linear 调用 lower_and_compile()。该函数只收集仍为 SINK 或尚无二进制的 PROGRAM 调用,按 to_program_key() 去重;未缓存项再调用 to_program(),当存在多个任务且没有 Beam 搜索时可交给 worker pool。编译后的 PROGRAM 会被替换回调用图,后续执行与 HCQ 链仍由运行时处理。

    座舱模型的 ModelState.__init__() 只装入 JIT 和 warp,并不在该构造函数里显式调用 lower_and_compile(),所以不能把道路模型的预编译时序直接推广到座舱模型。

    do_to_program() 要求 SINK 携带 KernelInfo,先调用 full_rewrite_to_sink();若 renderer 走 ISA 路径,还会执行预指令选择与指令选择;最后用 pm_to_program 反复重写到带 SINK、线性指令、源码和二进制的 PROGRAM。若输入本身已是 PROGRAM,会跳过从 SINK 开始的完整优化,但仍进入后续程序重写链,所以它不是「任何输入都从头编译」。

    阶段 关键函数 源码责任 对硬件移植的意义
    入口 lower_and_compile() / to_program_key() 收集未完成的调用并去重,编译后替换回图 只编译仍缺二进制的调用,不是逐帧重建模型
    优化 apply_opts() / Scheduler.apply_opt() 弱轴转全局轴,显式配置优先,再 Beam 或启发式 同一图在不同配置或搜索下会生成不同 launch 形态
    降级 full_rewrite_to_sink() 展开、向量化、dtype 与运算分解、屏障、控制流依赖 后端不能只实现 render()
    输出 do_linearize() / do_render() / do_compile() 线性化后输出源码或汇编,再交给后端编译器 CPU、Metal、QCOM、AMD 产物形态不同,不是共同 ABI

    优化层:弱轴、tensor core 与启发式

    apply_opts() 与 Scheduler.apply_opt() 把弱轴转为全局轴;显式传入的 opts_to_apply 优先,之后才可能进行 Beam 搜索,否则按条件调用手写启发式,并记录已应用的优化。hand_coded_optimizations() 会尝试 tensor core、图像向量化、matvec、归约分组和 upcast 等规则。

    关键边界是:同一张图在配置、renderer 能力或搜索结果变化后,可以生成不同的 launch 与访存形态,因此不能只比较模型名称来判断两次编译是否等价。另外,tensor core 的支持声明与 dtype、维度门必须与目标设备一致,启发式本身不是硬件性能保证。

    优化规则 作用 边界
    显式 opts_to_apply 优先于自动搜索 决定最终 launch 形态,需随产物一起记录
    Beam 搜索 可实际打开设备测候选 外层 ALLOW_DEVICE_USAGE=0 不构成「永不触碰硬件」的保证
    hand_coded_optimizations() tensor core、图像向量化、matvec、归约分组、upcast 声明与维度门须与目标设备一致

    降级与代码输出

    expand_reduce() 展开归约与轴,add_gpudims() 按 renderer 最大维度把逻辑 range 映射到全局与局部索引;目标的线程维度、最大工作组和同步语义因此成为编译接口的一部分。memory_coalescing() 按操作、缓冲、基础索引、有效位和参数分组连续访存,跳过 volatile,并按 dtype 与 renderer 能力选择向量长度。

    输出顺序不只取决于 AST 遍历。linearize() 与 CFGContext 对依赖图做带优先级的拓扑排序,给嵌套或相依的 range 增加执行顺序约束,调度或降级错误都可能改变载入、存储与循环语义。do_render() 输出源代码,ISA renderer 则经寄存器分配与汇编;do_compile() 与 Compiler.compile_cached() 再经后端编译器转成二进制。

    函数 具体职责 硬件适配边界
    expand_reduce() / add_gpudims() 展开归约与轴,映射逻辑 range 到全局/局部索引 线程维度、工作组上限、同步语义是编译接口的一部分
    memory_coalescing() 分组连续访存,按能力选向量长度 对齐、掩码、越界语义需要数值与故障样例验证
    linearize() / CFGContext 带优先级拓扑排序,约束嵌套 range 顺序 输出顺序不是 AST 遍历顺序
    do_render() / do_assemble() / do_compile() 源码或汇编输出,再经后端编译器生成二进制 不应把一个后端的产物形态当作共同 ABI

    两级缓存与配置边界

    to_program_key() 包含 AST key、renderer 类型、目标以及 NOOPT、dtype 模拟、tensor core、IMAGE 等代码生成配置;进程内的 to_program_cache 是一级缓存。由于 SPEC、DEBUG 不在该 key 中,对已命中的内核切换这些诊断开关,不会重新执行 full_rewrite_to_sink() 的验证与调试打印。第二级是 Compiler.compile_cached():在后端编译器 cache key 非空时以源代码查询磁盘缓存。

    两级缓存不是同一机制,新硬件验收应分别测试冷编译、内存命中、磁盘命中和配置切换。此外 full_rewrite_to_sink(optimize=False) 会跳过前段的 load、range 与调度优化,但仍执行展开、降级、分解、屏障与线性化准备,因此不能解释为「完全不编译」;do_to_program() 用 ast.tag is None 来决定该参数。

    缓存/开关 键或范围 行为边界
    进程内 to_program_cache AST key、renderer、目标、NOOPT、dtype 模拟、tensor core、IMAGE SPEC、DEBUG 不在键中,命中后不重跑重写诊断
    磁盘 Compiler.compile_cached() 后端编译器 cache key 与源代码 与进程内缓存不是同一层
    optimize=False 跳过前段优化 仍执行展开、降级、分解、屏障与线性化准备

    车载硬件与插件验收

    把内核生成链搬到新后端时,至少应固定模型产物哈希、tinygrad 版本、renderer 与编译器、目标架构和全部代码生成配置,并保存冷、热编译耗时及产物身份;再用同一输入张量在 CPU 基准与目标后端之间比较每层或可观测输出,覆盖掩码边界、非对齐形状、NaN/Inf、设备重连与缓存失效。内核编译或加载失败、执行超时、输出非有限值都应转换为明确的模型不可用状态,并验证 selfdrived 的控制退出。代码生成插件不应自行获得 sendcan 权限。

    常见问题

    tinygrad 编译成功是不是就等于数值正确?

    不等于。编译只说明从 SINK 到 PROGRAM 的重写链与后端编译器走通。源码明确指出编译成功不等于数值一致,型号数据与设备时延也待实测;因此需要用同一输入在基准与目标后端之间做数值对比。

    为什么改了 SPEC 或 DEBUG 开关也可能不重新编译?

    因为这两个开关不在 to_program_key() 中,进程内 to_program_cache 命中后不会重新执行 full_rewrite_to_sink() 的验证与调试打印。需要区分「缓存命中」与「本次重新编译」。

    optimize=False 是不是就等于完全不编译?

    不是。它会跳过前段的 load、range 与调度优化,但仍执行展开、降级、分解、屏障与线性化准备。因此不能把它解释为「完全不编译」。

    道路模型的预编译时序能不能直接套到座舱模型?

    不能。道路模型的 ModelState.__init__() 会对捕获图调用 lower_and_compile();座舱模型的 ModelState.__init__() 只装入 JIT 和 warp,不在该构造函数里显式调用它。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_KERNEL_CODEGEN_DEEP_DIVE.md(取用了入口阶段顺序、优化与降级函数、两级缓存与配置边界、验收要求)。
    • 项目 02 —— openpilot-research/TINYGRAD_ENGINE_BACKENDS_DEEP_DIVE.md(关联的执行引擎与后端专题,用于区分内核生成与设备执行,本文未展开其实现)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • Chestnut USB/AMD 推理后端:三层地址空间与双缓冲拷贝协议

    结论:Chestnut(USB+AMD:LLVM)推理后端不是一条本地 GPU 数据路径,而是「USB 桥控制内存 + VRAM 映射 + PCIe BAR」三层地址空间叠加双缓冲拷贝协议的组合。主机侧分配即使请求 host=True 也仍落在 VRAM,CPU 访问必须经 USB 桥映射;一次逻辑 COPY 会被拆成多次 USB 传输与 GPU 端复制。因此「USB 接口能打开」既不等于 GPU 就绪,也不等于模型输出有效。以下为固定源码快照 c8fb906 及固定 tinygrad 归档的静态结构分析,缺少 LFS 模型实体与 Chestnut 实板,所有时序与故障判断均为源码推导。

    从模型入口到设备的初始化链

    load_oob() 在 Chestnut 路径选择 USB+AMD:LLVM;modeld.main() 先按模型产物、设备或线缆判断是否尝试大模型,并把 AMDDevice.wait_timeout_ms 设为 3000 ms,加载或运行失败的回退属于上层逻辑,不是 USB 后端自身的保证。AMDDevice.__init__() 负责选接口、校验 AMD 目标架构、初始化设备属性;USB 分支采用 USBAllocator,设置 4 MiB 运行时分配区,并安装 pm_usb_batch、pm_usb_lower 与 pm_usb_bufferize。一般的 PCI/KFD 路径不走这套 USB 改写。

    USBIface.__init__() 从两个 USB VID/PID 集合中选设备,构造 USBPCIDevice 和 AMDev。后者先取得跨进程锁,创建 USB3,随后由 CustomASM24Controller 检查 LTSSM:未到 0x78 时尝试上电并复查,再配置 PCIe BAR。USB3 会读取产品字符串、必要时解绑内核驱动并重置设备、设置 configuration 并 claim interface。

    这里必须区分三层资格:VID/PID 匹配、PCIe 链路建立(LTSSM 到 0x78)、AMD 架构校验。任何单一的 chestnutPresent 位都不代表 GPU 已准备好执行推理。

    三种地址空间与缓冲所有权

    USBIface.ctrl 把系统页、计算队列页和 512 KiB SRAM 映射到 GPU 虚拟区,主机持有桥的 dma_view;初始化时清空 fence 与 SRAM,防止沿用旧哨兵。USBIface.alloc() 与 USBAllocator.map() 的语义容易被误读:即使请求 host=True 也落在 VRAM,CPU 侧访问需通过 USB 桥映射,并非本机普通 DRAM。

    层次 关键函数 源码契约
    USB 桥控制内存 USBIface.ctrl 系统页、计算队列页与 512 KiB SRAM 映射到 GPU 虚拟区;初始化清空 fence 与 SRAM。
    VRAM/CPU 可见视图 USBIface.alloc()、USBAllocator.map() 即使 host=True 也落在 VRAM;CPU 访问须经 USB 桥映射。
    PCIe BAR、XDATA、SRAM USBMMIOInterface BAR 读写需 4 字节对齐;桥 XDATA 用 0xE4/0xE5,SRAM 用 0xF2 加 bulk 端点。
    主机 staging 与传输结构 _host_block()、_xfer() 常驻 CPU 缓冲保存 libusb handle/context 与双半区 staging;提交后仍需保留源缓冲直至完成。

    USB3.list_devices() 带 functools.cache,结果中的设备指针用 libusb_ref_device 保留。该实现未在此函数内定义重枚举失效,因此断开后同一进程再次选设备是否拿到最新列表,须通过模拟或实板验证,不应假设缓存会自动更新。

    双缓冲复制协议与双半区

    常量 HALF/CHUNK/SLOT 分别是 0x40000、0x40000-512、0x4000 字节:每个 SRAM 半区末尾预留哨兵块。usb_wire() 为载荷加 512 字节并向 slot 对齐;usb_chunks() 对输入用一个 CHUNK、对输出用两个 CHUNK 作为窗口,因此逻辑 COPY 可能拆成多次 USB 传输和 GPU 端复制。

    usb_copy_rewriter() 只针对 host 与远端设备跨界的 COPY,按复制方向形成连续 run 并给每块编号;usb_copy_slicer() 在 GPU 提交图中插入等待、SRAM 复制、清哨兵与 usb_fence 更新。主机侧先用 usb_drained() 等待上轮完成,再通过 0xE5 设置 fence。

    方向 主机侧 GPU 侧
    host → GPU usb_copyin() 轮换两个半区;usb_chunk() 等前次 transfer 与 fence、填 staging 哨兵、发 0xF2,再异步 bulk OUT。 等对应 SRAM 哨兵,复制载荷到目标 VRAM,清哨兵并更新 fence。
    GPU → host usb_copyout() 先发 0xF2 arm read、写 usb_go,然后 bulk IN,再将两半区载荷复制回目标 host 地址。 等 usb_go,将 VRAM 复制到 SRAM 两半区,清 completion queue 并更新 fence。

    usb_reap() 和 usb_drained() 都是条件轮询,本身没有显式墙钟超时。libusb 传输有各自的 timeout,USB3.bulk_wait() 会抛异步错误,但不能据此推断任一桥接 fence 永远有界完成。对新硬件必须分别故障注入「传输成功、GPU 不更新 fence」与「GPU 更新、USB 回执丢失」两类状态。

    USB 访存降级与提交开销

    pm_usb_lower 将远端参数缓冲的标量 load/store 改成 USB 读/写;usb_copy() 对可识别的连续 copy/fill 循环合并为 stream,不能识别时退为逐元素写。64 位 kernargs 写入由 usb_store() 拆为两个 32 位 0xF0 写;usb_load() 则通过桥的 PCIe read stream 读取。这些路径决定命令提交开销与异常时可能的部分写入状态,不是普通进程内内存赋值。

    底层 USB3 使用控制传输与同步/异步 bulk;异步完成回调把状态锁存到 _async_err,等待方通过 bulk_wait() 消费事件并检查失败。桥的 pcie_request() 对读请求/配置写回执重试,对部分写操作走不等回执的快速路径。

    故障层次 含义 不能推出
    USB 传输成功 bulk/控制事务完成 PCIe TLP 已被 GPU 接受。
    PCIe TLP 被接受 桥层读写回执正常 GPU 队列已完成该命令。
    GPU 队列完成 fence 被更新 模型输出数值有效。
    模型输出有效 上层得到可用结果 安全放行条件成立。

    测试证据与硬件验收

    仓库存在 MockUSB3 和 mock GPU 环境,可作为后续隔离测试入口,但本文未运行 USB/AMD 模拟测试,也没有实板。建议先固定模型与目标固件版本、建立逐帧输入/输出数值基线;再量测分块、bulk、SRAM、VRAM、GPU 与回读的延迟分布;最后故障注入断连、重枚举、短传输、哨兵未清、fence 停止与 GPU 挂起。

    模型回退、ChestnutActive 与 selfdrived 的控制退出需要联合验证,不能把 USB 接口可打开当作安全放行条件。

    常见问题

    Chestnut 路径下 host=True 的分配真的在主机内存吗?

    不是。源文档说明 USBIface.alloc() 与 USBAllocator.map() 即使请求 host=True 也落在 VRAM,CPU 侧访问必须通过 USB 桥映射,并不是本机普通 DRAM。

    USB 传输成功就说明 GPU 已经算完了吗?

    不等价。源文档把故障归因分为四层:USB 传输成功、PCIe TLP 被接受、GPU 队列完成、模型输出有效。它们逐层递进,任一层成功都不能推出下一层成立。

    一次 COPY 会变成几次 USB 传输?

    不固定。逻辑 COPY 可能被拆成多次 USB 传输和 GPU 端复制:usb_chunks() 对输入用一个 CHUNK、对输出用两个 CHUNK 作为窗口,usb_copy_rewriter() 还会按方向形成连续 run。

    拔插设备后同一进程会重新枚举吗?

    源文档未给出重枚举失效机制。USB3.list_devices() 带 functools.cache,设备指针用 libusb_ref_device 保留,但该函数没有定义缓存失效,须通过模拟或实板验证,不能假设会自动更新。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/TINYGRAD_CHESTNUT_USB_AMD_BRIDGE_DEEP_DIVE.md(USB/AMD 初始化链、三种地址空间、双缓冲复制协议与故障分层)。
    • 项目 02 —— openpilot-research/ 同目录 Chestnut 与 HCQ 专题(固件刷写、通用队列链接作为相邻边界参考)。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11

  • 座舱 DMS 推理链:1440×960 warp 与 6 维不确定度解码

    结论:座舱 DMS 的链路是 camerad 座舱帧经固定 1440×960 luma warp 进入 tinygrad 编译模型,输出一维向量再按元数据切片解码为左右驾驶员的脸姿态、位置与八类概率。发布消息的顶层 valid 恒为真,它只表示组包正常,不是摄像头视角、帧年龄、标定有效性与模型置信度都合格。本文为固定源码快照 c8fb906 的静态分析,未运行模型或图像回放。

    进程、构建与消息链

    manager 进程表在驾驶员视角条件下启动模型进程,非 webcam 的 PC 默认禁用。main() 等待 camerad 的座舱 VisionIPC,连接后按流报告的宽高加载模型和 warp 产物;每次 recv() 返回缓冲才运行并发布 driverStateV2。模型输出服务名义 20 Hz,真正频率由相机帧与推理耗时决定。进程只订阅 extrinsicsCalibration,没有在循环中对该主题做 SubMaster.all_checks() 门。

    构建侧 SConscript 从 dmonitoring_model.onnx 编译 dmonitoring_model_tinygrad.pkl,并为源码列出的两组座舱相机尺寸生成独立 dm_warp_<宽>x<高>_tinygrad.pkl。warp 目标为 DM_INPUT_SIZE=(1440,960),只使用亮度平面、边界填充值 16、变换矩阵放在 NPY 设备;NV12 stride/高度/缓冲大小由 get_nv12_info() 计算。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败,而不是自动支持任意 USB 摄像头。

    组件/函数 实现功能 边界
    ModelState.__init__() 读取 OOB 模型,解析输入形状/输出切片元数据,预分配模型输出及相机尺寸对应 warp。 load_oob() 依平台选 QCOM/METAL/CPU:LLVM,构建产物与后端必须兼容。
    ModelState.run() 写入外参 calib,用 VisionBuf 内存指针建立 tinygrad 张量,执行 warp 与模型,拷贝一维 float32 输出。 按缓冲指针缓存零拷贝张量;前提是上游环形缓冲布局和生命周期稳定。
    slice_outputs() 按 OOB 元数据中的切片把一维输出分为各预测头。 分割布局不硬编码在 Python 中;模型升级必须同步元数据。
    parse_model_output() sigmoid 转概率,脸部描述向量末六维经 safe_exp 变为标准差。 safe_exp() 裁上界 11 抑制指数溢出;这里不做完整 isfinite 校验。
    fill_driver_data() 将左右侧各自的姿态、脸位置/标准差和八类概率写入消息。 姿态取描述向量前三维,位置取第 4-5 维。
    main() 接流、读取最新外参、逐帧推理、解码并发送。 只在有新外参消息时复制 rpyCalib,未显式检查其顶层 valid。

    图像几何与内参选择

    首次收到帧后,main() 按 buf.width 是否等于 _os_fisheye.width 选择 _os_fisheye 或 _ar_ox_fisheye 内参,并计算目标模型内参到原相机内参的逆变换;其后不随外参变化重算图像 warp。八项原 main() 假 VisionIPC/模型探针确认:首个非空帧按宽度是否等于 _os_fisheye.width 选择内参、不检查高度,生成的 model_transform 此后锁存,后续缓冲宽度变化也不重算。recv() 为空时立即继续,既不轮询外参也不发布。

    外参 rpyCalib 被单独送进模型 calib 输入,不是这个固定的图像 warp 矩阵。外参长度不匹配会在推理前抛错;推理抛错则本帧不发布。SEND_RAW_PRED 判断的是非空字符串,值为 '0' 同样会携带原始输出字节。两组相机的宽高/焦距来自硬编码配置,源码还提示其中一组鱼眼焦距近似值可能不精确。自制相机不能只通过改 VisionIPC 流名完成适配,需重做标定/warp 与质量验证。

    输出切片与不确定度解码

    slice_outputs() 仅按元数据切片并加 batch 轴,切片超出原向量时得到短数组而不报长度错误。parse_model_output() 把每侧描述向量的末六维经 safe_exp 转标准差,其余前段作为姿态/位置;fill_driver_data() 只取前三项姿态和第 4-5 项位置及对应五项标准差。极大标准差 logit 在 11 截顶,NaN 仍传播;sigmoid 借用这个截顶指数,因此极负 logit 的概率下限约为 1/(1+exp(11)),不是精确零。

    解码同时输出 LHD/RHD 两套人脸结果及 wheelOnRightProb。下游 DriverMonitoring._update_states() 按 RHD 学习结果选择一侧,然后根据脸概率/姿态标准差决定是否把分心类型纳入提醒。因此 driverStateV2 是预测,不是已完成的驾驶员状态判定。八项原切片/解析/组包探针确认:合成的九维脸描述可在位置切片为空时仍由 get_driverstate_packet() 组出顶层 valid=True 消息,这只证明缺少形状校验,不代表真实模型会输出九维描述。

    构造输入 原函数观察结果 验证范围
    切片超出原向量 得到短数组,不报长度错误 只证明切片函数不独立校验长度。
    极负 logit 概率 下限约 1/(1+exp(11)),非精确零 sigmoid 借用 11 截顶指数。
    位置切片为空的九维描述 仍组出顶层 valid=True 消息 缺少字段完整性校验。
    改变 location_ts 假消息各字段不受影响 发布结构不含相机 SOF 时刻。

    发布函数直接创建 valid=True 的顶层消息;它表示此函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。main() 调用时传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数;消费者只有消息总线自己的 logMonoTime、帧 ID 和模型耗时,不能从这个字段推断原始相机 SOF。策略端的脸姿态转换使用固定 1928×1208 参考尺寸和焦距 598,而相机配置中的 cabin 焦距为 567;自制镜头/warp 不能只替换上游像素流而保留这套转换假设。

    验证现状与插件边界

    本地 dmonitoring_model.onnx 只有 Git LFS 指针,目录未有运行所需的座舱 tinygrad .pkl/warp 文件,故不能在当前归档执行真实推理或验证数值精度。process_replay 定义座舱模型回放,使用 cabinCameraState/VisionIPC 帧作为输入并忽略运行耗时字段;这仅证明仓库有回放入口,不代表当前快照已通过。环境变量 SEND_RAW_PRED 非空时把原始浮点输出写进 rawPredictions;此数据可能含座舱行为信息,日志/插件应考虑隐私和存储边界。

    插件化时,可先把座舱采集和推理作为只读影子插件,契约至少包含像素格式、宽高/stride/缓冲生命周期、内参与 warp 版本、外参来源、模型/输出切片版本、SOF 时间戳、帧年龄、概率及标准差校验、隐私分级。权威 driverStateV2 只能有一个发布者;插件不能把缺帧或低质量预测宣称为「驾驶员注意」,也不能绕开 dmonitoringd 安全策略与独立执行网关。

    常见问题

    driverStateV2 的 valid=True 能说明什么?

    它只表示 get_driverstate_packet() 这个函数正常组包,不是摄像头视角、帧年龄、标定有效性、模型置信度均合格的保证。探针确认合成的九维脸描述在位置切片为空时仍可组出顶层 valid=True 消息。

    座舱相机换成别的分辨率还能直接用吗?

    不能。warp 目标是固定的 DM_INPUT_SIZE=(1440,960),构建脚本只为源码列出的两组座舱相机尺寸生成 dm_warp_<宽>x<高>_tinygrad.pkl。缺少匹配尺寸的 warp 产物时,模型初始化会在打开文件处失败。

    为什么极负的 logit 概率不是 0?

    因为 safe_exp() 把指数上界裁到 11,而 sigmoid 借用了这个截顶指数,所以极负 logit 的概率下限约为 1/(1+exp(11)),并非精确零。

    帧 ID 能用来还原相机的原始时刻吗?

    不能。main() 虽然传入 vipc_client.timestamp_sof,但 get_driverstate_packet() 不使用该参数,发布结构里只有帧 ID、CPU/模型耗时与总线时间戳,不能据此推断原始相机 SOF。

    延伸阅读

    参考资料

    • 项目 02 —— openpilot-research/DRIVER_MONITORING_MODEL_PIPELINE_DEEP_DIVE.md(座舱图像、模型推理与预测契约、输出解码与几何假设)。
    • 项目 02 —— 同目录探针记录:六项初始化产物、八项主循环帧、七项缓冲缓存、八项切片/解析/组包、七项脸姿态几何隔离探针结论。

    说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

    更新记录

    首次发布 2026-10-11 最近核对:2026-10-11