lmflow
Contents

lmflow 设计方案

状态:成品。Rust 引擎、C ABI、C++ 糖层、可选 OpenCV adapter、18 个内置算子、 Python 绑定(pybind11)、原生 SDK 发布(各平台头文件+库)、三端文档站全部就位; 326 个测试(Rust 282 + soak 2 + doctest 3 + Python 39)全绿,TSan 硬门禁 0 竞态。Rust / C++ / Python 三种宿主的 hello_world 都输出正确;支持线程池绑核 + 实时优先级(Linux/Android),可交叉编到 Android / iOS / 鸿蒙。 定位:一个数据流图计算框架 —— 把计算描述成有向图,节点是算子(Kernel), 边上流动带时间戳的数据包(Packet)。 组成:Rust 引擎 + C ABI 门面 + C++/Python 算子。 核心概念:Graph(图)、Node(节点)、Kernel(算子)、Edge/Port(边/端口)、 Packet(数据包)、Contract(端口类型契约)、Poller/Observer(输出的拉/推)。


0. 目标与范围

0.1 做什么

0.2 阶段划分

阶段 内容
B(已完成) 每条边一个 FIFO;节点就绪 = 每个输入口至少有一个包。多输入按时间戳对齐、bound 传播、max_in_flight > 1 并行 context 池均已落地。
A(已完成) process_timestamps:落地为 batch 输入策略(攒够一批一次交给算子,见 §7.10)。B/A 两阶段的引擎能力至此全部落地。

批处理(batch 输入策略)已支持(见 §7.10):攒够 capacity 个包一次交给算子,process() 用 input_count / input_at 读整批,关流刷余批。用于批推理 / 窗口聚合。多输入口按时间戳对齐(与 sync 同源,见 §7.10)。内置 BatchSumKernel 为样板。

零输入口 source 节点(生成型算子)已支持(见 §7.4):内核自产数据、source_done() 自报产完;无数据时可 source_yield(delay) 协作式让出 worker。源不能挂委托执行器(否则会独占宿主线程)。内置 RangeSourceKernel 为样板。

子图(subgraph)+ 跨文件 include 已支持(见 §7.11):纯建图期变换,把带 subgraphs / node.type 的配置展平成等价扁平图,运行时引擎 / 调度器不感知子图。

反馈环(back-edge)已支持(见 §7.12):把输入口标为 back_edges 即「最新值反馈寄存器」—— 容量 1、不参与就绪 / 终止 / 对齐;去掉 back-edge 后的图仍是 DAG。未被 back-edge 打断的拓扑环仍在 init 报错。

0.3 非目标


1. 决策记录(ADR)

已锁定的决策及其理由 —— 避免重复讨论。

# 决策 理由
1 只暴露 C ABI,不提供 C++ 类接口 模板/std::any 无法过 FFI;C ABI 稳定、可被任意语言绑定
2 算子用 C++/Python,引擎用 Rust 保留既有 C++ 算子资产;引擎侧拿到 Rust 的并发安全收益
3 走阶段 B 先行 时间戳同步是最难的一块,先打通垂直切片再补
4 cargo 主导构建;Python 部分破例用 CMake 单一工具链最省事;但 pybind11 的 wheel 必须有 C++ 构建步骤
5 Python 绑定用 pybind11(非 PyO3) 场景要 OpenCV:cv::Mat ↔ numpy 零拷贝是 pybind11 最成熟的路径
6 引擎不解释 payload,只搬引用 任意数据类型都能流动;张量、图像只是「又一个类型」,不该做成引擎的一等概念
7 内建类型只为跨语言存在,对引擎无特权 跨语言时对方无法解读不透明 C++ 对象,故需一套双方都认识的内存约定
8 LMFlowBuffer 一个 N 维描述符统管图像/张量/音频 避免 IMAGE/TENSOR/AUDIO 各设一种类型;语义对齐 numpy buffer protocol
9 Python 算子只收发内建类型 保持 payload 语言中立。否则 Python 算子的输出接到 C++ 算子上会得到无法解读的指针,且只在运行时暴露;另有 GIL-in-drop 死锁风险
10 省拷贝用 CoW(take_input + make_mutable) 线性管线全程零拷贝;被扇出共享时才复制,从而不污染其它分支
11 只有图输入口限流,内部边不对生产者背压 内部边设硬上界会让「扇出后汇合」的合法 DAG 死锁(详见 §7.5)
12 手写 flow.h 为权威 + 布局一致性测试(不用 cbindgen) header 是给用户看的文档,可读性优先;用 static_assert + Rust 测试钉死布局,拿到等价安全性
13 flow.hpp 糖层保留,但不属于 ABI 让 C++ 算子写法自然;模板便利全部在用户 TU 内 monomorphize,不过界
14 OpenCV 不进 core/默认 SDK,隔离成 adapters/opencv 可选组件 引擎与 flow.h/flow.hpp 零图像库依赖;默认配置不查找、不链接、不安装 OpenCV。显式开启 LMFLOW_BUILD_OPENCV_ADAPTER 后才提供 <lmflow/opencv.hpp> 与 lmflow::opencv
15 LMFlowBuffer 预留 flags/device/reserved 一次性预留,未来加字段(最可能是 GPU 内存空间)不破 ABI
16 节点默认跑在默认线程池(按 CPU 核数);宿主线程执行改为显式 DelegatingExecutor 默认不必宿主进入引擎就能推进(旧默认下「只 send 不 wait 就不动」是常见困惑);要零并发/顺序确定/Python 免 GIL 争抢,自己声明一个具名 DelegatingExecutor、把节点指过去
17 端口扁平序号 = YAML 声明顺序 常见做法是按 tag 字典序分组,混用「有标签/无标签」端口时 Index(0) 拿到的不是写的第一个 —— 真实陷阱,故分道
18 引入 side packet(常量输入) options 只能给标量/JSON,无法交付「一个已初始化好的模型」这类对象
19 输入策略做成节点级可插拔(sync/immediate/fixed_size/sync_set) 实时丢帧与(A 阶段的)时间戳对齐共用同一扩展点;fixed_size 同时是「内部边无界」的配套内存约束
20 全局水位兜底,超限时转化为图输入口背压 内部边无界的直接后果:100 帧 × 6 条边 × 6MB ≈ 3.6GB。只在图输入口刹车不会重新引入 diamond 死锁
21 节点级统计 + watchdog,但不做抢占中断 卡死必须能定位到具体节点;而中断一个正在跑的算子无法安全实现(同 cancel 语义),故只做可观测
31 节点统计全用原子、不用 Mutex 每包每节点都要更新,放锁里就是在热路径加锁(改造前每包 4 次加锁:计时进/出 + 耗时 + processed)。改原子后实测端到端 -4~5%,且顺带满足 R1「调算子时不持任何引擎锁」。计数器用 Relaxed(不参与 happens-before);started_us 归零时不清,读侧按 in_flight > 0 判断,从而避开「清零 vs 新一次开始」的覆盖竞争
32 内存序分两类:纯计数器 Relaxed,同步标志一律 SeqCst 已逐个审过 39 处。降为 Relaxed 的只有 8 处 —— 全局水位(total_queued/_bytes,每包每消费者 4 次 RMW)与 dropped,它们除了跟阈值比一下不承载任何 happens-before。其余 31 处有意保留:终止判定的两段式检查(input_closed[i] && queue_len(i)==0)、has_error/cancelled/source_done 的发布、执行器 stop 标志、边与 poller 的 closed —— 降级会坏掉终止正确性。收益是 arm64 专属:实测 codegen,aarch64 上 SeqCst RMW 走 __aarch64_ldadd8_acq_rel、Relaxed 走 __aarch64_ldadd8_relax,而 x86_64 两者都是同一条 lock incq(故本机 bench 量不出),Android / iOS arm64 是发布目标
33 notify_activity 只在真有等待者时才 wake(waiters 计数与 gen 同锁) notify_activity 在 dispatch 里是每包每条边调一次,而 Condvar::notify_all 即使没有任何等待者也会走一次 futex 系统调用。代数递增必须保留(那是防丢唤醒的本体),但 wake 可以跳过:notifier 持锁时读到 waiters == 0,则任何「正要等待」的线程都还没拿到那把锁 —— 它随后会看到递增后的 gen 而根本不进入等待,故不丢唤醒。实测每跳边际派发成本 763 → 279 ns(-63%);线程池模式只降 5~7%,因为那时宿主确实在等、那些 wake 是必要的 —— 只省掉了无用的那些
34 运行统计分级(stats: off/basic/full,默认 basic);watchdog_ms > 0 或 trace_capacity > 0 时强制 full basic 保留状态、错误、吞吐、队列与背压等低成本诊断;full 再启用每次回调计时、百分位、CoW 和执行器耗时;off 仅保留调度正确性必需状态。这样默认不再为每次 process 支付两次 Instant::now(),又不制造 Cargo feature / ABI 变体。旧 stats_timing: true/false 分别兼容为 full/basic。watchdog 与逐次调用 trace 都依赖单次耗时,故都会强制 full 并说明是哪一个触发的。
35 max_in_flight == 1(默认)时跳过按序重排的 BTreeMap;flush_staging 不再为整批产出建临时 Vec perf 采样(非估算)显示:pending_flush: BTreeMap 的每次调用插入+删除、以及 flush_staging 的临时 Vec<OutputBatch>,连带 malloc/free 共占约 13.5%。而 max_in_flight == 1 时同一时刻只有一次调用在飞,seq 必然等于 next_flush_seq —— 重排缓冲纯属白做,可直接接手。dispatch 只读 packets,故签名改 &[Packet]:图输入口的 send 不再为单包 vec![pkt] 分配,staging 的缓冲清空后放回、容量复用(不再 finish_grow)。实测每跳边际 279 → 236 ns;remove_leaf_kv / SpecFromIterNested / finish_grow 三项从 2.96% / 1.93% / 1.13% 归零,malloc/free 总量 13.5% → 7.5%
36 try_claim 每个输入口只拿一次队列锁 原先每口把同一把队列锁拿 3 次:front_ts(读队首 ts)、pop_front、queue_len(算 inputs_done)。合成一个临界区。安全性来自 ADR #30:只有 try_claim 会 pop 且全程持 sched,别的线程只 push(追加尾部、不动队首),故队首稳定;inputs_done 那处也无差别 —— 它要求 input_closed,而关流后不再有 push、长度已稳定。实测每跳边际 236 → 213 ns(pool1 -7%)。三个分支(正向口 / 反馈口 / 批处理)都合了。readiness() 里还有一次(它要跨口算就绪),没并 —— 要把观察值带出来会引入耦合、收益仅 ~20 ns,不值
37 节点级 on_error: abort|skip,默认 abort 长跑实时管线里一帧坏数据不该杀掉整条流水线,而原先任何一次算子失败都终止全图。skip 只丢那一个包并推进下游边界(不推进就把一帧错误升级成整图卡死);复用「无产出也要推进边界」那条既有路径,不新写机制。有损行为绝不静默:计入 errors + WARN(指数退避)。只有两个值——不设单独的 log,因为 skip 本身一定计数并打日志。定位:能在算子内处理的就在算子内处理(返回成功不产出),skip 专治管不到的失败(契约校验、panic / C++ 异常、第三方算子)。只管逐包失败:Open / Close 的一次性生命周期失败不受此策略影响(打不开就该让 start() 失败,而非空转着每帧报错)
38 声明式源定速 rate: N(Hz) 源本要么内核自己写 sleep、要么灌爆下游。rate 让定速变成一行 YAML。当前实现把下一次 Source 调用放入执行器的延迟队列,worker 用同一 Condvar 定时唤醒,不为每次等待创建线程,也不在 worker 内 sleep。按本次实际开始时刻计算剩余周期,避免累积漂移
39 reset 保留算子实例的复位重跑 每会话重建图 + 重跑 open(重载模型)是实打实的开销。reset 复用已 open 的算子跑下一轮。安全靠「静止相」:要求 Terminated + is_idle(与 Drop/start 同依据),故 &self + 内部可变即可无并发复位 —— 不用 Arc::get_mut(Poller 也持 Arc<GraphInner>,宿主留着 poller 时拿不到独占)。不碰线程池(worker 随图存活、park 着复用)。epoch 不 reset(只是诊断基准)。最易漏:Edge::last_sent(单调性)、GraphShared 的 error(无现成清除路径)、input_bounds 回 pre_stream 而非 done
40 F16 用自写的软件转换,不用 _Float16、不用 F16C / NEON 内建 F16 是移动端推理的标准张量 dtype,而张量前处理组此前遇 F16 直接报错 —— 在最相关的场景里用不了。选软件转换的理由:_Float16 不是所有目标编译器都有(MSVC 没有可移植的 half 类型),内建指令要按架构分派 + 运行期探测;而前处理不在最内层推理热路径上,这点成本换来「任意编译器 / 架构上逐位一致」是值得的 —— 且正因不依赖编译器,舍入行为才能被测试钉死。舍入取 IEEE 默认(就近、平局取偶);double → half 直接从 double 位模式做、不经 float 中转,否则会双重舍入(极少数入参偏 1 ulp)。见 §5.3
41 batch 多输入口 = capacity 个「对齐元组」,而非「各口各自数够 capacity 个」 后者实现最省事,但会把 0 号口的第 k 个与 1 号口的第 k 个配成一对,而它们未必是同一帧 —— 图像批与掩码批就此错位,且不报任何错。静默的错误配对是本项目明确拒绝的失败模式,故一批 = 把 sync 的对齐连续跑 capacity 轮,各口取数允许不同(input_count(i) 本就按口计数,算子侧零改动)。不足一批只在所有正向口都关闭后才刷(否则是过早切批)。实现上就绪期快照时间戳前缀 + 算好每口取数,认领期照计划弹出 —— 每口仍只拿一次队列锁,ADR #36 未破。见 §7.10
42 类型契约做两级校验:静态可证的建图期拒绝,ANY 边保留运行期检查;算子输出也必须兑现契约 producer output 与 consumer input 都声明具体类型且不同,无需等首包即可判错,故建图失败;任一侧为 ANY 时真实类型仍由包决定,继续逐包检查。输出契约不能只拿来推导下游:否则直接连 graph output 的错误包无人检查,故 process / close 的 staging 在 dispatch 前统一验证。Rust 自定义跨语言类型用 unsafe trait InteropType 把 ABI 承诺集中到实现处;任意 id 的 new_interop 降为 unsafe 且禁止伪装成内建类型
43 自定义类型身份从裸 type_id 收紧为 (稳定名,size,align) 描述符 仅比较 64 位哈希无法发现碰撞,也无法发现两侧用同一稳定名却声明了不同布局。lmflow_register_type_descriptor 对完全相同的重复注册幂等,但同 id 异名、同名异 id、同名同 id 异布局都立即失败;三期进一步删除 name-only 注册,并强制 id == lmflow_type_id(稳定名),不再允许名字与数值身份各自声明。C++ Packet::Make<T> / Contract::InputSet<T> / OutputSet<T> 自动注册,Rust InteropType 自动注册。已注册固定布局的 Foreign payload 会按 size 纳入字节水位;这是对象本体的浅尺寸,不包含 std::vector 等对象内部另行分配的堆内存
44 外部 LMFlowBuffer 在解引用前做完整描述符校验,且本版本只接受 CPU 内存 device/flags/reserved 若静默忽略,会把 GPU 指针或未来布局当 CPU 地址读;ndim > 8 会在切片时越界,任意 strides 的乘加也可能溢出。故 lmflow_packet_from_buffer 先统一验证维数、dtype、尾部零值、零 stride、数据指针和整个可寻址偏移范围,再进行连续快拷或 N 维逐元素拷贝。负 stride/非连续 CPU 视图继续支持;无法从裸指针证明真实分配大小,调用方仍须保证描述的地址范围确实可读
45 外部 CPU buffer 可显式零拷贝移交所有权,释放回调绑定 owner 而非 data 指针 解码器、相机和张量运行时常已拥有分配,强制 from_buffer 再复制会造成隐性带宽成本。lmflow_packet_adopt_buffer 按值保存描述符,并在最后一个 Packet 引用释放时调用 release_fn(user_data);单独的 user_data 能表达基址、池句柄或带引用计数的 owner,即使 data 指向负步长视图中部也可正确释放。失败不转移所有权;回调必须可在任意工作线程执行。可写且独占时 CoW 原地返回外部 strides,READONLY 或共享时复制为引擎连续缓冲
22 type_id = FNV-1a(修饰名),而非 typeid().hash_code() 后者实现定义、不保证跨动态库一致;而本项目 C++ 算子在 core、Python 绑定在另一 .so,天然跨产物。事后再改需全量重编,故一开始就用稳定方案 + LMFLOW_DECLARE_TYPE_NAME 逃生口。注意修饰名跨编译器不保证相同(GCC/Clang 走 Itanium ABI 一致,MSVC 不同),故跨工具链混用算子必须用逃生口显式声明稳定名。哈希算法在 C++ 与 Rust 各有一份独立实现,已用同一个字面量在两侧钉死(见 §13.5)
23 时间戳单调性:图输入口强制校验,内部边仅 debug 构建校验 外部数据进入的唯一门校验一次即可挡住绝大多数乱序;内部边逐包校验是热路径开销,且算子产出乱序属算子 bug,用 debug_assertions 捕获即可
24 不做 stream header,用 side packet 覆盖 header 会引入「流上的第二种数据」及其生命周期问题;side packet 已能表达「整条流不变的属性」。少一个概念优于多一个
25 不做程序化构图 API,动态图由宿主生成 YAML 保持单一真相源;builder API 是一大片表面积,应由真实需求驱动而非预先设计
26 不启用 LMFLOW_TYPE_HOST_OBJECT,且明确拒绝而非放任 见 ADR #9。原先「未启用」只靠「没有普通构造函数生产它」维持 —— 契约声明 7、C 侧手填 7 或 Rust unsafe from_foreign 仍可让数值相等检查放行。现契约、图输入、算子输出、side packet 四条入口都拒;报错给出 BUFFER / STR+JSON 替代方案。Rust 的 new_interop 也禁止使用内建保留区 0..15
27 子图(subgraph)= 纯建图期展开,不进 ABI node 的 type: 填子图名;subgraphs: 段内联定义、include: 引外部子图库。建图期展平成扁平图(命名空间 parent/inner + 边界按位置重映射),引擎不感知子图。见 §7.11
28 max_in_flight > 1 用 context 池 + 按序重排,而非单纯放开并发 并行处理多个时间戳时,完成顺序 ≠ 时间戳顺序;必须按序号重排刷新,否则下游时间戳非单调。序号在认领时按「取最小就绪时间戳」的顺序分配,故序号序 = 时间戳序
29 max_in_flight > 1 要求所属执行器线程数 > 1 单线程池与委托执行器(0 线程)的并行度恒为 1;默认池按核数开,故默认节点配它是合法的。判据看解析出的执行器,不看 YAML 里有没有写 executor
30 认领时即弹入输入(pop-at-claim),而非运行时 并行认领必须原子地「定时间戳 + 弹包」,否则两个并发认领会取到同一时间戳的包。副作用:空闲节点会立刻认领第一个包,故它不受 fixed_size 丢弃约束(在飞的包不算积压)

2. 分层架构

┌──────────────────────────────────────────────────────────────────┐
│  宿主(驱动图):Rust / C++ / Python                                │
│    new → init_from_yaml → add_poller → start                      │
│    loop { input.send(pkt); poller.next() } → close → wait_done    │
├─────────────────────── C ABI (include/lmflow/flow.h) ────────────────────┤ ← unsafe 边界①
│  Rust 引擎 (lmflow crate, lib + staticlib + cdylib)               │
│    graph(索引 arena) · scheduler/executor · edge(FIFO)            │
│    packet · timestamp · config(YAML) · registry · poller          │
├─────────────────────── C ABI (回调) ──────────────────────────────┤ ← unsafe 边界②
│  算子:C++(flow.hpp 糖层)   |   Python(pybind11 蹦床)            │
│        process 内调 lmflow_ctx_* 读输入 / 发输出                     │
└──────────────────────────────────────────────────────────────────┘

3. 数据模型

3.1 Packet 与所有权

pub enum Payload {
    Native(Box<dyn Any + Send + Sync>),  // Rust 侧构造
    Builtin(BuiltinPayload),              // 引擎分配:BYTES / BUFFER / 标量 / STR
    Foreign(ForeignPayload),              // 外部构造:裸指针 + drop_fn
}

pub struct ForeignPayload {
    ptr: *mut c_void,
    drop_fn: Option<unsafe extern "C" fn(*mut c_void)>,
    type_id: u64,
}
impl Drop for ForeignPayload {
    fn drop(&mut self) { /* 调一次 drop_fn */ }
}
// 断言:同一 payload 任一时刻只被单线程访问;drop 可能发生在别的线程。
// 前提由「节点独占令牌」保证(§7.0 规则 R3)。这是本设计唯一的 Send/Sync unsafe 断言。
unsafe impl Send for ForeignPayload {}
unsafe impl Sync for ForeignPayload {}

#[derive(Clone)]
pub struct Packet {
    data: Option<Arc<Payload>>,   // None = 空包(仅时间戳,用于 bound / 关流)
    ts:   Timestamp,
}

跨界表示 LMFlowPacket{payload, type_id, timestamp, owner, drop_fn}(40 字节), 三种所有权语义由 owner 区分:

场景 owner 语义
宿主/算子新建 NULL 提交(send/emit)后引擎接管;不提交须 lmflow_packet_drop
引擎借出(lmflow_ctx_input、observer 回调) 非空 借用,不得 drop、不得跨回调留存
引擎移交(poller_next_status、内建构造、clone) 非空 调用方持有一份引用,须 emit/send 或 lmflow_packet_drop

多路分发(Forward/扇出)= Arc::clone,不拷贝数据。

3.2 数据类型模型:两条路

引擎对 payload 完全不作解释,只搬引用、只按 type_id 做相等性校验。因此任意类型都能流动。

  1. 任意自定义类型(推荐给纯 C++ / 纯 Rust 管线) 调用方自备指针与 drop_fn,type_id 自取(C++ 糖层默认 typeid 哈希)。 cv::Mat、自定义结构体、模型张量对象一视同仁,引擎零参与。
  2. 内建类型(为跨语言而设) BYTES / I64 / F64 / BOOL / STR / BUFFER。由引擎分配、复制、释放, 免去在别的语言里构造 drop_fn;type_id 为约定常量,跨语言稳定。 对引擎没有特权,只是一套双方都认识的内存约定。

type_id 的现实限制:typeid(T).hash_code() 跨编译器/跨动态库/-fno-rtti 不保证一致。 算子与宿主分属不同编译产物时应改用稳定方案(对类型名做 FNV-1a)。糖层已把生成逻辑 收敛到单一函数 lmflow::TypeId<T>(),改一处即可。

3.3 LMFlowBuffer:一个描述符统管所有大块数值数据

typedef struct {
  void*   data;                    /* 首字节 */
  int64_t shape[8];                /* 各维元素数 */
  int64_t strides[8];              /* 各维**字节**步长 */
  int32_t ndim, dtype;
  uint32_t flags; int32_t device;  /* READONLY 标记 / 内存空间 */
  int64_t reserved[2];             /* ABI 预留,置零 */
} LMFlowBuffer;                      /* 168 字节,布局由 static_assert 钉死 */
用途 表示
灰度图 ndim=2, [H,W]
彩色图 ndim=3, [H,W,C](cv::Mat / numpy HWC)
推理张量 ndim=4, [N,C,H,W]
音频 ndim=2, [帧数, 声道]

语义与 numpy buffer protocol 一致(strides 以字节计,不要求连续),所以 numpy 与 cv::Mat 都能零拷贝包住同一块内存。

3.4 引用与写时复制(省拷贝)

payload 默认不可变共享。需要就地改写时用 CoW —— 语义等同 Arc::make_mut。

关键点(极易写错):算子拿到的输入包是借用,引擎的 Context 自己还持一份引用。 若直接 clone + make_mutable,引用数 ≥ 2,CoW 必然复制,省拷贝的意图落空。 必须先把包从输入槽取走:

lmflow::Packet p = cc.TakeInput(0);          // ← 关键:移出输入槽
LMFlowBuffer buf{};
if (LMFlowStatus st = p.MakeMutableBuffer(&buf)) return st;   // 独占 → 零拷贝
// ...原地写 buf.data...
cc.Emit(0, std::move(p));

线程池上的线性管线也保持零拷贝。 算子回调返回后,引擎立即释放本次 invocation 的输入槽;派发到下游后,先清除 producer 的 staging 引用,再唤醒下游 worker。这样下游 take_input + make_mutable 在唯一消费者的线性管线中稳定看到独占 payload。扇出仍然 保留共享引用并按 CoW 复制,保证兄弟分支互不污染。

CoW 生效的不变量(必须写进代码注释):

引擎在投递数据包之后,不得再保留任何额外引用。 否则引用数恒 ≥ 2,CoW 永远退化成全量拷贝 —— 而且不报错,只是变慢。 (例如「为推进时间戳边界而缓存最后一个包」这类实现会静默破坏它。)

限制:CoW 只支持引擎持有的内建 payload(BUFFER/BYTES/标量)。自定义 payload 引擎只有 drop_fn、无从复制,返回 LMFLOW_ERR_INVALID_ARG(该情形自行拷贝)。 要通用化需给 LMFlowPacket 增加 clone_fn 字段 —— 会改 ABI 布局,本版本不做。

3.5 Timestamp

UNSET < UNSTARTED < PRE_STREAM < MIN … MAX < POST_STREAM < ONE_OVER_POST_STREAM < DONE
                                 └ 普通数据区间 ┘

PRE_STREAM/POST_STREAM 是流首/流尾的单包位置;DONE 表示端口已关且不再有数据; UNSET 为默认值。算术用饱和运算,哨兵附近不会溢出回绕。 已实现于 core/src/timestamp.rs(含 9 个单测)。

提交时 timestamp == UNSET 的包,引擎自动继承当前 input_timestamp(与 Forward 一致); 在图输入口上提交 UNSET 视为非法。

3.6 Side packet:常量输入

整个 run 期间不变的任意对象,由宿主在启动前注入,算子按名字读取。

options side packet
来源 YAML 宿主运行前注入
内容 标量 / JSON 任意 payload(含自定义类型)
典型 阈值、尺寸、开关 已加载的模型句柄、标定矩阵、查找表、词表、外部资源上下文

没有 side packet 就无法把「一个已经初始化好的模型」交给算子 —— options 做不到, 这是引入它的直接原因。

LMFlowStatus lmflow_graph_set_side_packet(LMFlowGraph*, const char* name, LMFlowPacket pkt);  /* start 之前 */
LMFlowPacket lmflow_ctx_side_packet(const LMFlowContext*, const char* name);                /* 借用,勿 drop */
bool       lmflow_ctx_has_side_packet(const LMFlowContext*, const char* name);

4. 对外 C 接口

权威定义见 include/lmflow/flow.h(手写,已通过 gcc -std=c11 -Wall -Wextra 与 g++ -std=c++17 双向验证)。本节只列分组,避免文档与 header 双份漂移。

分组 主要函数
ABI / 诊断 lmflow_abi_version lmflow_last_error lmflow_set_log_callback
Packet 通用 lmflow_packet_drop lmflow_packet_clone
内建类型 lmflow_packet_from_bytes/i64/f64/bool/str + 对应 as_*
缓冲 lmflow_packet_new_buffer lmflow_packet_from_buffer lmflow_packet_as_buffer lmflow_dtype_size
写时复制 lmflow_packet_make_mutable_buffer lmflow_packet_make_mutable_bytes
算子注册 lmflow_register_kernel + LMFlowKernelVTable(48 字节)
Contract lmflow_contract_num_inputs/outputs …_input_id(tag) …_set_any …_set_type
Context 数据 lmflow_ctx_input …_input_payload …_take_input …_emit …_forward …_set_next_ts_bound
Context 端口 lmflow_ctx_input_id(tag,idx) …_input_index(name) …_input_name …_num_inputs/outputs
Context 参数 lmflow_ctx_option_i64/f64/bool/str + lmflow_ctx_options_json
图 lmflow_graph_new …_init_from_yaml …_start …_free
输入 lmflow_graph_input → lmflow_input_send/try_send/close;便捷 lmflow_graph_add_packet
输出 lmflow_graph_add_poller(_ex) → lmflow_poller_next_status/try_next_status/next_timeout;lmflow_graph_observe(_ex)
终止 lmflow_graph_cancel …_close_input …_close_all_inputs …_wait_done …_wait_done_timeout
Side packet lmflow_graph_set_side_packet lmflow_ctx_side_packet lmflow_ctx_has_side_packet
类型描述符 lmflow_type_id lmflow_register_type_descriptor lmflow_type_name lmflow_type_size lmflow_type_align
空闲 / 暂停 lmflow_graph_wait_until_idle …_timeout lmflow_graph_pause lmflow_graph_resume
算子自我信息 lmflow_ctx_node_name …_kernel_name lmflow_ctx_log lmflow_ctx_set_error lmflow_ctx_close_reason lmflow_ctx_counter_add
参数(增强) 点号路径嵌套、lmflow_ctx_require_option_*(必需参数)、lmflow_ctx_option_*_array(数组)
全局水位 lmflow_graph_total_queued …_total_queued_bytes;YAML max_queued_packets/bytes
统计 lmflow_graph_node_stats(LMFlowNodeStats,全原子无锁采集)、lmflow_graph_counter_value;YAML watchdog_ms
状态 / 拓扑 lmflow_graph_state …_num_input_ports/output_ports/num_nodes
内省 lmflow_graph_dump lmflow_graph_to_dot_view(Graphviz DOT) lmflow_graph_queue_depth lmflow_graph_dropped_count lmflow_graph_last_error lmflow_packet_debug_string

接口设计要点


5. 算子

5.1 C++ 算子(flow.hpp 糖层)

糖层 header-only、不属于 ABI、零运行开销,100% 建立在 flow.h 上。 核心是 KernelAdapter<T> 把虚函数桥成 C ABI vtable,并在蹦床里 try/catch 挡住 C++ 异常穿越 FFI(异常穿 extern "C" 是 UB)。

class PassThroughKernel : public lmflow::Kernel {
 public:
  static void GetContract(lmflow::Contract& c) { c.InputSetAny(0); c.OutputSetAny(0); }
  lmflow::Status Process(lmflow::Context& cc) override {
    cc.Forward(0, 0);                     // 零拷贝直通
    return lmflow::Status::Ok();
  }
};
LMFLOW_REGISTER_KERNEL(PassThroughKernel)    // 或 LMFLOW_REGISTER_KERNEL_AS(T, "别名")

5.3 F16(binary16)转换

张量前处理组支持 LMFLOW_DTYPE_F16,转换由 cpp/kernels/buffer_util.hpp 里自己实现的 half_to_float / f64_to_half 承担。刻意不用 _Float16,也不用 F16C / NEON 内建:

舍入是就近取整、平局取偶(IEEE 默认),double → half 直接从 double 位模式做而 不经 float 中转 —— 否则会双重舍入(两次各取整一次,极少数入参偏一个 ulp)。 上溢到 ±inf、下溢到非规格数 / ±0(保号)、inf/NaN 均按 IEEE 处理。

验证:cpp/tests/buffer_util_test.cc —— 期望值全是硬编码的 IEEE 位模式(不与任何编译器 内建类型对照,故在 MSVC / arm64 上同样有效),含全部 65536 个位模式的 half → double → half 往返、上溢/下溢临界点(65519 → 最大有限值 vs 65520 → inf)、以及相邻 half 的精确中点 (平局取偶最容易写错的地方)。CI 在 -O0 与 -O2 两档都跑(浮点位操作对优化敏感)。 开发时另用 GCC 的 _Float16 作 oracle 穷举对照过一次(65536 个 half→float + 46 万个 double→half,含全部相邻 half 中点),逐位一致 —— 但该对照不进仓库,因为它不可移植。

5.2 Python 算子(pybind11,已实现)

@lmflow.kernel("PyDouble")
class PyDouble(lmflow.Kernel):
    @staticmethod
    def get_contract(c):
        c.input_set_any(0); c.output_set_any(0)
    def open(self, cc):    self.factor = cc.option_int("factor", 2)
    def process(self, cc): cc.emit(0, cc.input(0).as_int() * self.factor)

与 C++ 算子在 YAML 里平等引用 —— 引擎不知道算子是什么语言写的。 细节见 §8 专章。约束:只收发内建类型;回调期持 GIL;异常转错误码。

5.2b Rust 算子(trait Kernel,已实现)

Rust 也能一等公民地写算子:实现 lmflow::Kernel(get_contract/open/process/close)+ 运行期 register_kernel::<T>("Name"),YAML 用 kernel: Name 引用。安全的 KernelCtx/ KernelContract 包装内部 Context/Contract(不裸调 C ABI);panic / Err 都被 catch_unwind 兜成图错误、不穿越边界。这是C ABI vtable 之上的糖(和 flow.hpp 的 KernelAdapter<T> 对 C++ 做的一样),不是引擎绕过 vtable 的原生快路(见 ADR)。见 core/src/kernel_api.rs。

外部 Rust 工程:cargo add lmflow → use lmflow::{Graph, Packet, Timestamp, Kernel, register_kernel}。 引擎就是这一个 crate lmflow(包名 = 库名 = lmflow → liblmflow.a,C ABI/CMake/Python 共用同一库)。

5.3 内置算子清单(cpp/kernels/,一文件一算子)

既是可用算子,也是 API 覆盖用例。

类型约定:捆绑算子一律用内建类型(LMFLOW_TYPE_I64 等)声明契约, 而不是 InputSet<int>(C++ 的 typeid)。原因是后者 Python/Go 侧无从产生同样的 标识 —— 若用它,Python 送来的整数会被类型校验拒绝。这条是「一套算子三语言可用」 的前提,flow.hpp 为此提供了 InputSetBuiltin。

算子 用途 覆盖接口
PassThrough 零拷贝直通 Forward
Scale 参数化数值变换 OptionI64、类型声明
Sum 有状态累加,Close 输出总和 跨包状态、PostStream
Split 1 进 2 出(扇出) 多输出
Zip 2 进 1 出 InputId(tag) 按标签定位端口
Filter 条件过滤 不 Emit + SetNextTimestampBound
Stringify int → std::string 异类型输入输出
Sink 只消费不产出 零输出口
Invert 原地改写 TakeInput + CoW MakeMutableBuffer
Normalize 参数用法示范 必需参数 / 数组参数 / 点号路径 / side packet 声明 / 日志 / 关闭原因

6. 图与拓扑

6.1 Rust 侧结构:索引 arena

核心决定:不还原自引用裸指针对象图,所有实体存在 Graph 的 Vec 里, 相互引用用 usize id。这既契合数据流本身的整数 id 语义,也避开借用检查器的自引用难题。

pub type NodeId = usize;
pub type EdgeId = usize;

pub struct Edge {
    name: String,
    producer: Option<NodeId>,           // None = 图输入口喂入
    consumers: Vec<(NodeId, usize)>,    // (下游节点, 它的第几个输入口)
    queue: Mutex<VecDeque<Packet>>,
    bounded: bool,                      // 仅图输入口为 true(§7.5)
    closed: AtomicBool,
}

pub struct Node {
    name: String,
    kernel: KernelInstance,             // C++/Python 算子黑盒
    inputs:  Vec<EdgeId>,
    outputs: Vec<EdgeId>,
    ctx: Mutex<Context>,
    sched: Mutex<NodeSched>,
}

pub struct Graph {
    nodes: Vec<Node>, edges: Vec<Edge>,
    edge_by_name: HashMap<String, EdgeId>,
    graph_inputs: HashMap<String, EdgeId>,
    graph_outputs: HashMap<String, EdgeId>,
    executors: HashMap<String, Executor>,
    pollers: Vec<Arc<Poller>>,
    state: Mutex<GraphState>,
}

用 HashMap<name, EdgeId> 直接拿 id,避免「名字排序名次」与「存储下标」混用这类错误。

6.2 init_from_yaml 的校验清单

任一不通过即返回错误,并可由 lmflow_last_error() 取原因:

  1. 端口名引用不到上游生产者;
  2. 同一端口名有多个生产者;
  3. 图输入口与某节点的输出口同名;
  4. 拓扑成环 —— 未被 back-edge 打断的环无法终止,故直接拒绝;把反馈输入口标为 back_edges 即可合法成环(§7.12);
  5. 节点的 executor 名未在 executors: 中定义;
  6. 零输入口节点 —— B 阶段的就绪规则对空集恒为真,会被无限调度成自旋(§7.4);
  7. max_in_flight > 1 却落在只有一个线程的执行器上(单线程池、或 0 线程的委托执行器, 并行度恒为 1)→ 报错,不静默降级。默认池按核数开,故默认节点配它通常合法。

第 7 条尤其重要:宁可报错也不静默忽略,否则用户以为开了并行、实际没有。

6.3 图的生命周期状态机

      new()            init_from_yaml()          start()
 ─────────────► Created ──────────────► Initialized ──────────► Running
                                             │                     │
                    add_poller / observe ────┘        close_all_inputs()
                    input()(取句柄)                  或 cancel()
                                                                   ▼
                                                                Draining
                                                                   │ 全部节点关闭
                                                                   ▼
                                                               Terminated ──► free()
状态 允许的操作
Created init_from_yaml、free
Initialized add_poller、observe、input(取句柄)、start、free
Running send/try_send、poller_next_status*、close_input、close_all_inputs、cancel、wait_done*、dump、queue_depth
Draining 同 Running,但 send 返回 LMFLOW_ERR_CLOSED
Terminated wait_done*(立即返回)、last_error、dump、free

6.4 端口的命名与定位

有两套标识符,分工明确:

标识符 属于 用途
端口名 (name) 图 连接用:上游 output_ports 与下游 input_ports 同名即连成一条边。整张图中每个名字只能有一个生产者
标签 (tag) 算子 算子表达「我哪个口是什么语义」,不依赖 YAML 书写顺序,改边名也不会错

声明语法(input_ports / output_ports 的元素):

input_ports: ["frames",                    # 无 tag(归入空 tag "")
              "VIDEO:cam0",                # 有 tag,index 自动
              "MASK:0:m0", "MASK:1:m1"]    # 有 tag,index 显式

三种定位方式:

cc.InputId("VIDEO")       // → 1    按 tag(推荐:语义稳定)
cc.InputId("MASK", 1)     // → 3
cc.InputIndex("frames")   // → 0    按边名(通用/路由类算子偶尔需要)
cc.Input(0)               //        直接序号,最省事但依赖声明顺序

规则:

最后一条是有意与常见做法分道(ADR #17)。按 tag 字典序分组的方案下,混用 有标签与无标签端口时 Index(0) 拿到的不一定是你写的第一个 —— 这是个只在运行时 才暴露的陷阱。改成声明顺序后它就不存在了。


7. 执行模型(B 阶段详细规格)

7.0 三条硬规则(整套并发设计的地基)

R1 —— 调用算子期间不持任何引擎锁。 回调前必须放掉所有锁,否则算子内部回调 lmflow_ctx_*(或算子自身阻塞)会死锁。 这条决定了 §7.3 的 staging 设计。

R2 —— 锁序恒为 node.sched → {edge.queue, blocked_flush_nodes},禁止反向。 推包路径必须「锁边 → push → 解锁边 → 再唤醒下游节点」。全局无环,故无死锁。

blocked_flush_nodes 是「哪些节点有 blocked_flush」的索引,必须与 sched.blocked_flush 在同一把 node.sched 锁内一起更新。否则会出现「索引已空、节点仍有 blocked_flush」 这类不一致快照,而 wait_done / is_idle 正是读这个索引来判定死锁的 —— 不一致就会 把正常排空误报成卡死。

只读该索引的地方(is_idle、wait_done、wait_until_idle、resume_blocked_flushes) 一律「取快照后立即释放,再去锁 sched」。注意这依赖临时 MutexGuard 在语句末释放: let v: Vec<_> = self.blocked_flush_nodes.lock()...collect(); 出了这条语句锁就没了。 同一条语言规则曾经反过来咬过一次(见 §附录的 pump_step 自锁死),所以别把 .lock() 的结果绑成活得更久的局部变量,那会把只读路径变成反向锁序。

R3 —— running == true 是节点的独占令牌。 一旦某线程把节点置为 running,它即独占该节点的 Context 与「从输入边弹包」的权利, 之后无需再持 node.sched 锁。这是 §3.1 中 Send/Sync 断言的依据。

7.1 M2:同步 push(无线程,先验证 FFI 闭合)

7.2 M3:节点调度状态机(合并唤醒,防丢唤醒)

struct NodeSched {
    opened: bool, closed: bool,
    running: bool,   // 已有任务在跑(独占令牌,R3)
    rescan: bool,    // 运行期间又来了包 —— 跑完必须重扫,否则丢唤醒
}
fn try_claim(node) -> bool {
    let mut s = nodes[node].sched.lock();            // R2:先 node
    if !s.opened || s.closed { return false; }
    if s.running { s.rescan = true; return false; }  // 合并唤醒,不重复入队
    if !inputs_ready(node) { return false; }         // 内部按需短暂锁 edge
    s.running = true; true                            // 拿到独占令牌
}

fn run(node) {                        // 由 R3,以下全程不持 node 锁
    let ctx = pop_one_from_each_input(node);
    let st  = kernel.process(ctx);     // ★ 零锁调用算子(R1)
    if st != OK { record_error(st); discard_staging(node); return finish(node); }
    dispatch_staging_to_downstream(node);   // 锁边 push → 解锁 → 唤醒下游(R2)
    finish(node);
}

fn finish(node) {
    let again = { let mut s = lock(); s.running = false;
                  let a = s.rescan; s.rescan = false; a };
    if again || inputs_ready(node) { schedule_if_claimed(node); }
    maybe_close(node);                 // §7.6
}

为什么必须有 rescan:若上游在本节点 running 期间 push 了包,try_claim 会因 running 返回 false;若不记下这次唤醒,包就永远躺在队列里没人处理 —— 经典丢唤醒。 (这也是调度态需要 Scheduling / SchedulingPending 两态区分的原因。)

7.3 emit 走 staging —— 让 R1 成立

算子在 process 里调 emit/forward 不直接写下游边(那要在回调期持边锁,违反 R1), 而是写进本节点 Context 内的暂存区;process 返回后引擎统一分发。

struct Context {
    inputs:  Vec<Option<Packet>>,   // None = 已被 take_input 取走
    staging: Vec<Vec<Packet>>,      // 每个输出口一个暂存队列
    input_ts: Timestamp,
}

⚠ 裸 C ABI 用户注意:take_input 后若既不 emit 也不 drop 就早退,会泄漏。 C++/Python 侧有 RAII/GC 兜住,裸 C 没有。

7.4 就绪判定 + 零输入 source 节点

B 规则:每个输入口都有 ≥1 包 → 取各口队首组成一次 Process(多输入按时间戳对齐,见 §7.2)。

零输入节点 = source(生成型算子),由内核自产数据。它套不上上面的规则(空集「恒真」既会自旋、又会「空真即关」),故单独处理:

内置 RangeSourceKernel(产 0..count 后 source_done)为样板。

7.5 背压策略

只有图输入口是限流点;图内部的边不对生产者施加背压。

机制 性质 满了怎样
max_queue_size(默认 100) 只是软水位,对所有边一视同仁 仅告警(指数退避)+ 可用 queue_depth 观测。不阻塞任何人
max_queued_packets(全局水位,默认 0 = 不限) 全图在途包数硬预算,只在图输入口施压 send 等排水,try_send 返回 LMFLOW_ERR_WOULD_BLOCK
fixed_size 输入策略(按节点输入口,非按边) 有界 + 有意有损 丢最旧;计数 + 首次 WARN。不阻塞上游
有界 Poller(capacity + overflow policy) 图输出订阅队列的局部上界 block 无损但要求宿主并发排水、带等待上界(默认 5s,到点响亮失败);drop_oldest / drop_newest / latest 有损并计数告警
input_queues.packets / input_queues.ports.*.packets 内部输入口无损包数硬上界 满时生产者保留 staging、释放 worker;下游出队后协作式恢复

⚠ 本表曾经是错的,而且错在一条安全属性上:它写着「图输入口有界(max_queue_size), 满时 send 阻塞至有空位」。实际上 max_queue_size 在全引擎只被 warn_if_over_soft_limit 读一次,send 从不看它 —— 它对图输入口和内部边同样只是告警。真正让 send 等待的是 全局包数水位,而它默认为 0(不限)。也就是说:**不显式配置 max_queued_packets 或节点级 input_queues.packets 时,普通内部队列没有任何硬上界, 只有 depth 100 时的一条 WARN。 (flow.h 的对应段落曾有同样的错误描述,已一并修正。)

Poller 队列属于全局水位统计:每个 Poller 是独立订阅者,因此同一包投给 N 个 Poller 会计 N 个队列槽(引用计数共享 payload,但每个订阅者都能独立滞留它)。默认 add_poller 保持历史兼容——无容量限制;需要严格约束输出滞留时用 bounded Poller:

有损策略都有独立 dropped_count 与指数退避 WARN。Poller pop、overflow 丢弃、reset 清空均同步扣减全局 packet/byte 计数。已注册 type descriptor 的自定义 Foreign payload 按固定对象 size 计量;这是浅尺寸,不包含 std::vector 等对象内部堆分配。

为什么内部边不能直接阻塞 worker —— 否则「扇出后再汇合」的合法 DAG 会死锁:

        ┌─► B(慢) ─┐
   A ──►┤           ├──► D
        └─► C(快) ─┘

C 迅速填满 D 的输入队列而阻塞;D 却要等 B 那一路才能消费;B 又在等 A 推进; 而 A 已阻塞在 C 上 —— 循环等待,且不需要环形拓扑就会发生。

input_queues 因此不在 dispatch 里等待。当前调用完成后若下游容量不足, 其 context 槽与 staging 保留在节点调度状态里,worker 立即返回线程池;该节点暂停认领 新输入。任一下游从输入队列弹包后重试 pending flush。限制的是节点继续产出, 不是占住线程等待,diamond 不形成线程循环等待。

input_queues.packets 是节点所有正向输入口的默认上限; input_queues.ports.video: {packets: 2} 按端口名覆盖。未列出的端口继承节点默认值, 显式 0 表示该端口不限。容量只按包数判定。

queued_bytes / peak_queued_bytes / total_queued_bytes 仍使用 Packet::byte_size() 记录 payload 浅尺寸,但只作诊断,不参与背压。它不包含 std::vector 等自定义对象内部 另行分配的堆内存,也不试图按共享 Arc 的唯一物理内存去重。

这些无损容量与 fixed_size 互斥:前者暂停生产者,后者有损丢最旧。一次调用向同一输出口 emit 的批量若自身大于有效包数容量会明确报错,避免永远无法满足的等待。若时间戳对齐 导致下游永远不能消费、所有 worker 又已空闲,等待接口会报告 internal backpressure cannot make progress,而不是永久挂起。

内部背压状态转换也会进入统一日志回调。第 1、2、4、8… 次阻塞打 WARN,包含生产者、 消费节点/端口、有效容量、已排队/已预留/本次待入队包数;对应阻塞解除时打 INFO 并报告 持续时间。指数限频避免长期慢消费者刷屏。若最终无法推进,等待接口的错误会列出每条 producer -> consumer.port 阻塞关系及容量、排队、预留和已阻塞时间。

阻塞 send 命中全局水位时怎么等 —— 分两种执行模型:

全局水位与 Poller 背压现在也使用同一套诊断规则:第 1、2、4、8… 次阻塞打 WARN, 恢复打 INFO。Input::backpressure_stats() 提供图输入端口的当前等待者、事件数和累计时长; Poller::backpressure_stats() 同时提供策略、容量、积压、丢包与 Block 等待统计。 graph.dump() 会输出 watermark 与 poller 行。Poller 有损策略的 WARN 统一包含输出口、 策略、容量、当前积压和累计丢包数。reset 会把这些运行期统计全部清零。 graph.to_dot_with_stats() / lmflow_graph_to_dot_view(g, LMFLOW_DOT_DIAGNOSTICS) 会把相同信息直接画进拓扑: 图标题显示唯一的全局 queued/limit;图输入口只显示该句柄的等待次数/时长,避免把全局 水位误读成每端口容量。消费者输入边显示 queue/capacity/reservation,输出端用 Poller 虚拟节点显示策略、容量、积压与丢包。当前阻塞用红色粗线,历史阻塞或丢包用橙色。 多输入节点在节点框内列紧凑端口表(queued/capacity,reservation,state);同步/批处理 策略中,若一个口正在背压而同组另一个口为空且仍开放,后者以黄色 WAITING 标成疑似根因。 immediate 不做这种推断。正常边只保留端口名,避免大图被零值统计淹没。持续时间按 µs / ms / s 自动换算;标题同时显示本轮 start 后的快照时长。图内诊断图例解释 红/黄/橙和 Poller 虚线,SVG 的 tooltip 保留节点、输入边和 Poller 的完整快照,使默认 标签保持紧凑而悬停仍能查看详细数据。同一次统计图导出只取一个时间点,标题、节点、 输入队列、图输入和 Poller 的持续时间不会因格式化先后产生漂移。

DOT 视图分三档:Topology 只画静态拓扑,Compact 加节点状态和核心吞吐/延迟统计, Diagnostics 再加逐端口队列、背压、Poller 和诊断图例。旧的 to_dot() 与 to_dot_with_stats() 分别映射到 Topology / Diagnostics。统计视图中节点边框表示 CREATED / IDLE / RUNNING / CLOSED / ERROR,填充色继续专用于延迟热力图。 Compact / Diagnostics 的执行器图例同时显示当前 queued、running/线程容量、peak queued 与 completed;Topology 保持静态执行器形状。统计直接进入 DOT,不新增独立 Executor stats 公共 API。 标题汇总 running/error/blocked/waiting/dropped 热点;Compact 对未运行且无异常的节点 省略零值吞吐与队列字段。节点、算子、命名空间、执行器和端口的可见标签按固定字符数 截断,完整名称放进 SVG tooltip。节点按 executor 分组并以状态优先级稳定排序,同时调小 nodesep/ranksep;不启用 concentrate,避免 Graphviz 把语义不同的多端口边合并。

统计视图还维护视图私有的区间基线:首次导出统计本轮 start 至当前,之后按同一 Compact / Diagnostics 视图的相邻两次导出计算增量,两种视图互不污染,reset 清空基线。节点保留累计值,同时显示区间 processed、吞吐率、in/out 增量与区间平均耗时; 诊断边和 Poller 显示区间背压事件/时长与丢包增量。端口直接标注推断原因:消费者队列满、 对齐输入缺失、下游排水慢、全局 packet 水位或 Poller 消费慢/丢包。该基线只服务 DOT, 不新增宿主查询 API。

Diagnostics 在同一快照上再做 Top 5 综合排序:节点按错误、当前背压/等待、区间丢包、 区间平均耗时与积压排序;输入口按当前阻塞、对齐等待、区间丢包、区间背压和积压排序。 当前 queue-full / aligned-input 根因会触发一次只读反向遍历,把其生产者以及所有上游输入边 标成紫色 PRESSURE PATH;直接根因仍保留红/黄,避免传播链盖掉原因。该遍历只读既有拓扑, 不进入调度路径。examples/python/live_diagnostics 展示宿主每 100ms 导出 DOT、可选调用 Graphviz 生成 SVG 的完整循环。

线程池任务的全局在飞计数必须在认领仍持节点调度锁时递增,不能等到提交执行器时才加。 否则两步之间存在「节点已有 ready 调用、全局计数仍为 0」的假空闲窗口,并发关流时 wait_done 可能误报 nodes not closed。回归测试 wait_done_does_not_mistake_claimed_pool_work_for_idle 高频覆盖该窗口。

7.6 关流与终止

7.7 错误路径

节点级策略 on_error(YAML 节点字段,默认 abort;未知值建图期明确报错,不静默):

abort(默认,历史行为)

skip(长跑实时管线用)

on_error 到底管哪些失败(容易误解,写清)。它只作用于 Process 路径,两个触发点:

  1. 算子还没跑就失败 —— 输入包的 type_id 与 GetContract 声明不符,引擎侧 check_input_types 拦下,process 根本没被调用。
  2. process 返回非 0 —— 这一个条件涵盖:Rust 算子返回 Err、Rust 算子 panic (catch_unwind → PANIC)、C++ 算子返回失败 Status、C++ 算子抛异常 (catch (...) → KERNEL)、以及 create 失败(构造 panic / 抛异常 → self 为 null)。 所以「算子失败」不只是「返回失败码」,panic / 异常也算。

不受 on_error 影响:

阶段 行为 为什么不适用
Open 失败 一律 record_error,start() 直接返回错误 算子连打开都没成功(如模型加载失败),它一个包也处理不了 —— skip 等于让图空转着每帧报错。这类一次性生命周期失败应当场让 start() 失败
Close 失败 一律 record_error 那时已在关流,没有「下一个包」可跳

一句话:on_error 管的是逐包失败(这一帧坏、下一帧可能好),不管一次性的生命周期失败 (打不开就是打不开)。

什么时候用哪个。 能在算子内处理的错误,就在算子内处理 —— 捕获后返回成功但 不产出即可,引擎会自动推进边界(与上面 skip 走的是同一条路)。这样你能按错误类型 精确决定哪些可恢复。on_error: skip 是给你管不到的失败用的: 引擎侧的契约类型校验失败、以及算子的 panic / C++ 异常(那些没法「返回成功」); 以及你不拥有源码的第三方算子 —— 用 YAML 就能加固,不必改算子。

7.8 竞态 / 死锁自查表

隐患 挡法
同一节点被两线程并发 process R3 独占令牌(running 在 node 锁下置位)
丢唤醒(包躺队列没人跑) rescan 标记 + finish 重扫
回调期持锁死锁 R1 零锁调用 + staging 暂存
锁序成环 R2 单向 node → {edge, blocked_flush_nodes};push 后先解锁再唤醒;索引只读处取完快照即释放
扇出汇合背压死锁 §7.5 内部边不背压
末尾节点永不关闭 关流在 finish 与上游事件两处检查
wait_done 永久阻塞 open_nodes 计数 + Condvar;错误也唤醒;并提供超时版本
零输入节点自旋 init 阶段拒绝
共享 payload 被就地改写 只读视图 + CoW(§3.4)
payload 跨线程析构 R3 保证访问串行;Send 断言写明前提
日志回调与引擎形成锁序环 回调时保证不持引擎锁

7.9 执行器与线程归属

图在 YAML 里定义命名线程池,节点按名字选择在哪个池上执行:

executors:
  - name: "cpu"
    type: "ThreadPoolExecutor"
    num_threads: 4
  - name: "io"
    type: "ThreadPoolExecutor"
    num_threads: 1
nodes:
  - { name: "decode", kernel: "Decoder",  executor: "io"  }
  - { name: "detect", kernel: "Detector", executor: "cpu" }
  - { name: "draw",   kernel: "Overlay" }        # 未指定 → 默认执行器(default)

CPU 亲和力(绑核) —— 池可选 affinity: [核号...],worker i 绑到 affinity[i % len] 号核:

executors:
  - { name: "rt", type: "ThreadPoolExecutor", num_threads: 2, affinity: [2, 3] }

实时优先级 —— 池可选 priority: 1..=99,把 worker 切到 SCHED_FIFO 实时调度:

executors:
  - { name: "rt", type: "ThreadPoolExecutor", num_threads: 2, affinity: [2, 3], priority: 20 }

执行器类型 —— executors[].type 目前两种:

type 拥有线程? 语义
ThreadPoolExecutor(默认,type 留空也算它) 是 自有 worker 线程,真并发
DelegatingExecutor 否 把就绪节点交还宿主线程跑;num_threads/affinity/priority 对它没有意义,写了报错而非静默忽略

默认(节点未写 executor)= 默认执行器,它是一个按 CPU 核数开线程的线程池(ADR #16):

想控制线程数 / 绑核 / 优先级,或想要宿主线程语义 —— 都是「自己声明一个,把节点指过去」:

executors:
  - { name: "cpu",  type: "ThreadPoolExecutor", num_threads: 4, affinity: [0, 1, 2, 3] }
  - { name: "host", type: "DelegatingExecutor" }        # 不拥有线程,交还宿主线程
nodes:
  - { name: "detect", kernel: "Detector", executor: "cpu"  }
  - { name: "draw",   kernel: "Overlay",  executor: "host" }   # 零并发、顺序确定
  - { name: "post",   kernel: "Post" }                          # 不写 → 默认池

挂在 DelegatingExecutor 上就重新拿到:零并发、执行顺序确定、断点调试直观。 任务由实际进入引擎阻塞接口或调用 pump_step() 的宿主线程执行;通常由 Python 主线程负责推进时,Python 算子就在主线程运行,且同一张图内不会互相争抢 GIL(§8.2)。 多个宿主线程同时进入也不会把委托任务跑并发;多个委托执行器则按轮询顺序抽取, 避免前一个队列持续繁忙时饿死后面的队列。

⚠ 委托任务的执行时机 —— 引擎不能凭空占用宿主线程,只能在宿主进入引擎时借用它。 因此挂在 DelegatingExecutor 上的节点,其任务在宿主调用下列阻塞接口期间被抽取执行:

lmflow_graph_wait_done / _timeout
lmflow_graph_wait_until_idle / _timeout
lmflow_poller_next_status / _timeout
lmflow_input_send(阻塞等待空位时)

⚠ 实现注记:委托执行器的待办队列由它自己持有,不是 GraphInner 上的特例字段。 于是 Node::executor 只是一个普通下标,派任务的地方(dispatch_task)对两种执行器 一视同仁 —— 宿主线程在调度器眼里就是又一个执行器。

但 pump_step 的另一半 try_advance_closing 与执行器无关、不能省:maybe_close 不向下游递归,已排空的 sink 只能靠宿主 pump 的全量扫描去 close 并置 Terminated。 纯线程池的图今天也依赖它。

7.10 输入策略(节点级可插拔)

「多个输入口如何凑成一次 Process」+「队列满了怎么办」被抽成可插拔策略, 而不是写死在引擎里 —— 实时丢帧与(A 阶段的)时间戳对齐因此共用同一扩展点。

- name: "detect"
  kernel: "Detector"
  input_ports: ["frames"]
  input_policy: { type: "fixed_size", capacity: 2 }
type 语义 阶段
sync(默认) 所有输入口齐备才触发。B = 每口至少一个包;A = 按时间戳对齐 B / A
immediate 各输入口独立触发,不等其它口。适合无需对齐的旁路处理 B
fixed_size 有界 + 满则丢弃最旧的包(capacity 默认 1) B
sync_set 部分对齐:输入口划成若干组,每组内各自按时间戳对齐,任一组就绪即触发、只带该组的口 B
batch 批处理:攒够 capacity 个对齐元组一次交给算子(process() 用 input_count/input_at 读整批),关流刷余批。多输入口按时间戳对齐 A

sync_set 用于「A、B 该配对、C 独立」这类图 —— 只能全对齐或全不齐是不够的。配置用端口名给出分组, 须完整划分全部输入口(每口恰属一组;要独立就单独成组):

input_policy: { type: sync_set, sets: [["left", "right"], ["imu"]] }

默认 sync 已经按时间戳对齐全部输入口。只有需要“组间互不等待”时才应使用 sync_set;单个 set 覆盖全部输入口与默认 sync 等价,建议直接省略。某一组触发时, 其它组的输入不会作为残留值传入,而是继续留在各自队列中等待后续调用。

引擎内部把「就绪判定」的返回从单个时间戳升级为触发计划(时间戳 + 参与口):认领时只对参与口 弹包、推进 bound,其余口原样不动。sync/immediate/fixed_size 的参与口是"全部",行为不变。

多路选择(Mux)不做成输入策略,而是内置 MuxKernel:控制口(输入 0,I64 选择器)的值选择 把哪个数据口(输入 1..)转发到输出,配默认 sync 用。理由:push 模型下"只要求选中那一路"的 策略会让未选中口越积越多、甚至因陈旧时间戳卡死;sync 全对齐 + kernel 读控制转发既不积压也不卡, 且引擎不解释 payload(读控制值的是 kernel,守住 ADR #6)。

fixed_size 是有意的有损策略,且不阻塞上游 —— 因此与「内部边不背压」(§7.5) 并不冲突,而是其配套的内存约束手段:摄像头 30fps 而算子只跑 10fps 时, 无界队列会让内存无限增长,丢旧帧才是正确取舍。

batch 把多个时间戳攒成一批一次交给算子(与「单包/次」相反):process() 里 input_count(i) 给出本次批大小、input_at(i, k) 取第 k 个包 —— 单包策略下 input_count 恒 0/1,故同一套访问器 两种交付通用。攒够 capacity 即触发,关流时不足一批也刷出(不丢数据),批的输出时间戳继承批内 末包(下游单调)。用于批推理、窗口聚合;内置 BatchSumKernel 为样板。

多输入口:一批 = capacity 个「对齐元组」(ADR #41)。对齐规则与 sync 完全同源 —— 把单包时的那一轮对齐连续跑 capacity 轮,每轮取各口游标处的全局最小时间戳。因此 各口取数可以不同:某口在某个对齐时间戳上没有包,该轮就不取它(input_count(i) 本就是 按口计数的,算子侧无需改动)。

刻意不做成「各口各自数够 capacity 个」:那会把 0 号口的第 k 个与 1 号口的第 k 个配成 一对,而它们未必是同一帧 —— 图像批与掩码批就此错位,且不报任何错。静默的错误配对是 本项目明确拒绝的失败模式。

不足一批时只有所有正向口都已关闭才刷余量(不可能再来数据了);只要还有口开着就继续等 —— 提前交付就是过早切批,同样是静默的语义偏差。反馈口(back_edges)不参与对齐,每次触发读一次 最新值,与其它策略一致(多口 batch 才让 batch + back_edges 成为可能:单口时凑不出 「至少一个正向口 + 一个反馈口」)。

实现上,就绪判定期已把各口时间戳前缀快照出来并算好每口取数,认领期照计划批量弹出 —— 每口仍只拿一次队列锁,保住 ADR #36。

丢包绝不静默:除 lmflow_graph_dropped_count(port) 累计计数外,首次丢弃还会打一条 WARN 日志。任何有损行为都必须可观测,否则「跑通了」和「悄悄丢了一半」无法区分。


7.11 子图(subgraph)与跨文件 include

子图把一张小图打包成「一个节点」复用;include 把子图库拆进独立 YAML。两者都是纯建图期变换(GraphConfig → 展平的 GraphConfig,见 src/expand.rs),插在 parse 与 check_supported/build 之间。运行时引擎 / 调度器完全不感知子图 —— 连边纯按端口名字符串,展开只是多产出些节点和名字。

定义与实例化:

subgraphs:
  Denoise:                 # 子图名
    nodes:
      - { name: a, kernel: BlurKernel,    input_ports: [sin], output_ports: [mid] }
      - { name: b, kernel: SharpenKernel, input_ports: [mid], output_ports: [sout] }
    input_ports: [sin]     # 边界输入口
    output_ports: [sout]   # 边界输出口
nodes:
  - { name: d, type: Denoise, input_ports: [raw], output_ports: [clean] }   # 实例:type 填子图名
  - { name: s, kernel: ScaleKernel, input_ports: [clean], output_ports: [out] }
input_ports: [raw]
output_ports: [out]

展开规则:实例节点 d(type: Denoise)被内联替换成子图内部节点:

跨文件 include:

# main.yml
include: ["lib.yml"]       # 相对本文件目录;可多个;可递归(被引文件也能 include)
nodes:
  - { name: d, type: Denoise, input_ports: [raw], output_ports: [clean] }   # Denoise 定义在 lib.yml

未知子图名 / 边界数不齐 / 环 / kernel+type 冲突,都在 init 时报错(LMFLOW_ERR_INVALID_ARG),不静默。


7.12 反馈环(back-edge)—— 最新值反馈寄存器

默认图必须是 DAG(check_acyclic 拒环)。要让边成环(反馈控制、循环状态、流驱动迭代),把消费反馈的那个输入口列进节点的 back_edges:

nodes:
  - name: acc
    kernel: FeedbackAddKernel
    input_ports: [in, out]      # in = 正向;out = 反馈(消费本节点自己的输出边 out)
    output_ports: [out]
    back_edges: [out]           # 把 out 口标为反馈寄存器 → 自环合法
input_ports: [in]
output_ports: [out]

语义(最新值反馈寄存器):被标记的输入口是一个容量 1、留最新值、消费一次的队列,且

为什么安全(三个危害各自化解):

危害 化解
终止死锁(A→B→A 互等对方关闭) 反馈口不计入排空判定 → 正向侧驱动关闭、级联拆环
内存无界(内部边无背压) 去掉 back-edge 后仍是 DAG(check_acyclic 只跳过标记的反馈边),DAG 内存论证成立;反馈口 cap-1 有界
时间戳非单调 反馈口不进对齐集

约束:非 source 节点必须至少留一个正向输入(否则永不触发,init 报错);back_edges 名字须是本节点输入口;sync_set 分组不得含反馈口;未被 back_edges 打断的拓扑环仍报错。纯建图期 + 调度器局部改动,无新增阻塞 / 锁。内置 FeedbackAddKernel(out = 正向 + 反馈)为样板,自环即运行累加。

7.13 重跑(reset)—— 保留算子实例的复位

Terminated 后 reset 可把图复位为可再次 start,保留已 open 的算子实例 —— 省掉每会话重建图 + 重跑 open(如重新加载模型)的开销。处理完视频 A 再处理 B,不必重建整张图。

前提:图须 Terminated 且静止(is_idle,没有 worker 还在算子里),否则返回 Error::State。宿主通常先 wait_done()。这条静止依据与 Drop / start 用的是同一个 —— in_flight == 0 且 main_queue 空 ⇒ 没有 worker 在 run_node 中途,故复位无并发。

不碰线程池:worker 随图存活、静止时都 park 在 condvar 上、stop 仍为 false,下一轮 start 直接复用;shutdown + join 只发生在 Drop。这也意味着 reset 不付重建线程的代价。

字段分三类:

类 例 reset 动作
构建期常量 拓扑 / 端口表 / 算子实例 / executor / on_error / min_period 不动
运行期状态 队列 / 统计 / next_seq / input_bounds / source_done / last_fire / closed / has_error 复位
刻意保留 opened(算子不重建)· side_packets(宿主注入的常量,不必重灌)· poller / observer(宿主复用同一句柄取下一轮输出) 保留

最易漏的三个(都有专门测试钉住):

  1. Edge::last_sent 必须回 unset() —— 否则单调性校验会拒掉下一轮从图输入口发的第一个包(时间戳通常又从小开始)。
  2. GraphShared::{error, has_error, cancelled} —— record_error 只「首因生效、不覆盖」,没有反向清除路径;不清则 reset 后的图带着旧错误出生,start 的 try_claim 立刻被挡回。为此新增 reset_run_state()。
  3. input_bounds 必须回 pre_stream()(不是上一轮 close 推到的 done())—— 否则 readiness / 对齐会认为每个空口「已到流尾」,语义崩坏。

opened 的保留由 start 侧配合:start 见 opened == true 就跳过 open(只重灌 side packet + 复位槽)。这正是「不重跑 open」= 不重载模型的价值所在。

C ABI:lmflow_graph_reset;Python:Graph.reset()。四层同一语义。


8. Python 接口(已实现)

8.1 形态

pybind11 模块 lmflow._lmflow(python/src/bindings.cc)链接 liblmflow, 只调用 include/lmflow/flow.h 这一层 C ABI —— 和 C++ 算子走同一条路。 Python 包 lmflow(python/lmflow/__init__.py)在其上提供 @kernel 装饰器、 Kernel 基类、Graph 上下文管理器与类型常量。 Python 既可注册算子,也可驱动图。

构建:pip install .(scikit-build-core 驱动仓库根 CMake:cargo 编引擎 + pybind11 扩展; pybind11 由 third_party/pybind11 子模块提供)。或直接走 CMake:cmake -B build -DLMFLOW_BUILD_PYTHON=ON。

@lmflow.kernel("PyOffsetKernel")
class PyOffsetKernel(lmflow.Kernel):
    @staticmethod
    def get_contract(c): c.input_set_any(0); c.output_set_any(0)
    def open(self, cc):    self.offset = cc.option_int("offset", 0)
    def process(self, cc): cc.emit(0, lmflow.Packet.from_int(cc.input(0).as_int() + self.offset))

with lmflow.Graph.from_yaml(CONFIG) as graph:        # with 是硬要求,见 8.3
    poller = graph.add_poller("out"); graph.start()
    source = graph.input("in")
    for i in range(10):
        source.send(lmflow.Packet.from_int(i), ts=i)
        print(poller.next(timeout=5.0).as_int())
    graph.close_all_inputs(); graph.wait_done(timeout=5.0)

同一张图里 C++ 算子与 Python 算子平等引用 —— 引擎不区分算子语言。

8.2 GIL

8.3 解释器生命周期(崩溃来源)

图必须在解释器开始销毁之前停掉,否则工作线程可能回调进正在析构的解释器 → 崩溃。 因此 Graph 实现上下文管理器,__del__ 只作兜底、不保证时机。文档必须强调 with。

8.4 数据类型限制

Python 算子只能收发内建类型(整数/浮点/布尔/字符串/bytes/LMFlowBuffer)。 理由见 ADR #9:保持 payload 的语言中立性。表达结构化数据:

想传 用什么
检测框、关键点等数值集合 N×K 的 LMFlowBuffer(零拷贝,C++ 侧可直读)
任意元数据 JSON 字符串(LMFLOW_TYPE_STR)
配置参数 node options,本不该进数据流

LMFLOW_TYPE_HOST_OBJECT(7 号)已预留但未启用。若将来开放,应利用「引擎在 init 阶段已知每个节点算子语言」这一点,直接拒绝「原生对象端口接到异语言算子」的拓扑。

8.5 零拷贝的正确写法

send(cv2.imread(...)) 通过 external buffer adoption 零拷贝进入图。绑定层持有 ndarray 引用并把传入数组临时标成只读;最后一个 Packet 引用归零时,释放回调先获取 GIL,再恢复 原 writeable 标志并 Py_DECREF。所有可能等待引擎线程的 Python API 都必须释放 GIL, 否则工作线程最终释放 ndarray 时会与宿主互等。

调用方不得通过另一个共享同一 allocation 的 numpy alias 并发修改数据。Python-owned buffer 以 READONLY 进入引擎,因此算子请求可写视图时会 CoW,不会改坏原数组。

如果数据本来就要由宿主生成,仍可让引擎分配缓冲并拿 numpy view 就地写入:

def process(self, cc):
    src = cc.input(0).as_numpy()                       # 零拷贝只读视图
    packet, dst = cc.new_buffer((h, w, 3), np.uint8)   # 引擎分配,dst 指向引擎内存
    cv2.resize(src, (w, h), dst=dst)                   # 直接写进去
    cc.emit(0, packet)

就地改写走 CoW,同样要先 take_input:

packet = cc.take_input(0)     # ← 先取走,否则上下文仍持引用 → CoW 必然复制
img = packet.make_mutable()   # 独占 → 零拷贝可写 numpy view
cv2.GaussianBlur(img, (5, 5), 0, dst=img)
cc.emit(0, packet)

8.6 其它必须处理的点


9. FFI 边界与安全


10. 构建与集成

Rust core 与 C++ kernels 分离构建

Python 部分由同一 CMake 编排(ADR #4)

依赖:serde/serde_yaml/serde_json;构建期 cc;Python 侧 pybind11。 crossbeam-channel、parking_lot 在 M3 引入。


11. 目录结构

lm-flow/                          仓库根
├── lmflow/                       第一方源码
│   ├── core/                     引擎 crate `lmflow`(包名=库名=lmflow → liblmflow.a)
│   │   │                         **默认纯 Rust**:不编译也不捆绑任何 C++
│   │   ├── Cargo.toml · Cargo.lock
│   │   ├── src/                  timestamp / packet / edge / node / graph / scheduler / ffi /
│   │   │                         kernel_api(Rust 算子糖)/ builtin(自带默认 Rust 算子)…
│   │   ├── tests/                纯 Rust 单元/集成测试
│   │   └── benches/              dispatch.rs(纯 Rust Criterion)
│   ├── include/                  公共头(消费者 #include "lmflow/xxx.h")
│   │   └── lmflow/
│   │       ├── flow.h            C ABI —— 唯一稳定接口(权威定义)
│   │       ├── flow.hpp          C++ 算子糖层(header-only,非 ABI)
│   │       └── flow_platform_log.hpp  可选:引擎日志接平台日志(logcat/os_log/HiLog)
│   ├── adapters/
│   │   └── opencv/               可选 OpenCV adapter(CMake target `lmflow::opencv`)
│   │       ├── include/lmflow/opencv.hpp
│   │       └── tests/opencv_test.cc
│   ├── cpp/                      C++ 侧(**非引擎**,且在 crate 之外 → 不随发布的 crate 分发)
│   │   ├── kernels/              18 个内置 C++ 算子(一文件一算子、自注册)
│   │   ├── abi_assert.cc         跨界结构体布局的编译期校验
│   │   └── tests/                flow_hpp_test.cc / buffer_util_test.cc + CMakeLists
│   ├── python/                   src/bindings.cc(pybind11)+ lmflow 包 + CMakeLists
│   └── examples/                 examples/<lang>/<name>/:cpp · python · rust · android · ios · harmonyos
├── third_party/pybind11/         vendored 子模块(仅构建 Python wheel 用)
├── cmake/                        engine.cmake · install-sdk.cmake · find_package 配置
├── CMakeLists.txt                顶层构建(驱动 cargo;C/C++ SDK + Python 扩展)
├── pyproject.toml                Python wheel(scikit-build-core → 同一份 CMake)
└── docs/
    ├── design.md                 本文档
    └── web/                      文档站源码(见 §11.1)

11.1 文档站

https://laomou.github.io/lm-flow/,由 .github/workflows/docs.yml 在每次推 main 时构建部署。 一个站点覆盖三端,三端平等:

路径 内容 生成方式
/ 手写英文首页 docs/web/index.md → build.py
/rust/ Rust API(跟 main) cargo doc --no-deps(默认纯 Rust feature)
/cpp/ 手写英文 C/C++ 指南 docs/web/cpp.md → build.py
/python/ Python API pdoc(内省已安装的包)
/design/ 本文档(中文) docs/design.md → build.py

三条关键取舍:

  1. C/C++ 端手写,不上 Doxygen。 flow.h 的 131 个函数声明没有一个前置 Doxygen 可识别 注释(全是普通 /* */),且大量说明是「章节横幅」而非贴着符号(一条注释同时说明 5 个 lmflow_packet_as_*)—— 换标记也绑不到符号上,Doxygen 只会产出空壳签名索引。手写指南是 唯一能真正解释 ABI 契约(所有权三态、指针生命周期、锁规则)的形式。
  2. doc 注释语言按「谁会读」划分,而非按语言划分:面向用户的入口(crate 首页、Graph / Packet / Kernel / KernelCtx / KernelContract / register_kernel / Timestamp / Contract、builtin 与 ffi 模块头、Python docstring)用英文;引擎内部的实现注释与 不变量论证仍用中文。
  3. 零新依赖。 build.py 只用 markdown2 + pygments,而这两个都是 pdoc 的依赖 —— workflow 本来就要 pip install pdoc,故依赖清单一行未增。刻意不引 MkDocs / Sphinx: 为两个手写页面搭一套静态站生成器,还要让它接管整个 site/(把 rustdoc / pdoc 产出当 "静态资源"塞进去),是纯粹的摩擦。

cargo doc 的产出根目录没有 index.html(真入口是 lmflow/index.html),故 build.py 额外写一个 site/rust/index.html 重定向壳,rustdoc 拷贝步骤用 cp -R .../doc/. site/rust/ 只合并内容、不覆盖它。workflow 末尾有一步产物齐全校验 —— 宁可构建失败,也不发半成品站点。

cargo test --doc 此前验证不了任何东西(4 个围栏块全是 ignore /text);现在 crate 首页与 kernel_api 模块头的 3 个示例都是可运行 doctest,RUSTDOCFLAGS: -D warnings 也一并 把失效的 intra-doc 链接变成硬错误。


12. 里程碑

里程碑 内容 验收
M0 ✅ C ABI(123 个函数,接口层已收尾)+ C++ 糖层 + 10 个算子 + ABI 断言 + 三语言宿主示例 + timestamp.rs 全部编译验证通过;故意破坏布局能被拦住;参数/日志/错误/关闭原因实跑验证
M1 ✅ crate 骨架 + ffi.rs 全量实现 + build.rs + abi_layout.rs cargo build / cargo test 通过
M2 ✅ 委托执行器(当时是默认)+ 注册表 + YAML + Context 读写 + poller/observer hello_world 输出 0..9(Rust 与 C++ 宿主各一份)
M4 ✅ 关流传播 + wait_done / wait_until_idle / cancel + 生命周期状态机 干净退出;所有权记账证明零泄漏
M5 ✅ 契约类型校验 + 图校验 7 项 + 错误路径 + catch_unwind + 全局水位 坏配置/坏算子被拒并给出可读原因
M3 线程池 executor + 并发调度状态机 多线程下仍正确;TSan 干净
M6 pybind11 绑定 + Python 算子 + 零拷贝 buffer + GIL 处理 examples/python/* 跑通
A 阶段 时间戳同步、bound 传播、back-edge、并行 in-flight、输入策略 单列

M3 之所以排在 M4/M5 之后:当年默认执行器是宿主主线程(旧 ADR #16),所以 M2 的 同步执行本就是产品的默认行为,而不是临时脚手架 —— 线程池当时是「显式 opt-in」的增量。 后来 ADR #16 反转:默认改成线程池,宿主线程执行降级为显式的 DelegatingExecutor (M2 那条路径至今仍在,只是不再是默认)。


13. 测试策略(已落地 326 个:Rust 282 + soak 2 + doctest 3 + Python 39;另有 3 个独立 C++ 测试)

测试文件 数量 覆盖
各模块 #[cfg(test)] 56 timestamp 哨兵/边界、Packet 三态与 CoW、YAML 校验、端口表(tag/序号/连续性)、错误优先级、全局水位、字符串驻留
tests/abi_layout.rs 10 跨界结构体 size/align/offset、状态码、type_id、dtype、时间戳哨兵 —— 与 cpp/abi_assert.cc 钉在同一组常量上
tests/c_abi.rs 12 完全以 C 调用方的方式驱动引擎:全流程、内建类型往返、缓冲分配与 CoW、空指针不崩、错误可读、observer、日志回调
tests/e2e.rs 28 真实建图 + 真实调 C++ 算子:直通/扇出/多 poller、7 项图校验、状态机、时间戳单调性、跨语言按类型传值、兜底关流、side packet
tests/type_contracts.rs 5 类型契约两级校验:具体类型静态拒绝、ANY 两向兼容、运行期动态检查、算子输出兑现契约
tests/memory.rs 7 所有权守恒记账(正常/积压/失败/取消路径)、CoW 零拷贝不变量(三级管线)、扇出复制不污染兄弟分支
tests/buffer_ops.rs 11 张量前处理组端到端:Cast / Affine / Clamp / Reduce、真实前处理链、F16 输入与输出(含 u8→f32→归一化→f16 的移动端链路)、非连续缓冲与未知 dtype 被拒
C++ / adapters tests 3 flow_hpp_test(异常不穿越 FFI + 构造失败安全)· buffer_util_test(F16 软件转换:65536 个位模式往返 + 平局取偶 + 上下溢临界,-O0/-O2 双档)· lmflow_opencv_test(仅显式启用 adapter 时编)

13.1 原始策略与补充

13.2 实现阶段真实抓到的缺陷

这些不是假想 —— 都是 MVP 过程中被测试或工具抓出来并修掉的,已各自留下回归测试:

缺陷 抓到它的手段 教训
pump_step 自锁死 —— edition 2021 里 if let 的临时 MutexGuard 活到块结束,run_node 内再锁同一队列 跑 hello_world 直接挂住 正是 R2 锁序规则要防的情形,却被语言的临时值规则绕过;.lock() 结果必须先落地到局部变量
输入槽残留引用 —— 只在下次调用开头才清,导致上游一直持着已处理完的包 tests/memory.rs 的所有权记账(恰好漏 1 个) 后果不止延迟释放:下游 CoW 永远看到引用数 ≥ 2 而静默退化成全量拷贝 —— 正是 §3.4 警告的那条不变量失效
CoW 测试用单节点管线,覆盖不到上一条 修复后回看测试设计 不变量测试必须覆盖「最短能触发的拓扑」而非最简拓扑
KernelInstance::open/process/close 是 pub 且解引用裸指针却未标 unsafe clippy not_unsafe_ptr_arg_deref
Context 用 Mutex 时,持 guard 的 &mut 与回调内从裸指针再造的 &mut 构成别名 UB 手工审查 改用 UnsafeCell + 令牌不变量
静态算子注册对象被链接器裁剪,宿主建图时报「算子未注册」 外部静态 SDK consumer 不做手动注册并实际建图 CMake targets 跨平台传播 whole-archive / force-load
「算子未注册」的报错分两处,其中一处没列出可用算子 e2e 测试断言报错内容 报错要能指导用户,不只是标明失败
SinkKernel 用 printf 抢 stdout,违反自己文档里的规定 测试输出里看到 内置算子也要遵守对用户提的要求
必需 side packet 借用计数器承载,会把内部键暴露到用户可见的 counter 列表 自我审查
丢唤醒 —— wait_for_activity 在判空闲之后才捕获活动代数,任务恰在此间隙全部完成就白等到轮询上限(max_in_flight 真并行测试里表现为 55ms 假慢) 并行测试的耗时断言 等待前必须先捕获代数,再 wait_activity_since;这是「捕获-判定-等待」三步的经典次序
阻塞 send 在线程池图上误报 WOULD_BLOCK —— 它只 pump_step(),而那只跑主线程任务;池图上恒为 false,一撞水位就报错而非背压 自我发掘 + blocking_send_applies_backpressure_on_pool… 回归测试 背压等待要区分「本线程能不能推」与「别的执行器还在不在跑」:池仍在跑就等排水,全图空转才算真卡死
句柄悬空(use-after-free) —— LMFlowInput*/LMFlowPoller* 原本由图槽持有、随 lmflow_graph_free 一起释放;Python/C++ 宿主先销毁图、后用句柄(如 del g 后 inp.send)就读已释放内存(实测挂死) 自我发掘 + Python del g 复现 + handles_stay_safe_after_graph_free 回归 改为调用方拥有句柄:各自持一份 Arc<GraphInner>,lmflow_input_free/lmflow_poller_free 释放;图先 free 也不失效,再用只得「已结束」错误。原先的 alive_ shared_ptr 兜底是坏的(no-op deleter,且 Python GC 看不见 C++ 引用)
C++ 算子构造抛异常穿越 FFI —— flow.hpp 的 KernelAdapter::create 直接 new T(),若 T 构造函数抛异常(如打开设备失败),C++ 异常会穿越 extern "C" 回到 Rust(UB;catch_unwind 接不住);且引擎把 create 返回的 null 当「无状态算子」,后续 process 会对 null self 解引用 自我发掘 + cpp/flow_hpp_test.cc(有 bug 版实测 abort/段错误) create 包 try/catch 失败返回 null;open/process/close 加 self 空判返回错误 —— 与 Python 端 py_create/py_invoke 的处理对齐
非连续 numpy 数组静默损坏 —— lmflow_packet_from_buffer 号称「逐行拷贝(源可能不连续)」,实则只认倒数第二维的 stride、假定最后一维连续,于是转置 / 步长切片 / 负步长 / 3D 非连续视图全拷错 自我发掘 + a.T/a[:,::2] 端到端实测输出错乱 改为按完整 strides 的 N 维里程表拷贝:最后一维连续则整行拷、否则逐元素;负步长用 .offset() 处理。回归:c_abi.rs from_buffer_handles_non_contiguous_strides + Python test_non_contiguous_ndarray_roundtrip

13.3 原策略清单


13.4 长跑内存曲线压测(soak)

tests/soak.rs —— 默认 #[ignore](不拖慢常规套件),CI 的 rust job 里以约 1 GiB 规模显式跑一遍;本地可用 LMFLOW_SOAK_PACKETS 放大。

为什么需要它,以及它证明了什么水位读数证明不了的事。 此前全局水位只有功能测试 (「撞到水位会转成背压」),而「功能对、内存仍在涨」是完全可能的。做负对照实测过: 把消费算子改成不释放包(堆起来),total_queued_bytes 峰值依旧稳稳停在 4 MiB 水位上、丢包断言也照样通过 —— 而 RSS 跟着总吞吐一路涨了 233 MiB,曲线是干净的线性斜坡。 也就是说:水位读数正常并不能推出内存有界,这个盲区只有量真实 RSS 才能盖住。

断言的形式很关键 —— 不是「内存小」,而是:

RSS 的增长受水位约束,而不是受总吞吐量约束。

因为该上界与包数 n 无关,泄漏与正常的差距就是两个数量级,不存在阈值调参的模糊地带。 实测(Linux,/proc/self/status 的 VmRSS):

吞吐 峰值积压字节 RSS 增长
250 MiB(默认) 4096 KiB = 水位 4364 KiB
2.5 GiB(10×) 4096 KiB = 水位 4364 KiB(与规模无关)
250 MiB(注入泄漏的负对照) 4096 KiB = 水位 238896 KiB → 测试失败 ✓

另外三条辅助断言各有职责:水位真的被压到过(否则一次「跑得太快、从不积压」的运行 也会绿,却什么都没证明)、软水位的超出量有界(允许 Relaxed 快照滞后几个包,但不该无界)、 无丢包。RSS 读数是 Linux 专属;其它平台自动跳过该条,其余断言仍然有效。

扇出 + 汇合(diamond)拓扑 —— watermark_bounds_memory_in_diamond_topology。 上面那条是单节点线性图,而 ADR #11 唯一要保护的形状恰恰是 diamond,此前零覆盖 (§13.1 曾声称有这个测试,实际没有)。拓扑:

   in ──┬─► slow(睡 200µs) ─► s ─┐
        └─► fast ───────────► f ─┴─► join(默认 sync,按时间戳对齐两路)

一条图输入边被两个节点消费 = 真正的内部扇出;两路再汇到 join 的两个输入口。 水位按个数(max_queued_packets: 64)而非字节 —— 因为 Payload::byte_size() 对 Native / Foreign 计 0,字节水位只对内建 payload 有效,而个数水位对所有形态成立。 注意扇出后每包占 2 个队列槽(on_enqueue 是按消费者调用的)。

吞吐 join 触发 峰值在途 RSS 增长
250 MiB 2000 / 2000 65 槽(水位 64) 4364 KiB
2.5 GiB(10×) 20000 / 20000 65 槽 4368 KiB

与线性图的 4364 / 4364 数值一致,且同样与吞吐无关;2.5 GiB 跑完 5.54 秒、未挂住, sync 汇合把每个时间戳都对齐处理掉、无丢失、无错配。

这条测试同时是 ADR #11 的活性回归:内部边一旦被加上阻塞式硬上界,慢分支会让 join 永不触发,wait_done_timeout 就会在这里超时 —— 那正是 §7.5 描述的循环等待。


13.5 type_id 的跨编译器行为(ADR #22 的验证)

type_id 有两个此前完全没有测试的面,而它们的失效都是静默的:

  1. 哈希算法在两侧各有一份手写实现 —— C++ 在 flow.hpp 的 Fnv1a + NormalizeTypeId, Rust 在 packet::fnv1a_type_id。任何一侧动了 offset basis、乘子、或那个 < 16 的 内建区规避分支,同一个类型在两边就会算出不同 id,契约校验形同虚设 —— 不报任何错。
  2. LMFLOW_DECLARE_TYPE_NAME 是跨工具链的唯一逃生口,却只在一个示例里用过。 若它哪天悄悄退化成 no-op,type_id 就会静默退回修饰名 —— 而修饰名 MSVC 与 GCC/Clang 不同,于是跨编译器混用算子时类型校验静默失配。

对应的钉法:

位置 形式 钉住什么
cpp/abi_assert.cc 编译期 static_assert FNV offset basis;Fnv1a("lmflow.test.Stable") 的确切值;NormalizeTypeId 在 0/15/16/17 的边界
core/tests/abi_layout.rs 运行期 assert_eq! 同一个字面量 0xBFB531B283179309,加 "i"/"d" 两个修饰名样例,加 2000 个取样都 ≥ 16
cpp/tests/flow_hpp_test.cc 运行期 assert 声明稳定名后 id 由该字符串决定;id 与修饰名算出的值不同(证明宏真生效,不是 no-op);未声明的类型 id 就是修饰名哈希;两个不同类型声明同名 → 同 id

两处期望值都由第三方实现(Python)独立算出,不是从任一侧抄来的 —— 否则就是拿实现验证 自己。为让编译期断言能穿透整条链,NormalizeTypeId 从 inline 改成了 constexpr。

上面这些都还抓不到编译器默认名称变化 —— 它们钉的是哈希函数,在任何编译器上都 同样通过。真正的默认身份来自 typeid(T).name(),故测试分别钉住两套已支持 ABI:

#ifdef _MSC_VER
assert(std::strcmp(typeid(int).name(), "int") == 0);
assert(lmflow::TypeId<int>() == 3143511548502526014ULL);
#else
assert(std::strcmp(typeid(int).name(), "i") == 0);
assert(lmflow::TypeId<int>() == 12638195996648667684ULL);
#endif

分歧比「修饰方案不同」更彻底:Itanium ABI(GCC/Clang)下是 "i",而 MSVC 的 type_info::name() 返回未修饰的可读名 —— "int"、"struct Foo"(修饰形式在 raw_name())。故 FNV("i") 与 FNV("int") 毫不相干。两套断言保证各自 ABI 内不会 静默漂移,但不宣称默认 id 能跨 ABI 对齐;跨工具链传自定义类型仍必须改用 LMFLOW_DECLARE_TYPE_NAME。

「证明宏真生效」那条做过负对照:把 LMFLOW_DECLARE_TYPE_NAME 改成 no-op,测试立即断言失败。 只断言「id == 某常量」是不够的 —— 那条单独看,一个 no-op 宏也可能巧合通过。


13.6 多配置生成器下的 cargo profile(构建正确性)

CMAKE_BUILD_TYPE 只对单配置生成器有意义(Unix Makefiles、单配置 Ninja);多配置 生成器(Ninja Multi-Config、Visual Studio)的配置是构建期由 --config 决定的,配置期 读那个变量通常为空。cmake/engine.cmake 原先在配置期做 if(CMAKE_BUILD_TYPE MATCHES ...), 于是在多配置生成器下会静默退到 debug:

cmake -B b -G "Ninja Multi-Config"
cmake --build b --config Release --target flow_engine
→ Finished `dev` profile [unoptimized + debuginfo]      # 要的是 Release

后果是 C++ 侧按 Release 编、却链进一个未优化的 debug 引擎,而且不给任何提示 —— 正是本项目明确拒绝的静默失效。这条在 Linux 上就能复现,不是 Windows 专属问题 (它是做 Windows 侦查时被发现的:windows-latest 默认生成器是多配置的 Visual Studio)。

改法:用生成器表达式让 profile 跟随实际构建的配置;imported target 改用 IMPORTED_LOCATION_<CONFIG> 并把 RelWithDebInfo/MinSizeRel 映到 release (cargo 只有 dev/release 两档);COMMAND_EXPAND_LISTS 让 Debug 下 --release 展开为无参数而不是空字符串参数(cargo 会拒空参数)。单配置生成器行为不变。

CI 守卫在 cmake-sdk job 里:配置一次 Ninja Multi-Config,对 Release / RelWithDebInfo / Debug 三个配置做 ninja -n 干跑,断言 profile 与 --config 一致,并额外确认真实的 --release flag 在生成的构建文件里。只配置 + 干跑,不真编译,秒级。 做过负对照:把 engine.cmake 改回原样,守卫立即失败。


14. 风险登记

风险 说明 缓解
payload 跨线程析构 在 A 线程建、B 线程析构 R3 保证访问串行;Send 断言写明前提
panic / 异常穿越 FFI 双向 UB 双向 catch_unwind / try-catch 转错误码
静态注册被链接器裁剪 注册对象无引用被 strip 官方 CMake targets 自动 whole-archive;自定义静态算子库采用等价选项
静态库链两份 → 双注册表 算子在一份里注册、另一份看不见 Python 侧强制用动态库;符号可见性只导出 flow_*
ABI 布局不一致 内存错乱 #[repr(C)] + 双向 static_assert;LMFLOW_ABI_VERSION 运行期校验
CoW 静默失效 引擎多留一份引用 → 恒复制、不报错只变慢 写成显式不变量(§3.4)+ 加断言/测试
GIL 拖累吞吐 Python 算子无法真并行 文档明示;重活写 C++;考虑 executor 隔离
跨平台回归 平台工具链、库名、系统依赖与 CRT 容易随构建脚本改动而静默失效 Windows/MSVC、macOS、iOS、Android、linux-aarch64 均在 CI;Windows 额外覆盖 SDK 安装消费与 Python wheel
B 的简化偏离完整语义 丢了时间戳对齐 明确划入 A 阶段;B/A 两阶段均已落地,对齐语义有专门测试(§13)

15. 待决项的处置

上一版列出的 7 项未决事项已全部拍板,理由并入 ADR(§1):

原未决项 结论
时间戳单调性校验放在哪层 图输入口强制校验;内部边仅 debug_assertions 下校验(ADR #23)
内部边软水位的默认值与告警形式 软水位默认取顶层 max_queue_size(默认 100);每条边首次超限打 WARN,之后按 1/2/4/8… 指数退避,避免日志洪水;深度与丢弃数经 queue_depth / dropped_count 可查
type_id 是否改稳定方案 现在就改为 FNV-1a(修饰名)+ LMFLOW_DECLARE_TYPE_NAME 逃生口(ADR #22)
生产可观测性 已落地:LMFlowNodeStats、逐输入口 LMFlowInputQueueStats(当前/峰值包数与字节、reservation、阻塞次数/时长/生产者)、DOT 热力图与背压聚合、watchdog_ms、算子自报计数器
是否启用 LMFLOW_TYPE_HOST_OBJECT 不启用(ADR #26)
stream header 不做,用 side packet 覆盖(ADR #24)
subgraph 组合 已支持:建图期展开 + include: 引外部库(ADR #27,§7.11)

另外明确不做的:程序化构图 API(ADR #25)、observer 注册后的移除(登记于 header)。

15.1 结构体前向兼容的两种做法(有意的不一致)

结构体 做法 为什么
LMFlowBuffer 固定 reserved 字段 在热路径上、形状稳定(对齐 numpy buffer protocol),固定布局便于零开销传递
LMFlowNodeStats 入参 struct_size 诊断用、字段天然会持续增加;调用方填 sizeof。引擎写出完整结构体,故 struct_size 偏小时明确失败(溢出护栏)—— 字段增加后老宿主重编即可,拿到的是干净报错而非内存损坏
LMFlowInputQueueStats 入参 struct_size 同属诊断快照,独立于节点统计扩展,避免每增加端口级指标就膨胀 LMFlowNodeStats

15.2 实现阶段的验证结果

当初留给实现去验证的 结果
CoW 不变量是否被意外破坏 —— 需专门的「不应发生拷贝」测试 确实被破坏了,已修并留下三级管线的零拷贝测试(见 §13.2)
调度状态机的 rescan 逻辑 线程池已落地,并发正确性由 TSan 硬门禁常绿保证(0 竞态);max_in_flight > 1 的认领/按序重排路径同在门禁内
全局水位的实际效果 已有功能测试证明能拦住无限增长;内存曲线压测已补上(tests/soak.rs,见 §13.4)—— 实测 RSS 增长与总吞吐无关:250 MiB 与 2.5 GiB 两种规模下增长同为 ~4.3 MiB(≈ 水位本身)
跨平台 Windows/MSVC 与 macOS 原生 test,加上 iOS / Android / linux-aarch64 交叉编译,均在 CI 内

15.3 当前实现的已知边界

诚实记录,避免误以为已完成。本节只写当下仍然成立的边界 —— 一旦某条被实现,就从这里删掉 (否则它会反过来低报完成度:曾有一版这里还写着「线程池尚未实现」,而线程池早已落地并进了 TSan 门禁)。

引擎语义上的边界

验证覆盖上的边界