系列目录 · 资源与调度 · Read in English
“算术量没有增加多少,怎么连代码都生成不出来?”
白板上的图非常无辜:一个生产者,接着很多消费者。内存还有余量,计算单元也没增加,可依赖资源池先亮起红灯。把并行程序当成“节点加连线”画出来很容易,麻烦在于每条线最终都要找到一种设备可以理解的等待方式,而同步对象未必无限。
图的边不必等于一个物理同步槽
设搬运引擎 D 产生一份数据,计算引擎 V 按顺序执行三个消费者:
1 | D: P |
最直接的实现为每条跨引擎边分配事件。P 产生三份通知,三个消费者各自等待。这当然好理解,却可能让同步资源消耗随扇出增长。
如果 V 的队列保证后续命令不会越过前面的等待,那么在 C1 前等待 P,已经使 C2 和 C3 间接受约束。语义需要的是三个消费者都不能早于 P,未必需要三份长期存活的物理通知。
这里最关键的是“如果”。不同硬件对队列顺序、命令完成和等待作用域的定义不同。没有队列语义支撑,看到同一个消费者引擎就直接合并,是把一张纸上的顺序误当成设备保证。
把边改写成区间,问题突然变清楚了
已核实的实现先给调度后的任务编号,再按生产者引擎和消费者引擎的组合分桶。每条跨引擎边用区间表示:
1 | (producer_position, consumer_position] |
接着按消费者位置排序,选择最早消费者的位置作为一道屏障,把覆盖该点的边吸收到同一组。被吸收的生产者都向这组发出完成通知;最早消费者等待一次,后面的同引擎命令受到队列顺序保护。
考虑三个重新设计的区间:
1 | P1 → C1 : (1, 5] |
选位置 5,可以合并前两个区间,却不能合并第三个,因为 P3 在屏障之后才出现。如果让位置 5 等待 P3,就会要求未来的工作先完成,轻则串行化,重则与队列发射条件形成等待环。
该实现通过比较生产者位置和屏障位置,把尚未到来的生产者留给下一组。这是算法中真正保护正确性的条件,不能在优化重构时当作“只是排序细节”删掉。
这是一种合并,也是一种保守等待
合并前,C1 可能只需要 P1。合并后,C1 还会等待原本只服务 C2 的 P2。这样减少了同步组数量,却可能让较早消费者多等一会儿。
因此这类策略的目标不是无条件最大化重叠,而是在有限同步资源下生成合法调度。它有明确的交换关系:更少的活跃同步对象,换取可能更强的时序约束。
一个常见误会是:“算法合并了边,所以减少等待,必然更快。”准确说法应是:减少显式等待或同步组数量,不等于减少临界路径长度。真正的性能取决于 P2 是否已经完成、C1 的额外等待是否挡住长链,以及后续任务是否因此失去重叠机会。
这一点也解释了为什么应先按引擎对分组。不同消费者队列之间没有天然顺序保护,不能因为依赖同一份数据,就借用另一条队列的等待结果。
资源预算不能全部交给图级调度
代码中还引入了图级依赖预算,并为内核内部同步预留一部分资源。公开文章没有保留实际数量,因为原理与具体数量无关。
用教学预算表示:总共有 K 个槽,复杂内核最坏需要 H 个内部槽,那么图级同时活跃量必须不超过 K−H。否则图级调度“刚好用满”以后,某个正常内核连自己的内部同步都无法建立。
不过,固定预留 H 仍然是一种策略假设。如果未来加入内核需要更多内部事件,预算模型也要随之更新。更一般的方案可以在任务属性中声明内部需求,并在时间轴上计算组合峰值;代价是调度器和内核接口更复杂。
这与普通寄存器分配类似,但不能完全照搬。同步槽还携带生产次数、消费次数和释放协议。一份对象什么时候可回收,取决于协议是否完成,不仅取决于源代码变量是否离开作用域。
为什么要做两次检查
已阅读的提交在生成之前模拟依赖组活跃区间,统计峰值;超预算时给出长时间存活的组及起始任务位置。生成结束后,又检查图级组和内部寄存器是否全部归还。
这两次检查针对不同错误。前者证明计划中的峰值没有越过预算;后者确认实际发射过程中没有少等待、少释放或内部资源泄漏。计划完全正确,也可能因为某条特殊代码路径跳过释放而失败。实际资源计数最终归零,也不能证明中途从未超限。
诊断还应有可行动性。“资源不足”只告诉用户撞墙了;“某组从哪个任务开始,跨过多少任务仍未回收”才让开发者知道应缩短哪一段寿命。
手工演练一次合并后的账本
再看两个生产者 P 和 Q、三个同队列消费者 A、B、C。假设 A 只读 P,B 同时读 P 和 Q,C 只读 Q,而且 P、Q 都位于 A 之前。合并后可以让一个组登记两个不同生产者,并在 A 之前等待一次。注意“两个不同”:P 被两条边引用,不应该因此被登记成两份独立生产工作。
这一点解释了为什么构造屏障时需要对生产者去重。若元数据宣称会有三份通知,实际只有两份,消费者可能永远等待;若宣称两份但某条生成路径发出三份,又可能提前回收或触发计数错误。图结构去重与运行时协议计数必须使用一致的单位。
接下来把 Q 移到 A 之后。原来的合并条件立刻失效,不能为了维持同一个漂亮屏障而让 A 等一个还没发射的任务。再把 C 移到另一条消费者队列,也不能继续认为 A 的等待保护了 C。两个小改动足以检验算法依赖的核心前提,不需要大型模型。
最后比较两种诊断:一种只说“申请失败”,另一种指出某个事件从 P 开始,一直跨过许多任务直到 C 才结束。后者把资源压力还原成时间跨度,开发者才有机会通过移动消费者、改变分组或缩短持有区间处理它。压力通常发生在某一瞬间,提交里总共创建多少事件只是另一个统计量。
一个容易漏掉的反例
假设两个消费者属于同一个引擎,但运行在不同队列,或者后续命令允许绕过前面的等待。此时 C1 等过 P,不能推导 C2 也等过 P。这是队列模型变化以后,原先正确的屏障合并可能失效的典型场景。
另一个反例是一个逻辑算子拆成多个任务,选错生产者任务:等待了配置准备,却没等待真正写输出的任务。图级依赖构造必须跟 lowering 后的任务分解一致,不能仅凭原始算子的名称猜测完成点。
回归矩阵与实验
| 维度 | 用例 | 需要观察 |
|---|---|---|
| 扇出 | 小规模、预算附近、远超预算 | 活跃组峰值与生成是否受控 |
| 引擎关系 | 同队列、跨引擎、多个引擎对 | 屏障只在合法范围共享 |
| 区间关系 | 相交、相邻、不相交 | 未来生产者不被提前吸收 |
| 内核内部需求 | 无内部事件、少量事件、需求增长 | 预留策略是否仍成立 |
| 生命周期 | 重复等待、重复释放、多生产者 | 计数错误必须被拒绝 |
| 终结状态 | 正常退出、生成中途失败 | 资源处理协议清楚 |
提交包含高扇出图的生成测试,以及边界分配、多生产者计数、重复等待和重复释放的检查。
建议的实验分两阶段。先用小型离散事件模型验证所有消费者都晚于真正的生产者完成,再比较逐边事件与分组合并策略。记录峰值同步槽数、额外等待边数量、理论临界路径和模拟队列利用率;只有在有真实设备测量条件时,才增加端到端时延。
图有一百条边,并不意味着需要一百个同步槽。但在删掉第一个槽之前,必须能清楚说出:是哪条队列顺序,替它承担了那份承诺。
If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !