约 37 分钟技术学习游戏引擎

Games104学习笔记(27)

GAMES104:现代游戏引擎架构——面向数据编程与任务系统(下)

课程:GAMES104《现代游戏引擎:从入门到实践》
第 20 讲(下):现代游戏引擎架构:面向数据编程与任务系统
本地视频时长:约 1 小时 6 分钟

一、本讲概览

上半部分讨论了线程、Fiber、任务图、Work Stealing 等调度机制,回答的是“如何让多个 CPU 核心同时有工作可做”。下半部分进一步追问:

即使任务已经被并行调度,如果每个任务都在随机访问内存、追逐指针、频繁跳转,CPU 是否真的能高效执行?

答案是否定的。现代处理器的算术能力增长很快,但主存访问延迟并没有以同样速度缩短。许多游戏程序真正浪费的不是加法和乘法,而是等待数据进入 CPU。

因此,本讲的知识链是:

  1. 面向对象代码为什么容易形成离散对象、虚函数调用和指针追逐。
  2. CPU 流水线、缓存、预取和分支预测如何影响真实执行成本。
  3. 面向数据编程如何围绕“数据将怎样被批量处理”设计内存布局。
  4. ECS 如何将身份、数据和逻辑拆开。
  5. Archetype 与 Chunk 如何把同类组件紧密存放。
  6. Unity DOTS 与 Unreal MassEntity 如何把这些思想工程化。
  7. 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。
  • 对象还可能指向资源、父对象、子对象和其他系统状态。

从软件结构看,这种组织很灵活;从硬件视角看,它可能意味着:

  1. 先读取 GameObject。
  2. 再读取组件指针。
  3. 再跳到组件对象。
  4. 通过虚表找到函数地址。
  5. 再跳转到具体实现。
  6. 具体实现继续读取其他对象。

每一步都可能触发新的缓存行读取,而且后一步地址往往依赖前一步结果,CPU 难以提前把所有数据并行取回。

4.3 继承树中的行为分散

继承和虚函数会把同一个更新阶段的实际实现分散到许多类型中。例如,不同对象都调用 Tick,但真正执行的函数位于不同派生类。

这会带来:

  • 指令地址不连续,降低指令缓存局部性。
  • 间接调用目标不稳定,增加分支预测难度。
  • 很难看出一次系统更新究竟读取和写入哪些数据。
  • 难以把大量同类计算自动转化为 SIMD。
  • 并行调度器难以仅凭对象接口推导数据冲突。

问题不在于虚函数“必然很慢”,而在于大量细粒度、目标多变的虚调用会破坏批处理机会。

五、CPU 真正执行的是数据与指令流

CPU 不理解“角色”“飞行器”或“组件”的语义。它看到的是:

  • 从某个地址加载数据。
  • 对寄存器中的数执行运算。
  • 根据条件选择下一条指令。
  • 把结果写回缓存或内存。

因此,高层抽象最终必须映射为:

  1. 数据在内存中的物理位置。
  2. 指令的读取顺序。
  3. 分支的可预测性。
  4. 数据依赖链的长度。
  5. 能否并行发射多条指令。
  6. 能否使用 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}$ 的地址。若每个节点位于不同缓存行,就会形成:

  1. 等待当前节点。
  2. 取得 next 指针。
  3. 发起下一次读取。
  4. 再次等待。

多次内存访问无法充分重叠,预取器也难以预测随机链。

相反,数组地址可以直接计算:

$$ A_i = A_0 + iS $$

其中 $S$ 是元素步长。CPU 可以同时推测和预取多个后续元素。

所以在热点路径中:

  • 稳定连续数组通常优于链表。
  • 索引通常比跨堆对象指针更容易管理和压缩。
  • 批量分配通常比大量小对象独立分配更友好。

十二、假共享:数据不同,缓存行相同

两个线程即使修改的是两个不同变量,只要变量位于同一个缓存行,也可能产生假共享。

例如:

  • 核心 A 频繁写入计数器 a。
  • 核心 B 频繁写入计数器 b。
  • a 与 b 位于同一缓存行。

缓存一致性协议通常以缓存行为粒度维护所有权。A 的写入会使 B 的缓存行副本失效,B 的写入又会反过来使 A 的副本失效,于是缓存行在核心之间来回迁移。

解决方向包括:

  • 让不同线程频繁写入的数据落在不同缓存行。
  • 采用每线程局部累计,最后再归并。
  • 按连续区间切分数组,避免交错写入。
  • 减少共享可变状态。

这说明并行安全与并行性能不是同一件事:程序可能没有数据竞争,却仍因假共享而很慢。

十三、面向数据编程的核心问题

面向数据编程不是某个语法特性,而是一种设计方法。它首先询问:

  1. 系统要处理哪些数据?
  2. 每一阶段读取什么、写入什么?
  3. 数据以什么顺序被访问?
  4. 一次处理多少元素?
  5. 哪些字段属于热点,哪些字段很少使用?
  6. 如何让同类数据连续?
  7. 如何让编译器和 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 有三层价值:

  1. 语义层:说明系统处理什么数据。
  2. 存储层:定位满足条件的 Archetype 与 Chunk。
  3. 调度层:推导系统之间的读写冲突。

设系统 $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}} $$

这通常需要:

  1. 在目标 Archetype 的 Chunk 中分配位置。
  2. 搬移共有组件数据。
  3. 初始化新增组件。
  4. 更新 Entity 到存储位置的映射。
  5. 回收原位置。

因此,结构性变更通常比修改普通组件字段昂贵。工程上常见策略:

  • 延迟记录结构性变更。
  • 在安全同步点批量应用。
  • 避免在热点内层循环中频繁增删组件。
  • 对高频状态切换考虑使用标记字段或启用位。

是否采用标记还是结构变更,需要在查询效率、内存占用和变更频率之间权衡。

二十四、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 批量循环如何生成高质量机器码

它们形成一条完整链路:

  1. ECS 暴露连续数据和明确访问集合。
  2. Job System 根据数据分块和依赖并行调度。
  3. 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]

这体现了关键模式:

  1. 查询先选择匹配 Chunk。
  2. 一次取得该 Chunk 所需组件数组。
  3. 在紧凑循环中按统一索引批量更新。

逻辑上仍是在更新每个实体,但硬件看到的是规则数组循环,而不是跨越大量对象的虚函数调用。

二十八、从逐对象 Tick 到批量 System

传统写法的思路是:

  1. 遍历所有 GameObject。
  2. 对每个对象遍历组件。
  3. 对每个组件调用虚函数 Tick。

数据导向写法的思路是:

  1. 运行 MovementSystem。
  2. 找到拥有 Position 与 Velocity 的 Chunk。
  3. 连续读取 Position 和 Velocity。
  4. 批量更新位置。
  5. 再运行 RenderPrepareSystem、AnimationSystem 等其他变换。

两种方式在功能上可能等价,但执行形态不同:

逐对象 Tick 批量 System
控制流围绕对象 数据流围绕阶段
方法目标经常变化 循环体稳定
数据位置不确定 数据范围明确
访问依赖隐式 读写集合显式
难以整体向量化 容易形成 SIMD 循环
并行冲突难分析 可由访问声明构建依赖

二十九、DOP 与 Job System 的结合

理想的高吞吐更新阶段可以这样设计:

  1. System 声明 Query 与读写权限。
  2. ECS 找到所有匹配 Archetype。
  3. 将匹配 Chunk 拆为多个 Job。
  4. 调度器根据读写冲突建立依赖。
  5. Worker 处理不同 Chunk。
  6. 每个 Worker 在连续数组上执行批量循环。
  7. 编译器将循环向量化。
  8. 在同步点提交结构性变更。

这种架构同时利用了:

  • 任务级并行:多个 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 或外部服务限制的流程。
  • 需要复杂对象图但不在帧热点中的编辑器数据。

三十四、性能优化的推荐顺序

  1. 先用 Profiler 找到真实热点。
  2. 确认热点受计算、分支、缓存、带宽还是同步限制。
  3. 明确系统的读集合、写集合与生命周期。
  4. 缩小热点数据结构。
  5. 分离冷热数据。
  6. 把同类数据连续存储。
  7. 把逐对象调用改成批量循环。
  8. 检查分支模式与指针依赖。
  9. 再考虑 SIMD 与多线程。
  10. 用目标平台的真实帧时间和硬件计数器验证。

不应为了“看起来现代”先重写架构,再去寻找它解决的问题。

三十五、并行调试与系统同步的工程提示

课程末尾问答涉及并行调试,以及渲染线程与逻辑线程同步。可将相关工程原则归纳为:

  • 让任务依赖显式存在于任务图,而不是散落在隐式锁中。
  • 为每个 Job 记录名称、输入范围、依赖和执行线程。
  • 保留可切换的单线程或确定性执行模式,便于复现。
  • 对跨线程命令使用不可变快照、双缓冲或命令队列。
  • 让逻辑线程生成渲染所需数据,渲染线程消费稳定快照。
  • 明确资源所有权和生命周期,避免两个线程任意修改同一对象图。
  • 用调度轨迹观察等待、争用、长尾和关键路径。

这些方法的共同目标,是把“某个线程在某个时刻碰巧做了什么”转化为可观察、可重放的数据依赖关系。

三十六、核心设计原则

  1. 优化访问模式,而不只是优化单个函数。
  2. 数据布局由最常见、最昂贵的访问路径决定。
  3. 一次加载的缓存行应尽量包含当前循环真正需要的数据。
  4. 让热点工作集尽量留在高速缓存中。
  5. 避免热点路径中的随机指针追逐。
  6. 把同类操作集中成稳定的批量循环。
  7. 分支应尽量规则,数据应尽量连续。
  8. 把身份、数据和逻辑分开,便于存储与调度独立优化。
  9. Archetype 负责分类,Chunk 负责连续存储,Query 负责选择。
  10. Entity Handle 必须能识别已经失效并被复用的索引。
  11. 结构性变更应批量、延迟并放在明确同步点。
  12. System 必须显式声明读写集合。
  13. 无数据冲突只是并行执行的必要条件,不是充分条件。
  14. Job System 负责分配工作,DOP 负责高效供给数据。
  15. SIMD、多核和缓存优化必须一起考虑。
  16. 不要为低频、非热点代码支付过高架构复杂度。
  17. OOP 与 DOP 可以分层共存:高层组织对象,底层批量处理数据。
  18. 所有性能结论最终都要由目标平台上的测量支持。

三十七、核心术语

术语 含义
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 不是全局万能架构,应优先用于大规模、高频、同构的热点数据。
  • 所有布局和并行优化都必须用真实硬件数据验证。

三十九、参考资料