多线程渲染与任务系统
Multithreaded Rendering & Task Systems
CPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。
应用阶段与内存(18.4.1 节)
- 用性能分析器找热点(多为每帧执行多次的内部循环);多试编译器优化选项和不同编译器,不要预设哪个选项更快(更激进的循环优化可能更慢)。
- 内存墙:1980–2005 年 CPU 性能约每两年翻倍,DRAM 约每六年翻倍(冯·诺依曼瓶颈)。如今关键是内存访问模式,缓存访问模式可造成数量级的性能差异;在 GPU 上速度与功耗与数据移动距离成正比。缓存利用空间局部性和时间局部性。存储层次从寄存器(约 1 周期)、L1/L2/L3、DRAM、SSD、硬盘到云存储,每降一级延迟显著增加。
| 建议 | 说明 |
|---|---|
| 顺序访问的数据顺序存储 | 例如按访问顺序交错存放纹理坐标、法线、颜色、顶点 |
| 关键代码避免指针间接、跳转和函数调用 | 分支预测与缓存预取对二叉树、链表、图失效,尽量用数组;McVoy 与 Staelin 的链表例子让 CPU 停顿超过百倍;把指针树扁平化为带跳跃指针的列表、用 van Emde Boas 布局、用高分支度树减少深度 |
| 数据结构对齐缓存行 | Intel、AMD 常见 64 字节缓存行;VTune、CodeAnalyst、Instruments、Valgrind 可帮助定位;对齐也影响 GPU 着色器性能 |
| 尝试不同数据组织 | 结构数组 (AoS) Vertex {float x,y,z;}、数组结构 (SoA) {float x[1000],y[1000],z[1000];} 便于 SIMD,但数组变大时缓存未命中增加;混合 {float x[4],y[4],z[4];} 的 250 个元素可能最好 |
| 内存池 | 启动时为同尺寸对象分配大池并自管分配释放,连续记录更缓存一致;但 C#、Java 等有垃圾回收的语言中池可能更慢 |
| 渲染循环内不分配 / 释放内存 | 预先分配临时空间,栈和数组只增长,用标记表示已删除元素 |
两种并行方式(18.5 节)
实时渲染常用共享内存的对称多处理 (SMP),例如多核 PC。
flowchart LR
subgraph "多处理器流水线"
I1["输入"] --> S1["阶段1 CPU1"] --> S2["阶段2 CPU2"] --> S3["阶段3 CPU3"] --> O1["输出"]
end
subgraph "并行处理"
I2["输入"] --> P1[CPU1]
I2 --> P2[CPU2]
I2 --> P3[CPU3]
P1 & P2 & P3 --> O2["输出"]
end上图上半是时间并行,下半是空间并行;二者理想加速都是线性的 倍。
多处理器流水线(18.5.1 节):把应用阶段分为粗粒度的 APP、CULL、DRAW 三段。APP 控制其他阶段,做碰撞检测、更新视点等;CULL 做场景图遍历与层次视锥剔除、LOD 选择、状态排序,并总是生成待渲染物体列表;DRAW 遍历列表发出所有图形调用。单核时三段都在一核;双核时按负载把 APP+CULL 与 DRAW 分开,或 APP 与 CULL+DRAW 分开;三核时各占一核。
- 优点:吞吐(帧率)提高,只在切换帧时同步。缺点:延迟更大。延迟是从采集用户输入到最终图像的时间,不同于帧率:例如头显位置 10 ms 到达 CPU、渲染 15 ms,帧率 66.7 Hz 但延迟 25 ms,不做预测补偿会感觉迟钝。延迟随流水线阶段数增加。
- 降低延迟:在 APP 末尾才更新视点等延迟敏感参数(约减少一帧);让 CULL 与 DRAW 通过 FIFO 重叠执行(满则 CULL 停顿,空则 DRAW 饥饿),代价是状态排序无法充分进行。
并行处理(18.5.2 节):飞行模拟、第一人称射击、VR 等无法接受长延迟。静态分配把工作分成 份,要求负载高度可预测;动态分配用工作池,空闲 CPU 取任务,必须保证一个任务只被一个 CPU 取走、维护队列开销不能太大;任务越大队列开销越小,但太大会负载不均。例:每帧工作 30 ms、三个 CPU 线性加速,流水线每 10 ms 出一帧(100 FPS)但延迟 30 ms;并行处理延迟和每帧时间都是 10 ms。但"视情况而定":若从 30 FPS、1 帧延迟变为 60 FPS、2 帧延迟,多出的一帧延迟并无可感知差别。
任务系统(18.5.3 节)
核心多时,趋势是任务 (job) 式多处理:任何核心生成的任务都放入工作池,空闲处理器领取,从而同时容纳流水线与并行。做法是先理清各系统间依赖(如 Frostbite 的 CPU 任务图),再把系统分解为任务。好的任务候选:输入输出明确;运行时独立、无状态、总会完成;不至于大到经常成为唯一在运行的任务。C++11 内置多线程支持,Intel TBB 简化任务生成、池化与同步。坑:处理器等待同步会停顿,加上任务管理开销,任务化版本甚至可能更慢。
- 模拟、碰撞检测、遮挡测试、寻路等应自建任务并行。阴影贴图生成和深度预 pass 时 GPU 着色单元常闲置,可安排计算着色器任务;Lauritzen 认为由于硬件差异和语言限制,写出又快又可移植的计算着色器并不现实。
图形 API 的多线程支持(18.5.4 节)
- DirectX 10 及更早只允许一个线程访问驱动。驱动中可并行的两类操作:资源创建(纯 CPU 时天然可并行,但创建删除也可能阻塞)和渲染相关调用。
- 命令缓冲 (CB):状态切换和绘制调用的列表,源于 OpenGL 的显示列表,可创建、存储、重放、拼接。只有一个线程通过驱动把 CB 提交给 GPU,但所有线程都能并行创建或拼接 CB。DirectX 11 中该线程使用立即上下文,其他线程用延迟上下文生成 CB;只有立即上下文能做 GPU 查询和回读。
flowchart LR T2["线程2 延迟上下文: 录制 CB G"] --> M["线程1 立即上下文: 依次执行 CB A, M, T, G, N 后 Present"] T3["线程3 延迟上下文: 录制 CB M, N"] --> M T4["线程4 延迟上下文: 录制 CB T"] --> M T1["线程1 自己录制 CB A"] --> M
如上图,线程 1 在等待线程 3 的 CB N 时还能做其他工作。CB 创建时并未完全绑定:例如视图矩阵放在常量缓冲中,CB 只存引用,相机移动时改常量缓冲内容即可,无需重建 CB。并行度取决于按视图、物体还是材质为粒度创建和组合 CB。
- 取舍:CB 本身有创建和内存开销;DirectX 11、OpenGL 中把 API 状态映射到 GPU 仍很贵,应用是瓶颈时 CB 有帮助,驱动是瓶颈时反而有害。新 API 提供贴合现代 GPU 的轻薄提交接口,命令缓冲管理、内存分配和同步由应用负责,CB 只在创建时验证一次,重放开销更低,从而降低驱动成为瓶颈的可能。
GEA 的补充:并发编程底层(卷 I 第 4 章)
- RTR4 这里只讲到任务、同步开销和命令缓冲;GEA 第 4 章补齐了任务系统之下的底层:硬件并行分类与指令流水线(并发与并行基础)、线程与纤程(GEA 提到实现引擎任务系统时可用纤程做协作式调度,见 进程、线程与纤程)、互斥锁等同步原语及其内核调用开销(线程同步原语)、死锁与优先级反转(基于锁的并发问题)、原子操作与内存栅栏(无锁编程与内存序)以及 SIMD(SIMD 向量化)。
- 「并行」一词两书用法不同:RTR4 18.5 节的「多处理器流水线 / 并行处理」指把渲染应用阶段分给多个 CPU(时间并行 vs 空间并行);GEA 4.1–4.2 的「流水线」指 CPU 内部的取指-译码-执行-访存-写回指令流水线,「任务并行 / 数据并行」指按异构操作还是按数据分工。阅读时注意区分层次。
- 两书都提醒同步会吃掉并行收益:RTR4 说任务化版本可能因等待同步和管理开销反而更慢;GEA 说内核同步原语一次上下文切换可超过 1000 个时钟周期,主张少用锁、减少争用。
PBRT 的视角:离线光线追踪器的分块并行(PBRT 4e 1.3.4、1.5.10)
- 分块与波次:
ParallelFor2D()按图像范围和处理器数自动选块大小。块要明显多于核数,因为各块耗时差异很大(书中图 1.17 的直方图),一核一块会让先做完的核闲着;块太多又要反复付"领取任务"的固定开销。前两波每像素各 1 个样本,之后每波翻倍并设上限(capped doubling),在预览和线程空闲之间折中。 - 线程局部状态:
ScratchBuffer、Sampler、RNG都带可变状态,加锁代价过高,于是每线程一份(ThreadLocal、Sampler::Clone())。 - 线程安全约定:渲染期绝大部分数据只读,可以无锁并发读取;
Point3f、Transform等底层类不保证线程安全;表 1.1 中各基础接口的实现要求线程安全,例外有两个:只在场景构建时由单线程调用的Light::Preprocess(),以及Sampler(有状态、不要求线程安全,所以每个渲染线程用Clone()各持一份);独立函数都线程安全(只要不同线程不传同一块数据的指针)。 - 可重现调试:每个像素样本的执行是确定性的,崩溃时会打印类似
--debugstart 16,27,821的参数,可直接从该像素样本重新开始,而不必再等几小时。 与本页游戏引擎做法的差别:游戏的任务系统要在每帧固定预算内调度异构任务,PBRT 面对的是几乎同质、彼此独立的像素样本,只需要解决负载不均衡这一个问题。
PBRT 的视角:线程池、ParallelFor 与统计(PBRT 4e B.6.4–B.7)
- Amdahl 定律:串行比例为 、 核时最大加速比为 ,上限 ;5% 的串行(解析场景、建加速结构)就把加速比封顶在 20 倍。作者在 GPU 路径上就遇到过解析和准备场景比渲染还慢的情况,因此并行化了场景解析与对象创建。
- 线程池:
InitPBRT()创建线程池(通常每核一个),主线程也参与工作,所以只另启 个线程。任务实现ParallelJob接口(HaveWork()、RunStep()),挂在一个由互斥锁保护的双向链表上;新任务插到表头,嵌套产生的任务先执行,相当于深度优先,避免任务数爆炸。RunStep()在锁内领取一段工作、解锁后执行;没有工作时在条件变量上等待。 ParallelFor():按块 (chunk) 分发循环迭代,块大小与线程数成反比,在"抢锁次数"和"负载均衡"之间折中;所有工作都已领取后立刻把任务移出链表;调用线程自己也执行迭代,直到循环完成才返回,因此循环对象可以放在栈上。RunAsync()/AsyncJob:用std::bind绑定参数,返回的AsyncJob用std::optional存结果;Wait()期间会通过DoParallelWork()帮忙做线程池中的其他工作,而不是空等。任务系统与工作窃取可参考 Cilk(Blumofe 等 1996、1999)。ThreadLocal<T>:固定大小的哈希表,按需为每个线程创建对象(如每线程的Sampler、ScratchBuffer)。- 统计系统(B.7):
STAT_COUNTER等宏定义thread_local计数器,并注册一个 lambda;结束时用ForEachThread()让每个线程把自己的计数汇总。作者实测改用原子计数器会让渲染慢 10–15%。另有STAT_PIXEL_COUNTER可以输出每像素统计图(--pixelstats)。
出处
- realtime_rendering_4ed.pdf 第 18 章 Pipeline Optimization
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章
- PBRT 4e source excerpt: chapter 01 (Introduction)
- Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)第 1 章 1.3.4、1.5.10 节,pbr-book.org/4ed/Introduction
- PBRT 4e source excerpt: chapter B (Utilities)
- Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)附录 B B.2–B.7 节与延伸阅读,pbr-book.org/4ed/Utilities