17.3 与 Job System 的对接
前两节把三块料备齐了:17.1 节给了主线程到渲染线程的命令缓冲通道,17.2 节证明了「一帧的命令可以拆成多条独立编码器、按顺序合并」在语义上与串行录制等价。但还有一个悬而未决的问题——谁来切块、谁来把块分给线程、谁来合并。这三件事本质上是「把一批互相独立的工作分给一组 worker,再按顺序收结果」,正好是卷一那套 Job System 的本职。
本节把渲染的并行录制接到 Job System 上。分工是这样的:主线程把一帧的绘制项按材质分组、切成若干连续分块;每个分块作为一个「录制作业」派给 worker;worker 在自己的线程上把这个分块录成一段命令(这里用「算打包排序键」来代表录制的 CPU 成本);主线程收齐后按分块顺序合并。正确性靠「合并后的绘制序列 == 单线程顺序录制」来守,收益靠墙钟来量。
录制作业:分块、录制、合并
三个动作对应三个函数。partitionEvenly 把绘制项切成连续的 k 块——连续是有意的,因为同一块内部材质变化少,setPipeline 的切换也少。recordItems 把一块绘制项录成命令流,材质一变就插一条 setPipeline。mergeCommands 把各块命令首尾相接,顺序即分块顺序。
这里有一个必须说清楚的细节:分块边界会让相邻两块各录一次相同的 setPipeline。比如第 1 块末尾是 material-2、第 2 块开头也是 material-2,串行录制只会设一次管线,并行录制会设两次。这不是 bug,是并行录制的固有开销——块切得越细,冗余越多。所以断言的口径不能是「命令流逐条相等」,而应是「绘制序列逐条相等」:每条命令流里的 draw 与 setBindGroup 必须一一对应、顺序一致,setPipeline 允许在块边界重复。
可运行产物:正确性由断言守,收益由墙钟量
下面 src/render/thread/render-jobs.ts 是完整可编译文件,层级 L1。它把上面三个函数、绘制序列抽取、以及一个确定性调度模型放在一起。
// file: src/render/thread/render-jobs.ts
// tier: L1
// verify: npx vitest run src/render/thread/render-jobs.test.ts
//
// helio 渲染卷 17.3:把一帧的绘制项切成若干「录制作业」,分给 Job System 的
// worker 并行录成各自的命令缓冲,再由主线程按顺序合并提交。本节只做两件事:
// 1) 合并的正确性——并行录出的分块合并后必须与单线程顺序录制逐条相等;
// 2) 调度的代价——用一个确定性 list-scheduling 模型算出 makespan。
// 与卷一 14/15 章共用同一套「任务 → 依赖 → 合并」的思路,这里换成渲染命令。
import type { RenderCommand } from './command-buffer.ts';
export interface DrawItem {
readonly id: number;
readonly material: number;
readonly vertexCount: number;
}
/** 单线程顺序录制一批绘制项:材质变化时插一条 setPipeline。 */
export function recordItems(items: readonly DrawItem[]): RenderCommand[] {
const out: RenderCommand[] = [];
let current = -1;
for (const it of items) {
if (it.material !== current) {
out.push({ op: 'setPipeline', name: `material-${it.material}` });
current = it.material;
}
out.push({ op: 'setBindGroup', index: 0, name: `bg-${it.id}` });
out.push({ op: 'draw', vertexCount: it.vertexCount });
}
return out;
}
/** 把数组按顺序切成至多 k 个连续分块(前若干块各多 1 个,不丢不重)。 */
export function partitionEvenly<T>(items: readonly T[], k: number): T[][] {
if (k < 1) throw new RangeError('k 至少为 1');
const n = items.length;
const parts = Math.min(k, n);
const chunks: T[][] = [];
let start = 0;
for (let i = 0; i < parts; i++) {
const size = Math.floor(n / parts) + (i < n % parts ? 1 : 0);
chunks.push(items.slice(start, start + size));
start += size;
}
return chunks;
}
/** 合并各分块录出的命令:按分块顺序首尾相接。 */
export function mergeCommands(chunks: readonly (readonly RenderCommand[])[]): RenderCommand[] {
const out: RenderCommand[] = [];
for (const c of chunks) out.push(...c);
return out;
}
/**
* 取出命令流里的绘制序列(draw / setBindGroup)。分块边界可能让相邻两块
* 各录一次相同的 setPipeline(材质没变也重设一次),这是并行录制的预期开销;
* 但绘制序列本身必须与单线程顺序录制逐条相等。
*/
export function drawSequence(cmds: readonly RenderCommand[]): RenderCommand[] {
return cmds.filter((c) => c.op === 'draw' || c.op === 'setBindGroup');
}
/**
* 模拟「录一个分块」的真实 CPU 成本:逐项算一个打包排序键(多次位运算),
* 再累加成一个校验和。串行录整帧与分块并行录的校验和必须相等。
*/
export function recordChunkWork(items: readonly DrawItem[], iterations: number): number {
let acc = 0;
for (const it of items) {
let h = (it.id + 1) * 2654435761;
for (let i = 0; i < iterations; i++) h = (h ^ (h >>> 13)) * 1274126177;
acc = (acc + (h >>> 0)) % 2147483647;
}
return acc;
}
export interface Job {
readonly id: number;
readonly cost: number;
}
export interface JobPlacement {
readonly id: number;
readonly worker: number;
readonly start: number;
readonly end: number;
}
export interface Schedule {
readonly placements: readonly JobPlacement[];
readonly makespan: number;
}
/** 确定性 list-scheduling:每个作业放到最早空闲的 worker 上。 */
export function listSchedule(jobs: readonly Job[], workers: number): Schedule {
if (workers < 1) throw new RangeError('workers 至少为 1');
const free = new Array<number>(workers).fill(0);
const placements: JobPlacement[] = [];
for (const job of jobs) {
let pick = 0;
for (let w = 1; w < workers; w++) if (free[w]! < free[pick]!) pick = w;
const start = free[pick]!;
const end = start + job.cost;
free[pick] = end;
placements.push({ id: job.id, worker: pick, start, end });
}
return { placements, makespan: Math.max(0, ...free) };
}
npx vitest run src/render/thread/render-jobs.test.ts 本机全绿,6 条断言:合并后的绘制序列与串行一致(k 取 1/2/3/5 都验)、分块不丢不重、分块校验和等于整帧、单 worker 的 makespan 等于总成本、worker 足够多时 makespan 等于最长作业、每个作业都落到当时最早空闲的 worker。npx tsc --noEmit 对本章文件零错误。
这里刻意把「录制」抽象成 recordChunkWork 这个纯计算函数,而不是真的去调 WebGPU:这样并行录制的正确性与收益都能在 Node 里用 worker_threads 直跑、可断言、可复现,不依赖 GPU。真实引擎里这个函数的内部会换成往 GPUCommandEncoder 里写命令,但分块、派发、合并的骨架一模一样——把易变的 GPU 部分隔离出去,才让这套编排能被单独验证。
真跑:worker_threads 的墙钟
正确性有了,接着量收益。下面两个文件把「分块录制」真派到 worker_threads 上:record-worker.ts 是 worker,收到一个分块就录、回传校验和;render-jobs-demo.ts 是主线程,跑一次串行、再跑一次 4 worker 并行,比墙钟。
// file: src/render/thread/record-worker.ts
// tier: L1
// verify: node src/render/thread/render-jobs-demo.ts
//
// Job System 的 worker:收到一个绘制项分块与迭代次数,在子线程里「录制」
// 这个分块(算打包排序键),把校验和回传主线程。主线程负责按分块顺序合并。
import { parentPort } from 'node:worker_threads';
import { recordChunkWork, type DrawItem } from './render-jobs.ts';
interface WorkRequest {
readonly chunk: readonly DrawItem[];
readonly iterations: number;
}
parentPort?.on('message', (raw: unknown) => {
const req = raw as WorkRequest;
const checksum = recordChunkWork(req.chunk, req.iterations);
parentPort?.postMessage({ checksum });
});
// file: src/render/thread/render-jobs-demo.ts
// tier: L1
// verify: node src/render/thread/render-jobs-demo.ts
//
// 真跑:把一帧的绘制项分成 W 块,用 worker_threads 并行「录制」,与单线程
// 顺序录制对比墙钟耗时。校验和相等说明并行录制没有改变结果;耗时差说明
// 分块并行确实把 CPU 录制的墙钟压了下来。
import { availableParallelism } from 'node:os';
import { performance } from 'node:perf_hooks';
import { Worker } from 'node:worker_threads';
import {
partitionEvenly, recordChunkWork, type DrawItem,
} from './render-jobs.ts';
const ITEMS = 4096;
const ITERATIONS = 20000;
function makeItems(n: number): DrawItem[] {
const out: DrawItem[] = [];
for (let i = 0; i < n; i++) out.push({ id: i, material: i % 7, vertexCount: 3 + (i % 5) });
return out;
}
function serial(items: readonly DrawItem[]): { ms: number; checksum: number } {
const t0 = performance.now();
const checksum = recordChunkWork(items, ITERATIONS);
return { ms: performance.now() - t0, checksum };
}
async function parallel(items: readonly DrawItem[], workers: number): Promise<{ ms: number; checksum: number }> {
const chunks = partitionEvenly(items, workers);
const t0 = performance.now();
const results = await Promise.all(chunks.map((chunk) => new Promise<number>((resolve, reject) => {
const w = new Worker(new URL('./record-worker.ts', import.meta.url));
w.once('message', (m: unknown) => { resolve((m as { checksum: number }).checksum); void w.terminate(); });
w.once('error', reject);
w.postMessage({ chunk, iterations: ITERATIONS });
})));
const ms = performance.now() - t0;
const checksum = results.reduce((a, b) => (a + b) % 2147483647, 0);
return { ms, checksum };
}
const items = makeItems(ITEMS);
const workers = Math.min(4, availableParallelism());
const s = serial(items);
const p = await parallel(items, workers);
console.log(`items=${ITEMS} iterations=${ITERATIONS} workers=${workers}`);
console.log(`serial: ${s.ms.toFixed(1)} ms checksum=${s.checksum}`);
console.log(`parallel: ${p.ms.toFixed(1)} ms checksum=${p.checksum}`);
console.log(`checksum equal: ${s.checksum === p.checksum}`);
console.log(`speedup: ${(s.ms / p.ms).toFixed(2)}x`);
node src/render/thread/render-jobs-demo.ts 的本机输出(本机 availableParallelism() 为 10,故取 4 个 worker):
items=4096 iterations=20000 workers=4
serial: 576.4 ms checksum=359992271
parallel: 212.4 ms checksum=359992271
checksum equal: true
speedup: 2.71x
两件事同时成立:checksum equal: true 说明并行录制与串行录制算出了同一个结果,speedup: 2.71x 说明 4 个 worker 确实把墙钟压了下来。加速比只有 2.71 而不是 4,是因为每次并行都要现场起 4 个 worker、把分块 postMessage 过去、再等消息回来——这部分固定开销不随分块大小缩小,分得越碎摊得越薄。多次实跑这个比值在 2.4x 到 3.0x 之间波动,取决于当时的系统负载。
调度模型与边界
真实引擎不会每次都现场起 worker,而是常驻一个线程池,作业从队列里取。作业分到哪个 worker、什么时候开始,就是调度问题。listSchedule 给了一个确定性模型:每个作业放到当时最早空闲的 worker 上,跑完的 makespan 就是整帧录制的墙钟下界。
用这个模型能看清两条边界。第一条是下限:无论多少 worker,makespan 都不会小于最长单个作业——如果一个分块的录制时间就超过了其它所有块之和,加再多线程也没用,说明分块本身不均衡,该改的是切法而不是线程数。第二条是上限收益:当 worker 数超过分块数时,多出来的 worker 只会空转,收益封顶在分块数上。所以「块数」和「线程数」要一起定:块数略多于线程数,既让调度有回旋余地,又不至于把每块切得太碎。
这套「作业 → 依赖 → 合并」的骨架与卷一是一致的:卷一的依赖 DAG 把任务连成图、线程池与任务队列 负责把就绪的任务派给空闲 worker;本节的渲染录制只是换了一种「任务」——它的输入是绘制项、输出是一段命令流,任务之间没有数据依赖,退化成一棵「扇出再扇入」的树。正因为没有依赖,才能放心地全并行。
把渲染录制接进 Job System 时,有一条线程安全的红线必须守住:命令缓冲与绑定组不可跨线程共享。worker 只能操作自己那一块的局部数据(绘制项、临时命令数组),录制产出的命令流要通过消息回传或共享内存交回主线程,由主线程统一 submit。任何试图让两个 worker 同时往同一个 GPUCommandEncoder 里写命令的做法都会立刻出错——WebGPU 的编码器不是线程安全的。所以并行的边界是「一块一个编码器」,而不是「一个编码器多线程填」。
这也解释了本节 demo 为什么把「录制」建模成一个纯计算函数而不是真去建编码器:编码器只能在拥有设备的那个线程上用,跨线程的是数据而不是设备对象。worker 算出的是「这一块要录哪些命令」的描述,主线程拿到描述后在自己的线程上真正建编码器、录命令、提交。分工的边界就落在这里:worker 负责算,主线程负责录与交。
小结
- 并行录制的编排是「主线程分块 → worker 各自录 → 主线程按序合并」,正好落在 Job System 的职责范围内。
- 正确性口径是绘制序列相等:
setPipeline允许在分块边界重复,但每条draw必须一一对应。 - 本机实测:4 worker 把 4096 个绘制项的录制从 576.4 ms 压到 212.4 ms(2.71x),校验和与串行一致;加速比受 worker 启动与消息往返的固定开销限制,多次实跑在 2.4x–3.0x。
- 调度模型给出两条边界:makespan 不小于最长单块(分块要均衡),收益不超过分块数(块数别少于线程数)。
- 线程安全红线:一块一个编码器,命令缓冲与绑定组不可跨线程共享,合并与
submit只在主线程做。
到这里,多线程渲染的三层——命令缓冲通道、并行录制、作业编排——都跑通了,而且每一层都有一条本机可复现的断言守着:通道不覆盖在途缓冲、并行录制与串行逐字节等价、并行作业与串行同校验和。下一章把渲染器的所有部件装配起来,真正把《微光岛》渲染出来。
阅读导航:上一节:17.2 OffscreenCanvas 与并行命令录制 · 下一节:18.1 渲染器装配与场景接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。