15.2 任务图构建与帧内调度
15.1 给了我们一张图能排出的两样东西:合法执行序 order 和可并行的批次 levels。但那张图是手写的、静态的。真实的帧不是这样——每一帧里出现的任务集合都在变:这一帧岛上有 3 个可见区块要更新网格,下一帧可能只有 1 个;某个任务这一帧根本不需要跑。这一节把「图」接到真实的帧循环上:每帧重建一次图,跑一次分层,得到本帧的执行计划。
每帧都要重建一次图
为什么不建一次图、之后反复用?因为图的节点集合和边集合都随场景变化:
| 会变的东西 | 后果 |
|---|---|
| 可见区块数量 | 区块任务数变,第一并行层的宽度跟着变 |
| 任务是否激活 | 某个系统这一帧被跳过,节点消失 |
| 数据依赖 | 读写的资源集合不同,推导出的边不同 |
固定一张图只能在「任务集合恒定」的玩具场景里成立。一旦承认场景会变,图就必须跟着重建。代价是每帧多花一次「建图 + 分层」的时间——15.1 的 TaskGraph 已经把这步压得很轻,稍后我们量一下它到底花多少。
顺带说明和前面几节的分工:13.3 讲的是 ECS 层面怎么声明系统的读写依赖、把系统分成可并行的组;本节不重复那套推导,而是直接接受「一组带 after 的任务」,把它们变成可执行的帧计划,并交给第 14 章那套线程池去跑。
可运行产物:帧调度器
src/core/frame-scheduler.ts 把模板任务和「本帧动态出现的任务」合并成一张新图,再调用 TaskGraph.plan()。层级 L1,Node 直跑。
// file: src/core/frame-scheduler.ts
// tier: L1
// verify: npx vitest run src/core/frame-scheduler.test.ts
//
// helio 内核:每帧重建任务图并求出帧内调度计划。
// 模板任务固定存在;动态任务随本帧场景变化(如可见区块数)临时加入。
// 计划 = 分层拓扑序;同一层内的任务互不依赖,可以丢给工作线程并行执行。
import { TaskGraph } from './task-graph.ts';
import type { TaskId } from './task-graph.ts';
/** 帧内任务声明。cost 是相对耗时(毫秒),只用于估算帧内关键路径。 */
export interface FrameTask {
readonly id: TaskId;
readonly after: readonly TaskId[];
readonly cost: number;
}
/** 一帧的调度计划。 */
export interface FramePlan {
readonly frame: number;
readonly taskCount: number;
readonly order: readonly TaskId[];
readonly levels: readonly (readonly TaskId[])[];
readonly parallelism: readonly number[];
readonly maxParallelism: number;
readonly criticalPathMs: number;
}
/**
* 帧调度器。持有「模板任务」,每帧把模板与动态任务合并成一张新图,
* 再跑一次拓扑分层。整个过程每帧重复一遍,所以它本身的耗时要可控。
*/
export class FrameScheduler {
readonly #template: readonly FrameTask[];
constructor(template: readonly FrameTask[]) {
this.#template = template;
}
/** 生成第 frame 帧的计划。dynamic 是本帧临时出现的任务。 */
plan(frame: number, dynamic: readonly FrameTask[] = []): FramePlan {
const graph = new TaskGraph();
const cost = new Map<TaskId, number>();
for (const t of this.#template) {
graph.add({ id: t.id, after: t.after, run: () => {} });
cost.set(t.id, t.cost);
}
for (const t of dynamic) {
graph.add({ id: t.id, after: t.after, run: () => {} });
cost.set(t.id, t.cost);
}
const topo = graph.plan();
const parallelism = topo.levels.map((l) => l.length);
// 关键路径估计:层内并行只算最慢任务,层与层串行相加。这是理想打包下的下界。
let criticalPathMs = 0;
for (const level of topo.levels) {
let slowest = 0;
for (const id of level) slowest = Math.max(slowest, cost.get(id) ?? 0);
criticalPathMs += slowest;
}
return {
frame,
taskCount: graph.size,
order: topo.order,
levels: topo.levels,
parallelism,
maxParallelism: topo.maxParallelism,
criticalPathMs,
};
}
}
/**
* 造出 glimmer-isle 第「chunks 个可见区块」这一帧的完整任务表。
* 每帧都从头拼一次:区块数会变,任务集合随之变化——这正是「每帧重建图」的来源。
* cost 是模拟耗时(毫秒),只用于关键路径估计,不是实测。
*/
export function buildFrameTasks(chunks: number): FrameTask[] {
const tasks: FrameTask[] = [
{ id: 'input', after: [], cost: 0.4 },
{ id: 'sim-physics', after: ['input'], cost: 1.8 },
{ id: 'sim-ai', after: ['input'], cost: 1.2 },
{ id: 'sim-anim', after: ['sim-physics'], cost: 0.9 },
];
const chunkIds: string[] = [];
for (let i = 0; i < chunks; i++) {
const id = `chunk-${i}`;
tasks.push({ id, after: ['input'], cost: 0.6 });
chunkIds.push(id);
}
tasks.push({ id: 'render-list', after: ['sim-anim', 'sim-ai', ...chunkIds], cost: 0.5 });
tasks.push({ id: 'cull', after: ['render-list'], cost: 0.7 });
tasks.push({ id: 'submit', after: ['cull'], cost: 0.3 });
return tasks;
}
buildFrameTasks(chunks) 就是「本帧的任务集合」的生成函数。它每帧被重新调用一次,产出的任务数随 chunks 变化——这正是「每帧重建」的来源。
每帧的执行顺序与并行度
下面这个基准脚本模拟 5 帧,每帧给不同的区块数,打印执行顺序与并行度。
// file: bench/frame-rebuild.ts
// tier: B
// verify: node bench/frame-rebuild.ts
//
// 两件事:(1) 模拟 5 帧,打印每帧的执行顺序与并行度;
// (2) 测量「每帧重建任务图 + 拓扑分层」本身的开销,随任务数的变化。
import { performance } from 'node:perf_hooks';
import { FrameScheduler, buildFrameTasks } from '../src/core/frame-scheduler.ts';
const sched = new FrameScheduler([]);
console.log('=== 每帧执行顺序与并行度 ===');
const chunksPerFrame = [0, 1, 3, 3, 2];
for (let frame = 0; frame < chunksPerFrame.length; frame++) {
const plan = sched.plan(frame, buildFrameTasks(chunksPerFrame[frame]!));
console.log(
`frame ${frame} tasks=${plan.taskCount} maxParallel=${plan.maxParallelism}` +
` parallelism=[${plan.parallelism.join(',')}]`,
);
console.log(` order: ${plan.order.join(' -> ')}`);
}
console.log('\n=== 重建开销(plan() 一次) ===');
function benchRebuild(chunks: number, iters: number): number {
// 预热,让 JIT 先编译。
for (let i = 0; i < 2000; i++) sched.plan(i, buildFrameTasks(chunks));
const t0 = performance.now();
for (let i = 0; i < iters; i++) sched.plan(i, buildFrameTasks(chunks));
const t1 = performance.now();
return ((t1 - t0) / iters) * 1000; // 微秒/次
}
for (const chunks of [0, 3, 100, 1000]) {
const iters = chunks >= 1000 ? 20000 : 200000;
const us = benchRebuild(chunks, iters);
const tasks = buildFrameTasks(chunks).length;
console.log(`chunks=${String(chunks).padStart(4)} tasks=${String(tasks).padStart(4)} rebuild=${us.toFixed(2)} µs/帧`);
}
本机实测(Node 26.5.0,跑两遍数字基本一致):
frame 0 tasks=7 maxParallel=2 parallelism=[1,2,1,1,1,1]
frame 1 tasks=8 maxParallel=3 parallelism=[1,3,1,1,1,1]
frame 2 tasks=10 maxParallel=5 parallelism=[1,5,1,1,1,1]
frame 3 tasks=10 maxParallel=5 parallelism=[1,5,1,1,1,1]
frame 4 tasks=9 maxParallel=4 parallelism=[1,4,1,1,1,1]
第 0 帧只有 input -> {sim-physics, sim-ai} -> ...,第一并行层宽 2;到了第 2 帧多出 3 个区块任务,第一层一下涨到 5——并行度是跟着场景走的,不是常量。order 每帧都满足「前置先跑」,这一点由 15.1 的 plan() 保证,另有 5 个 vitest 用例(npx vitest run src/core/frame-scheduler.test.ts,全部通过)断言分层结果、层内无依赖、顺序可复现。
并行度涨了,帧就更快了吗
不一定。并行度(第一层有多宽)和帧耗时是两件事。FramePlan 里另有一个 criticalPathMs,它按「层内取最慢任务、层间相加」估算帧内关键路径——也就是理想并行下这一帧至少要花多久。
对本节的模板,无论区块数是 0 还是 3,criticalPathMs 都是 4.6 ms:
input 0.4 + max(sim-physics 1.8, sim-ai 1.2) + sim-anim 0.9
+ render-list 0.5 + cull 0.7 + submit 0.3 = 4.6 ms
原因是区块任务的模拟耗时(0.6 ms)比同层的 sim-physics(1.8 ms)短——多塞几个区块任务,只加宽了第一层,没有拖长关键路径。反过来,如果某个区块任务比 sim-physics 还慢,关键路径就会跟着涨。这条规律在排任务时很有用:决定帧耗时的是每层最慢的那个任务,不是任务总数。 优化帧预算时,先盯住每层的「最慢者」。
重建的代价:每帧一次的开销
重建不是免费的。把「建图 + 分层」跑上几十万次取平均,本机实测每帧的重建耗时:
| 任务数 | 重建耗时 |
|---|---|
| 7 | 1.44 µs |
| 10 | 2.47 µs |
| 107 | 32.75 µs |
| 1007 | 383.33 µs |
(同一台机器上多跑几遍,这些数字有百分之几的抖动,量级稳定。)关键不是绝对值,而是它随任务数近似线性增长:7 个任务 1.4 µs,1000 个任务 383 µs,摊到每个任务约 0.3–0.4 µs。7 个任务时,重建只占一帧(16.6 ms)的万分之一,完全可以忽略;1000 个任务时,383 µs 约占一帧的 2.3%——仍然划算,但已经不是「免费」。
这就是「每帧重建」的代价曲线:任务少的时候它什么都不是,任务多的时候它开始被看见。
依赖粒度太细会吃掉收益
从上面那张表能读出一条工程规律:并行度的收益有上限,而调度开销没有。 把一个大任务切成 1000 个细粒度子任务,理论上并行度更高,但你同时付出了:
- 重建成本:1000 个任务光建图分层就 383 µs/帧。
- 调度成本:每个任务要进队列、被工作线程取走、完成后递减后继入度——这些都不是零。
- 同步成本:任务越多,跨线程的原子操作越多,争用越重。
当单个任务的实际耗时接近「调度它一次」的开销时,切成更多份只会更慢。经验做法是按「够粗、够独立」切:一个区块的网格更新作为一个任务,而不是拆成「读顶点、算法线、写缓冲」三个任务。到底多细合适,只能靠实测——这也是为什么这一节把重建开销单独量了出来。
从并行批次到线程池
拿到 levels 之后,派发逻辑短得可以写在一屏里:逐层处理,层内的任务全部丢给线程池,等这一层全部完成,再进下一层。 层的边界天然是一道屏障——层与层之间的依赖,正好由「上一层全部完成」来满足。
// snippet: 把分层计划派发到线程池(示意,完整实现见第 14 章)
for (const level of plan.levels) {
// 层内任务互不依赖,可以一起提交。
const pending = level.map((id) => pool.submit(tasks.get(id)!.run));
await Promise.all(pending); // 本层全部完成 = 进入下一层前的一道屏障
}
这里有个必须说清的取舍:为什么不把所有任务一次全提交、靠依赖计数自动放行? 那样并行度更高(不需要等整层),但需要一个每任务一个的「未完成前置计数」,并且要在每次完成时用原子操作递减、减到 0 才放行——这正是第 14 章 work stealing 队列之上的「依赖调度器」要做的事。按层派发是它的简化版:实现简单、屏障清晰,代价是层内只要有一个慢任务,整层都得等它。层数少、层内任务耗时不均时,这个代价会显现。
常见坑
- 只建一次图、之后反复用。任务集合一变,旧图就失效;静态图只适合任务恒定不变的场景。
- 把
levels展平成order再串行跑。这样并行度永远是 1,白排了分层——order只是「一个合法顺序」,不是「必须的顺序」。 - 层内派发后不等本层完成就进下一层。这会破坏依赖:下一层的任务可能在它的前置还没写完数据时就开始读。
- 按
order派发却忘了层边界。order里相邻的两个任务未必无依赖,照它并行派发是错的;能并行派发的单位是levels,不是order。 - 对重建开销不设预期。任务上万时,每帧几百微秒的重建会从「可忽略」变成「要计入帧预算」;要么减少任务数,要么把图改成增量更新。
- 把动态任务 id 拼错或重复。每帧重建时,
chunk-0这类 id 必须在本帧内唯一,否则add()会直接抛duplicate task。 - 以为并行度越高帧就越快。真正决定帧耗时的是关键路径(每层最慢者),加宽一层但不缩短最慢任务,帧时间纹丝不动。
- 把
cost当成实测耗时。它只是用来估关键路径的相对权重;要拿它做帧预算判断,得先把它换成真实测量值。 - 每帧都重新分配一堆临时对象。建图会产生 Map 与数组,帧率敏感时可用对象池复用它们(见第 5 章)。
15.1 和 15.2 合起来,已经能从「一组带依赖的任务」走到「本帧的执行序与并行批次」。但这些批次最终要交给别的线程去跑,而别的线程一旦并行,就必然引出「谁等谁、数据怎么传」的问题。15.3 就来处理帧边界上的跨线程同步。
阅读导航:上一节:15.1 依赖 DAG 与拓扑执行 · 下一节:15.3 与主循环、渲染线程的同步 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。