《游戏引擎实现原理》15.2 任务图构建与帧内调度

每帧重建一张任务图:把本帧真实出现的任务(可见区块数每帧都在变)拼成 DAG,跑一次拓扑分层,得到本帧的执行序与并行批次。本节给出可编译的 FrameScheduler,并真实跑出 5 帧的执行顺序、每帧并行度,以及「重建」本身随任务数增长的开销。

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 还慢,关键路径就会跟着涨。这条规律在排任务时很有用:决定帧耗时的是每层最慢的那个任务,不是任务总数。 优化帧预算时,先盯住每层的「最慢者」。

重建的代价:每帧一次的开销

重建不是免费的。把「建图 + 分层」跑上几十万次取平均,本机实测每帧的重建耗时:

任务数重建耗时
71.44 µs
102.47 µs
10732.75 µs
1007383.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 与主循环、渲染线程的同步 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径