Games104学习笔记(27)
GAMES104:现代游戏引擎架构——面向数据编程与任务系统(下)
课程:GAMES104《现代游戏引擎:从入门到实践》
第 20 讲(下):现代游戏引擎架构:面向数据编程与任务系统
本地视频时长:约 1 小时 6 分钟
一、本讲概览
上半部分讨论了线程、Fiber、任务图、Work Stealing 等调度机制,回答的是“如何让多个 CPU 核心同时有工作可做”。下半部分进一步追问:
即使任务已经被并行调度,如果每个任务都在随机访问内存、追逐指针、频繁跳转,CPU 是否真的能高效执行?
答案是否定的。现代处理器的算术能力增长很快,但主存访问延迟并没有以同样速度缩短。许多游戏程序真正浪费的不是加法和乘法,而是等待数据进入 CPU。
因此,本讲的知识链是:
- 面向对象代码为什么容易形成离散对象、虚函数调用和指针追逐。
- CPU 流水线、缓存、预取和分支预测如何影响真实执行成本。
- 面向数据编程如何围绕“数据将怎样被批量处理”设计内存布局。
- ECS 如何将身份、数据和逻辑拆开。
- Archetype 与 Chunk 如何把同类组件紧密存放。
- Unity DOTS 与 Unreal MassEntity 如何把这些思想工程化。
- Job System 与 DOP 如何分别解决“调度”和“数据供给”问题,并最终组合成高吞吐引擎架构。
二、学习目标
- 理解现代程序为何经常受内存访问限制,而不是受算术指令限制。
- 理解缓存层级、缓存行、局部性与工作集。
- 理解指针追逐、虚函数、分支预测失败和假共享的成本。
- 区分面向对象编程与面向数据编程关注的问题。
- 掌握 AoS、SoA、冷热数据分离和批处理的基本思想。
- 理解 SIMD 为什么需要规则、连续的数据布局。
- 理解 Entity、Component、System 的职责边界。
- 理解 Archetype、Chunk、Query 和结构性变更。
- 理解 Entity Handle 中 Index 与 Generation 的用途。
- 理解 Unity DOTS 的 ECS、C# Job System 与 Burst 三部分如何配合。
- 理解 Unreal MassEntity 的 Fragment、Processor 和批量查询模型。
- 能够从数据访问集合判断两个系统是否可以安全并行。
三、从“有多少核心”转向“数据在哪里”
Job System 解决了工作分配问题:
- 把一帧工作拆成多个 Job。
- 用依赖图表达先后关系。
- 让空闲 Worker 主动获取可执行任务。
- 在多个核心之间动态平衡负载。
但是,Worker 获得 Job 后仍要读取数据。若数据不在缓存中,核心只能等待更低层缓存或主存返回。于是会出现一种常见现象:
- CPU 利用率看似不低。
- 线程和 Job 数量也足够。
- 但 IPC 较低,大量周期消耗在 Cache Miss 和停顿上。
- 增加线程反而会争夺内存带宽,并扩大缓存失效。
所以现代高性能引擎需要同时回答两个问题:
| 问题 | 主要方法 |
|---|---|
| 工作如何分配给多个核心 | Job System、任务图、Work Stealing |
| 每个核心如何快速获得所需数据 | DOP、连续布局、批处理、SIMD、ECS |
只优化前者,可能得到一群同时等待内存的核心;只优化后者,则可能只有一个核心高效地工作。
四、OOP 的价值与性能边界
4.1 面向对象编程解决了什么
面向对象编程把数据和操作数据的方法封装在对象中,并借助封装、继承和多态组织复杂软件。
它特别适合:
- 建立容易理解的领域模型。
- 隔离模块实现细节。
- 用接口表达可替换行为。
- 管理数量有限、生命周期清晰的高层对象。
- 提高大型工程的可维护性。
因此,DOP 并不是要求彻底消灭 OOP。两者关注的优化目标不同:
- OOP 主要围绕对象、职责和代码组织建模。
- DOP 主要围绕数据集合、访问模式和硬件执行建模。
总体上讲,OOP依然比DOP应用得多。
4.2 对象模型如何造成数据离散
传统游戏对象常由多个组件对象组成:
- GameObject 持有组件指针数组。
- 每个组件独立分配。
- 每个组件通过基类虚函数执行 Tick。
- 对象还可能指向资源、父对象、子对象和其他系统状态。
从软件结构看,这种组织很灵活;从硬件视角看,它可能意味着:
- 先读取 GameObject。
- 再读取组件指针。
- 再跳到组件对象。
- 通过虚表找到函数地址。
- 再跳转到具体实现。
- 具体实现继续读取其他对象。
每一步都可能触发新的缓存行读取,而且后一步地址往往依赖前一步结果,CPU 难以提前把所有数据并行取回。
4.3 继承树中的行为分散
继承和虚函数会把同一个更新阶段的实际实现分散到许多类型中。例如,不同对象都调用 Tick,但真正执行的函数位于不同派生类。
这会带来:
- 指令地址不连续,降低指令缓存局部性。
- 间接调用目标不稳定,增加分支预测难度。
- 很难看出一次系统更新究竟读取和写入哪些数据。
- 难以把大量同类计算自动转化为 SIMD。
- 并行调度器难以仅凭对象接口推导数据冲突。
问题不在于虚函数“必然很慢”,而在于大量细粒度、目标多变的虚调用会破坏批处理机会。
五、CPU 真正执行的是数据与指令流
CPU 不理解“角色”“飞行器”或“组件”的语义。它看到的是:
- 从某个地址加载数据。
- 对寄存器中的数执行运算。
- 根据条件选择下一条指令。
- 把结果写回缓存或内存。
因此,高层抽象最终必须映射为:
- 数据在内存中的物理位置。
- 指令的读取顺序。
- 分支的可预测性。
- 数据依赖链的长度。
- 能否并行发射多条指令。
- 能否使用 SIMD 一次处理多个元素。
性能优化的第一步不是猜测哪一行高级语言“看起来复杂”,而是理解它最终产生了什么数据流和机器行为。
六、内存墙:算得快,取得慢
处理器核心的执行速度与主存访问速度之间存在巨大差距。为了隐藏这种差距,现代 CPU 使用多级缓存:
- 寄存器最小、最快。
- L1 Cache 很小,但延迟最低。
- L2 Cache 更大,延迟更高。
- L3 Cache 通常由多个核心共享。
- 主存容量最大,但延迟远高于缓存。
一次普通算术操作可能只需少量周期,而一次落到主存的读取可能需要上百周期。具体数值因处理器和访问状态而异,但数量级差异才是关键。
程序性能因此常由下面的问题决定:
数据能否在需要之前进入靠近核心的缓存?
七、缓存行:CPU 搬运数据的基本块
CPU 通常不会只从内存读取某一个字节,而是按缓存行搬运一整块连续数据。常见缓存行大小为 64 字节,但实际大小应以目标平台为准。
设一次逻辑计算真正使用的字节数为 $B_{\text{useful}}$,为了获得它而加载的总字节数为 $B_{\text{loaded}}$,则可定义缓存行利用率:
$$ U = \frac{B_{\text{useful}}}{B_{\text{loaded}}} $$
例如,加载 64 字节却只使用其中 4 字节,则:
$$ U = \frac{4}{64} = 6.25% $$
其余数据占用了:
- 内存带宽。
- 缓存容量。
- 缓存替换机会。
- 硬件预取带宽。
所以“数据已经在某个对象里”并不代表布局高效。还要看一次缓存行中有多少数据会被当前循环实际使用。
八、局部性:让下一份数据就在附近
8.1 空间局部性
如果访问地址 $A$ 后,很快还会访问与 $A$ 相邻的地址,那么程序具有良好的空间局部性。
连续数组天然适合这种模式。CPU 读取一个缓存行后,后续若干元素可能已经在缓存中,硬件预取器也容易识别线性访问。
8.2 时间局部性
如果一份数据刚被访问后,很快还会再次访问,那么程序具有良好的时间局部性。
但时间局部性并不等于“任何复用都有效”。如果两次访问之间处理了大量其他数据,原来的缓存行可能已经被替换。
8.3 工作集
工作集是某一执行阶段频繁访问的数据集合。若工作集大小为 $W$,可用高速缓存容量为 $C$,理想状态是:
$$ W \le C $$
实际还要考虑缓存组相联、其他线程竞争、指令缓存和系统活动,所以应保留余量。
优化目标通常不是“让全部游戏世界进入缓存”,而是:
- 把一个阶段需要的数据缩小。
- 分块处理大集合。
- 让当前块的工作集能稳定留在缓存中。
九、指令也是数据
程序代码同样需要从内存读取,并进入指令缓存。
当更新循环在大量虚函数之间跳转时:
- 指令流分散。
- 当前函数代码可能把另一个函数的代码挤出指令缓存。
- 间接跳转目标较难预测。
- CPU 前端可能无法持续向执行单元供给指令。
这解释了为什么把同类对象集中交给同一个 System 处理,不仅改善数据缓存,也可能改善指令缓存:循环体更小、更稳定,处理对象的代码路径更一致。
十、流水线、预取与分支预测
现代 CPU 把取指、解码、执行和写回等阶段重叠起来。流水线充满后,可以让多条指令同时处在不同阶段。
为了维持吞吐量,CPU 会:
- 提前取指。
- 推测分支方向。
- 预取可能即将访问的数据。
- 在依赖允许时乱序执行。
10.1 分支预测
遇到条件分支时,CPU 在结果真正算出前预测下一条路径。如果预测正确,流水线继续推进;如果预测错误,错误路径上的工作需要被丢弃并重新取指。
可用一个简化期望成本表示分支代价:
$$ T_{\text{branch}}
p_{\text{correct}}T_{\text{hit}} + (1-p_{\text{correct}})T_{\text{miss}} $$
其中通常有:
$$ T_{\text{miss}} \gg T_{\text{hit}} $$
规律稳定的分支容易预测;数据随机、每个元素走不同逻辑的分支更难预测。
10.2 为什么批处理有利
批量处理同类数据时:
- 循环体相同。
- 分支模式更稳定。
- 数据地址近似线性增长。
- 编译器更容易展开循环和向量化。
- CPU 更容易预取后续缓存行。
这正是 DOP 经常把“遍历所有对象并调用各自方法”改成“一个系统遍历所有满足条件的数据列”的原因。
十一、指针追逐为何昂贵
链表和离散对象经常形成串行地址依赖:
$$ A_{i+1} = \operatorname{next}(A_i) $$
在读到节点 $A_i$ 之前,CPU 不知道下一个节点 $A_{i+1}$ 的地址。若每个节点位于不同缓存行,就会形成:
- 等待当前节点。
- 取得 next 指针。
- 发起下一次读取。
- 再次等待。
多次内存访问无法充分重叠,预取器也难以预测随机链。
相反,数组地址可以直接计算:
$$ A_i = A_0 + iS $$
其中 $S$ 是元素步长。CPU 可以同时推测和预取多个后续元素。
所以在热点路径中:
- 稳定连续数组通常优于链表。
- 索引通常比跨堆对象指针更容易管理和压缩。
- 批量分配通常比大量小对象独立分配更友好。
十二、假共享:数据不同,缓存行相同
两个线程即使修改的是两个不同变量,只要变量位于同一个缓存行,也可能产生假共享。
例如:
- 核心 A 频繁写入计数器 a。
- 核心 B 频繁写入计数器 b。
- a 与 b 位于同一缓存行。
缓存一致性协议通常以缓存行为粒度维护所有权。A 的写入会使 B 的缓存行副本失效,B 的写入又会反过来使 A 的副本失效,于是缓存行在核心之间来回迁移。
解决方向包括:
- 让不同线程频繁写入的数据落在不同缓存行。
- 采用每线程局部累计,最后再归并。
- 按连续区间切分数组,避免交错写入。
- 减少共享可变状态。
这说明并行安全与并行性能不是同一件事:程序可能没有数据竞争,却仍因假共享而很慢。
十三、面向数据编程的核心问题
面向数据编程不是某个语法特性,而是一种设计方法。它首先询问:
- 系统要处理哪些数据?
- 每一阶段读取什么、写入什么?
- 数据以什么顺序被访问?
- 一次处理多少元素?
- 哪些字段属于热点,哪些字段很少使用?
- 如何让同类数据连续?
- 如何让编译器和 CPU 看见可以批处理、向量化与并行化的结构?
可以把 DOP 概括为:
先设计数据及其流动方式,再围绕这些数据写变换过程。
它关心的不是单个对象“拥有哪个方法”,而是一批数据“将接受哪些变换”。
十四、AoS 与 SoA
假设粒子包含位置、速度、颜色和生命周期。
14.1 AoS:对象数组
AoS(Array of Structures)把每个对象的全部字段放在一起:
| 粒子 0 | 粒子 1 | 粒子 2 | … |
|---|---|---|---|
| 位置、速度、颜色、生命 | 位置、速度、颜色、生命 | 位置、速度、颜色、生命 | … |
优点:
- 单个对象的完整状态集中。
- 代码直观。
- 若一次确实使用对象的大部分字段,局部性可能很好。
缺点:
- 只更新位置和速度时,颜色等冷数据也会被带入缓存。
- 相同字段之间的步长较大。
- SIMD 装载和自动向量化更困难。
14.2 SoA:结构数组
SoA(Structure of Arrays)为每个字段保存独立连续数组:
- position[]
- velocity[]
- color[]
- lifetime[]
优点:
- 系统只读取当前需要的数据列。
- 相同字段连续排列。
- 缓存行利用率更高。
- 更容易使用 SIMD。
代价:
- 单个逻辑对象的数据被拆散。
- 添加、删除和重排实体时要保持多个数组索引一致。
- 可变长度字段与复杂引用需要额外方案。
14.3 选择不是绝对的
AoS 和 SoA 不是二选一的宗教规则。实际系统常采用:
- AoSoA:把小批对象组成块,每个块内部采用 SoA。
- Hot/Cold Split:热点字段连续存放,冷数据独立保存。
- Chunk:把同一组件组合的若干实体装入固定大小内存块。
判断依据始终是访问模式,而不是抽象形式本身。
十五、冷热数据分离
热点数据是几乎每帧都会访问的字段,例如:
- 位置。
- 速度。
- 当前状态标记。
- 碰撞范围。
冷数据是很少访问的字段,例如:
- 调试名称。
- 编辑器描述。
- 成就文本。
- 低频配置。
如果冷热字段混在一个大对象中,热点循环加载一个对象时会连带加载大量冷数据。将它们分离后:
- 热点结构更小。
- 一个缓存行可容纳更多实体。
- 工作集缩小。
- 冷数据不会污染热点缓存。
因此,数据拆分的目的不是让类型更多,而是让每次传入 CPU 的字节更接近真正需要的字节。
十六、SIMD:一条指令处理多份数据
SIMD(Single Instruction, Multiple Data)允许一条向量指令同时处理多个数据元素。
若向量宽度可容纳 $w$ 个标量,在完全可向量化且没有其他瓶颈的理想情况下,算术吞吐量上限可接近:
$$ S_{\text{SIMD}} \le w $$
实际收益还会受到以下因素限制:
- 内存带宽。
- 数据对齐。
- 分支分歧。
- 数据重排成本。
- 循环剩余元素。
- 前后依赖关系。
规则、连续、同类型的数据最适合 SIMD;随机对象、复杂虚函数和不可预测分支会阻碍向量化。
所以 DOP 的重要价值之一,是把程序重构成编译器和硬件能够识别的批量循环。
十七、从组件化对象到 ECS
传统组件化设计已经比深继承树更灵活:
- GameObject 负责身份和组件集合。
- Transform、Model、Motor、AI 等组件组合出行为。
- 新能力通常通过添加组件实现,而不是扩展继承层级。
但“组件化”不自动等于“数据导向”。
如果每个组件仍然是独立堆对象,并拥有虚函数 Tick,那么它仍可能存在:
- 指针追逐。
- 离散内存。
- 虚调用。
- 逐对象更新。
- 隐式数据访问。
ECS 在组件化思想上进一步收紧约束:
- Entity 只表示身份。
- Component 尽量只保存数据。
- System 集中保存处理逻辑。
- System 批量查询并处理满足条件的实体。
十八、ECS 的三个基本角色
18.1 Entity
Entity 是一个轻量标识符,本身不承载复杂行为。它用于关联一组组件。
可将实体 $e$ 拥有的组件类型集合记为:
$$ C(e)={T_1,T_2,\ldots,T_n} $$
18.2 Component
Component 是数据片段,例如:
- Translation。
- Rotation。
- Velocity。
- Health。
- Renderer。
在严格的数据导向 ECS 中,组件应尽量:
- 不持有复杂虚函数体系。
- 不自行执行 Tick。
- 数据尺寸明确。
- 适合搬移、复制和批量排列。
18.3 System
System 保存逻辑。它声明需要读取和写入的组件类型,并批量处理匹配实体。
若系统需要组件集合 $Q$,实体匹配条件可写为:
$$ Q \subseteq C(e) $$
例如,MovementSystem 可能查询同时拥有 Position 与 Velocity 的实体,而不关心实体在玩法语义上是角色、载具还是粒子。
十九、ECS 与传统组件模型的关键差异
| 维度 | 传统组件对象 | 数据导向 ECS |
|---|---|---|
| 身份 | GameObject 对象 | 轻量 Entity ID |
| 数据 | 分散在组件对象中 | 按组件类型或 Archetype 集中 |
| 逻辑 | 组件自己的虚函数 | System 批量执行 |
| 更新单位 | 一个对象或一个组件 | 一批匹配实体 |
| 内存布局 | 常由对象分配决定 | 由 ECS 存储策略显式决定 |
| 并行分析 | 数据访问常隐含在方法中 | System 可声明读写集合 |
| SIMD | 较难形成规则循环 | 更容易形成连续批处理 |
ECS 的核心不只是“组合优于继承”,而是把数据布局和批量执行提升为架构的一等公民。
二十、Archetype:按组件组合分组
Archetype 表示一种固定的组件类型组合。
例如:
- Archetype A:Position + Velocity。
- Archetype B:Position + Velocity + Renderer。
- Archetype C:Position + Health + AIState。
拥有相同组件组合的实体属于同一个 Archetype。这样可以:
- 快速判断一批实体是否满足 Query。
- 为同一组件组合建立统一内存布局。
- 避免逐实体检查大量组件指针。
- 以 Chunk 为单位批处理。
Archetype 关注的是组件类型集合,不是实体的玩法类别。两个完全不同的游戏对象,只要组件集合相同,就可能进入同一个 Archetype。
二十一、Chunk:固定大小的批量存储
一个 Archetype 通常拥有一个或多个 Chunk。Chunk 是固定大小或受控大小的连续内存块,存放一批具有相同组件组合的实体。
课程展示的 Unity ECS 示例使用固定大小 Chunk,并以约 16 KB 作为示意。这个具体值属于实现选择,核心思想是:
- 同类实体紧密排列。
- 同类型组件形成连续区域。
- Query 可以整块遍历。
- Chunk 可作为 Job 的自然切分粒度。
若 Chunk 可用字节数为 $B_{\text{chunk}}$,每个实体在该 Archetype 中占用的总字节数为 $B_{\text{entity}}$,忽略头部与对齐时,单个 Chunk 的理论容量约为:
$$ N_{\text{chunk}}
\left\lfloor \frac{B_{\text{chunk}}}{B_{\text{entity}}} \right\rfloor $$
实际容量还要扣除元数据、对齐和实现保留空间。
二十二、Query:把访问意图显式化
System 通常通过 Query 选择实体。Query 可以声明:
- 必须拥有的组件。
- 必须排除的组件。
- 只读组件。
- 可写组件。
- 可选组件或标签。
显式 Query 有三层价值:
- 语义层:说明系统处理什么数据。
- 存储层:定位满足条件的 Archetype 与 Chunk。
- 调度层:推导系统之间的读写冲突。
设系统 $A$ 的读集合和写集合分别为 $R_A$、$W_A$,系统 $B$ 为 $R_B$、$W_B$。两者若满足:
$$ W_A \cap (R_B \cup W_B)=\varnothing $$
且
$$ W_B \cap (R_A \cup W_A)=\varnothing $$
则从组件访问集合看,它们不存在直接读写冲突,有机会并行执行。
这不代表一定可以并行:系统还可能访问外部资源、全局状态、文件、渲染设备或其他隐式数据。因此访问声明必须完整。
二十三、结构性变更与实体迁移
当实体添加或删除组件时,它的组件集合发生变化,也就属于另一个 Archetype。
例如:
$$ {\text{Position},\text{Velocity}} \rightarrow {\text{Position},\text{Velocity},\text{Renderer}} $$
这通常需要:
- 在目标 Archetype 的 Chunk 中分配位置。
- 搬移共有组件数据。
- 初始化新增组件。
- 更新 Entity 到存储位置的映射。
- 回收原位置。
因此,结构性变更通常比修改普通组件字段昂贵。工程上常见策略:
- 延迟记录结构性变更。
- 在安全同步点批量应用。
- 避免在热点内层循环中频繁增删组件。
- 对高频状态切换考虑使用标记字段或启用位。
是否采用标记还是结构变更,需要在查询效率、内存占用和变更频率之间权衡。
二十四、Entity Handle:Index 与 Generation
ECS 常用 Index 加 Generation(或 Serial Number)的组合表示实体句柄:
$$ H=(i,g) $$
其中:
- $i$:实体表中的索引。
- $g$:该索引当前的代数或序列号。
当实体被销毁后,索引 $i$ 可以重新用于新实体,但 Generation 会递增。
句柄有效条件可写为:
$$ \operatorname{valid}(H) \iff \operatorname{alive}[i] \land \operatorname{generation}[i]=g $$
这样,旧句柄即使仍保存相同 Index,也会因 Generation 不匹配而失效,避免把已经销毁实体的引用错误地指向后来复用该槽位的新实体。
课程中的 Unreal MassEntity 示例使用 FMassEntityHandle,并以 Index 和 SerialNumber 实现这一思路。
二十五、Unity DOTS:三根支柱
Unity Data-Oriented Technology Stack(DOTS)把三类技术组合起来:
25.1 ECS
ECS 提供数据导向的组织框架:
- Entity 表示身份。
- Component 保存数据。
- System 批量处理数据。
- Archetype 和 Chunk 提供缓存友好的存储。
25.2 C# Job System
C# Job System 负责:
- 把批处理工作拆成 Job。
- 声明依赖。
- 在 Worker 线程上调度。
- 利用多个核心并行处理 Chunk 或数组区间。
25.3 Burst Compiler
Burst 以 LLVM 为基础,把受约束的高性能 C# 子集编译为平台相关的优化原生代码。
为获得可预测的高性能,它会限制或避免:
- 托管对象分配。
- 垃圾回收依赖。
- 反射。
- 不可预测的虚调用。
- 大量通用托管容器和运行时特性。
这些约束不是单纯“少用语法”,而是让编译器更容易证明:
- 数据类型和布局明确。
- 内存别名受控。
- 循环可向量化。
- 调用目标可确定。
- 代码可以脱离托管运行时热点路径执行。
二十六、为什么 ECS、Job System 与 Burst 要组合
三者分别解决不同问题:
| 技术 | 解决的问题 |
|---|---|
| ECS | 数据如何组织、查询和批量遍历 |
| Job System | 批量工作如何跨核心调度 |
| Burst | 批量循环如何生成高质量机器码 |
它们形成一条完整链路:
- ECS 暴露连续数据和明确访问集合。
- Job System 根据数据分块和依赖并行调度。
- Burst 对规则循环进行内联、向量化和平台优化。
如果缺少 ECS,Job 可能仍在随机访问对象;如果缺少 Job System,连续数据可能只被单核运行;如果缺少高质量代码生成,规则数据布局也无法充分转化为机器吞吐。
二十七、Unreal MassEntity 的对应概念
MassEntity 是 Unreal Engine 中面向大规模实体处理的数据导向框架。课程中的示例展示了与 ECS 思路对应的几个要素。
27.1 Entity
FMassEntityHandle 是纯身份句柄,由 Index 与 SerialNumber 等信息识别实体,不把完整对象状态塞入句柄。
27.2 Fragment
Fragment 是实体的数据片段,可类比 ECS Component。例如:
- Transform Fragment。
- Force Fragment。
- Velocity Fragment。
27.3 Processor
Processor 承担系统逻辑,声明查询条件并批量执行。
27.4 按 Chunk 获取数组视图
课程代码示例中,Processor 在一次 Chunk 执行中取得多个 Fragment 的数组视图,然后用同一实体索引访问各列:
- LocationList[EntityIndex]
- ForceList[EntityIndex]
- VelocityList[EntityIndex]
这体现了关键模式:
- 查询先选择匹配 Chunk。
- 一次取得该 Chunk 所需组件数组。
- 在紧凑循环中按统一索引批量更新。
逻辑上仍是在更新每个实体,但硬件看到的是规则数组循环,而不是跨越大量对象的虚函数调用。
二十八、从逐对象 Tick 到批量 System
传统写法的思路是:
- 遍历所有 GameObject。
- 对每个对象遍历组件。
- 对每个组件调用虚函数 Tick。
数据导向写法的思路是:
- 运行 MovementSystem。
- 找到拥有 Position 与 Velocity 的 Chunk。
- 连续读取 Position 和 Velocity。
- 批量更新位置。
- 再运行 RenderPrepareSystem、AnimationSystem 等其他变换。
两种方式在功能上可能等价,但执行形态不同:
| 逐对象 Tick | 批量 System |
|---|---|
| 控制流围绕对象 | 数据流围绕阶段 |
| 方法目标经常变化 | 循环体稳定 |
| 数据位置不确定 | 数据范围明确 |
| 访问依赖隐式 | 读写集合显式 |
| 难以整体向量化 | 容易形成 SIMD 循环 |
| 并行冲突难分析 | 可由访问声明构建依赖 |
二十九、DOP 与 Job System 的结合
理想的高吞吐更新阶段可以这样设计:
- System 声明 Query 与读写权限。
- ECS 找到所有匹配 Archetype。
- 将匹配 Chunk 拆为多个 Job。
- 调度器根据读写冲突建立依赖。
- Worker 处理不同 Chunk。
- 每个 Worker 在连续数组上执行批量循环。
- 编译器将循环向量化。
- 在同步点提交结构性变更。
这种架构同时利用了:
- 任务级并行:多个 System 或 Chunk 并行。
- 数据级并行:SIMD 一次处理多个元素。
- 缓存局部性:每个 Job 处理连续、有限的工作集。
- 调度灵活性:Chunk 可以作为动态负载均衡单元。
三十、批次大小与任务粒度
Chunk 是自然的任务单位,但不意味着每个 Chunk 必须对应一个独立 Job。粒度仍需权衡。
设总处理时间近似为:
$$ T_{\text{total}} \approx T_{\text{compute}} + N_{\text{jobs}}T_{\text{schedule}} + T_{\text{sync}} + T_{\text{memory}} $$
若 Job 太细:
- 调度次数增多。
- 队列操作增多。
- 依赖管理成本增大。
- 工作时间可能小于调度开销。
若 Job 太粗:
- 核心之间负载不均。
- 尾部任务形成长尾。
- 某些 Worker 空闲时无法继续拆分工作。
实际系统常把若干 Chunk 合并为一个批次,或根据历史耗时动态调整批次大小。
三十一、性能不能只看算法复杂度
两个算法可能具有相同的渐进复杂度,却有完全不同的硬件成本。
例如,两个遍历都是 $O(N)$:
- 一个遍历连续数组。
- 一个遍历随机分布的链表。
虽然复杂度相同,但后者可能产生大量缓存未命中和串行地址依赖。
性能分析还要观察:
- L1/L2/L3 Cache Miss。
- 分支预测失败。
- IPC。
- 内存带宽。
- SIMD 利用率。
- Job 等待时间。
- Worker 空闲比例。
- 锁和原子操作争用。
- 结构性变更次数。
Big-O 描述输入规模增长趋势,不能替代对常数、数据布局和硬件行为的分析。
三十二、常见误区
32.1 “使用 ECS 就一定更快”
错误。若系统查询混乱、组件过大、结构性变更频繁、Job 粒度不当,ECS 同样可能很慢。
32.2 “组件化就是 ECS”
错误。组件对象仍可能独立分配并自行 Tick。ECS 更强调数据与逻辑分离、集中存储和批处理。
32.3 “SoA 永远优于 AoS”
错误。若算法每次需要一个对象的全部字段,AoS 可能更合适。布局必须服从访问模式。
32.4 “无锁就没有缓存竞争”
错误。原子操作和假共享仍可能让缓存行在核心之间频繁迁移。
32.5 “增加线程可以隐藏所有内存延迟”
错误。多个线程可能共同耗尽内存带宽,并相互挤压缓存。
32.6 “抽象一定会损失性能”
错误。问题不在抽象本身,而在抽象是否让关键数据访问不可见、不可预测。零成本抽象和编译期抽象仍能提供良好结构。
32.7 “应该把整个引擎改成 ECS”
错误。编辑器、资源管理、UI、网络会话和少量高层对象未必需要 ECS。ECS 更适合规模大、更新频繁、模式规则的热点数据。
三十三、适合采用数据导向设计的场景
- 大量粒子或特效实例。
- 大规模人群、交通或单位模拟。
- 动画姿态批处理。
- 物理宽相位和约束求解。
- 可见性判断与渲染实例准备。
- 大量行为相似的 AI Agent。
- 高频空间查询。
- 每帧遍历成千上万个同构对象的系统。
不一定适合强行 ECS 化的场景:
- 数量很少的全局管理器。
- 高度异构、低频更新的工具逻辑。
- 主要受 I/O 或外部服务限制的流程。
- 需要复杂对象图但不在帧热点中的编辑器数据。
三十四、性能优化的推荐顺序
- 先用 Profiler 找到真实热点。
- 确认热点受计算、分支、缓存、带宽还是同步限制。
- 明确系统的读集合、写集合与生命周期。
- 缩小热点数据结构。
- 分离冷热数据。
- 把同类数据连续存储。
- 把逐对象调用改成批量循环。
- 检查分支模式与指针依赖。
- 再考虑 SIMD 与多线程。
- 用目标平台的真实帧时间和硬件计数器验证。
不应为了“看起来现代”先重写架构,再去寻找它解决的问题。
三十五、并行调试与系统同步的工程提示
课程末尾问答涉及并行调试,以及渲染线程与逻辑线程同步。可将相关工程原则归纳为:
- 让任务依赖显式存在于任务图,而不是散落在隐式锁中。
- 为每个 Job 记录名称、输入范围、依赖和执行线程。
- 保留可切换的单线程或确定性执行模式,便于复现。
- 对跨线程命令使用不可变快照、双缓冲或命令队列。
- 让逻辑线程生成渲染所需数据,渲染线程消费稳定快照。
- 明确资源所有权和生命周期,避免两个线程任意修改同一对象图。
- 用调度轨迹观察等待、争用、长尾和关键路径。
这些方法的共同目标,是把“某个线程在某个时刻碰巧做了什么”转化为可观察、可重放的数据依赖关系。
三十六、核心设计原则
- 优化访问模式,而不只是优化单个函数。
- 数据布局由最常见、最昂贵的访问路径决定。
- 一次加载的缓存行应尽量包含当前循环真正需要的数据。
- 让热点工作集尽量留在高速缓存中。
- 避免热点路径中的随机指针追逐。
- 把同类操作集中成稳定的批量循环。
- 分支应尽量规则,数据应尽量连续。
- 把身份、数据和逻辑分开,便于存储与调度独立优化。
- Archetype 负责分类,Chunk 负责连续存储,Query 负责选择。
- Entity Handle 必须能识别已经失效并被复用的索引。
- 结构性变更应批量、延迟并放在明确同步点。
- System 必须显式声明读写集合。
- 无数据冲突只是并行执行的必要条件,不是充分条件。
- Job System 负责分配工作,DOP 负责高效供给数据。
- SIMD、多核和缓存优化必须一起考虑。
- 不要为低频、非热点代码支付过高架构复杂度。
- OOP 与 DOP 可以分层共存:高层组织对象,底层批量处理数据。
- 所有性能结论最终都要由目标平台上的测量支持。
三十七、核心术语
| 术语 | 含义 |
|---|---|
| Data-Oriented Programming | 围绕数据布局、访问模式和批量变换组织程序 |
| Memory Wall | 处理器计算能力与内存访问速度差距形成的瓶颈 |
| Cache Line | 缓存与内存之间搬运数据的基本块 |
| Spatial Locality | 临近地址在短时间内被连续访问 |
| Temporal Locality | 同一数据在短时间内被重复访问 |
| Working Set | 某个阶段频繁访问的数据集合 |
| Cache Miss | 所需数据不在目标缓存层,需要从更低层取得 |
| Pointer Chasing | 后续访问地址依赖前一次读取结果 |
| Branch Prediction | CPU 对条件分支方向进行推测 |
| False Sharing | 不同线程写不同变量,却因共享缓存行而互相失效 |
| AoS | Array of Structures,对象完整字段连续排列 |
| SoA | Structure of Arrays,相同字段分别连续排列 |
| SIMD | 一条指令同时处理多份数据 |
| Entity | ECS 中表示身份的轻量 ID |
| Component | ECS 中由 System 处理的数据片段 |
| System | 批量读取或写入组件数据的逻辑 |
| Archetype | 由固定组件类型集合定义的实体分组 |
| Chunk | 存放同一 Archetype 一批实体的连续内存块 |
| Query | 根据组件集合和访问权限选择实体或 Chunk |
| Structural Change | 添加或删除组件导致实体改变 Archetype |
| Generation | 用于识别实体索引复用的代数或序列号 |
| DOTS | Unity 的数据导向技术栈 |
| Burst | Unity 面向高性能代码生成的编译器 |
| MassEntity | Unreal Engine 的大规模数据导向实体框架 |
| Fragment | MassEntity 中的实体数据片段 |
| Processor | MassEntity 中批量处理 Fragment 的逻辑单元 |
三十八、复习要点
- Job System 解决工作调度,DOP 解决数据供给,二者需要配合。
- 现代 CPU 很多时候不是缺少算术能力,而是在等待内存。
- CPU 按缓存行搬运数据,真正重要的是缓存行利用率。
- 空间局部性、时间局部性和较小工作集能减少缓存未命中。
- 指令同样占用缓存;大量不稳定虚调用会破坏指令局部性。
- 分支预测失败会清空错误路径上的流水线工作。
- 指针追逐形成串行地址依赖,数组访问更容易预取。
- 假共享没有逻辑数据冲突,却会造成缓存一致性流量。
- DOP 从数据集合与变换过程出发,而不是从对象方法出发。
- AoS 适合完整对象访问,SoA 适合同字段批处理。
- 热冷分离减少无关数据进入缓存。
- 连续、规则、少分支的数据更容易 SIMD 化。
- 组件化设计不自动等于 ECS。
- ECS 将身份、数据和逻辑分别交给 Entity、Component、System。
- Archetype 按组件组合分类,Chunk 紧密存放同类实体。
- Query 既选择数据,也为并行依赖分析提供依据。
- 添加或删除组件会触发结构性变更与 Archetype 迁移。
- Index 加 Generation 可避免旧句柄误指向复用槽位的新实体。
- Unity DOTS 由 ECS、C# Job System 和 Burst 协同组成。
- Unreal MassEntity 通过 Fragment、Processor 与 Chunk 查询实现批处理。
- ECS 不是全局万能架构,应优先用于大规模、高频、同构的热点数据。
- 所有布局和并行优化都必须用真实硬件数据验证。