中EN
概念页

数据局部性

Data Locality

数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。

意图

利用 CPU 缓存,通过合理安排数据来加速内存访问。(Game Programming Patterns — data-locality)

机制

现代 CPU 用缓存加速内存访问,访问刚访问过的内存附近会快得多。利用这一点的办法是提高数据局部性:把数据按处理顺序放在连续内存里。(Game Programming Patterns — data-locality)

CPU 从内存读一个字节时,会自动取回一整块相邻内存(通常 64 到 128 字节),放进缓存,这一块叫缓存行 (cache line)。下一个要用的字节恰好在这块里就是缓存命中,否则是缓存未命中,要等主存。(Game Programming Patterns — data-locality)

动机部分的要点(Game Programming Patterns — data-locality):

  • CPU 越来越快,RAM 却远远跟不上,从 RAM 取一个字节可能要数百个周期。作者用仓库作比喻:会计(CPU)一分钟处理完一箱文件,仓库管理员(总线)却要一整天才送来一箱;改为每次把整托盘相邻的箱子都运来放在办公室角落(缓存),下一箱恰好在托盘上就只要一秒。
  • 一次缓存未命中相当于在循环里插了一个 sleepFor500Cycles()。作者写了两个计算完全相同、只是缓存未命中次数不同的程序,慢的那个慢了五十倍。由此得出:数据的组织方式直接影响性能,性能不只是代码的属性。
  • 隐含前提是单线程:多个线程修改相邻数据时,反而应该让它们落在不同缓存行上,否则多个核要为同一缓存行做昂贵的同步。

书中的三个手段(Game Programming Patterns — data-locality):

  1. 连续数组代替指针追逐:原来是一组 GameEntity*,每个实体再用指针指向自己的 AI、物理、渲染组件,更新时在内存里到处跳。改成三个分别存放 AI、物理、渲染组件的连续数组,游戏循环直接顺序遍历。作者测试中,这一改动让更新循环快了五十倍。封装其实没丢多少:每个组件仍然拥有自己的数据和方法;GameEntity 也可以保留,它的指针只是改为指向这些数组,供需要传递“概念上的实体”的代码使用,只是性能关键的循环绕过它直接访问数据。一个提示:新代码里的 -> 少多了,想提高局部性就找可以去掉的间接访问。
  2. 打包数据 (packed data):粒子系统里,让活跃粒子始终排在数组前部,循环只遍历前 numActive_ 个;激活时与第一个非活跃粒子交换,停用时与最后一个活跃粒子交换,从而保持有序,而不是每帧排序。对比逐个检查 isActive() 的写法:每次检查都把整个粒子拉进缓存,非活跃的就白载入了,还可能引起分支预测失败和流水线冲刷。保持有序还有个好处:粒子里不再需要活跃标志,对象更小,一条缓存行装得更多。代价是失去一点面向对象:Particle 不知道自己的下标,无法自己 activate(),必须通过 ParticleSystem;作者认为二者本就是分在两个类里的同一个概念。直觉上移动内存比赋值指针“重”,但算上跟随指针的开销,有时移动反而更便宜(仍要先做性能分析)。ParticleSystem 本身也是为单一类型定制的 对象池。
  3. 冷热分离 (hot/cold splitting):AI 组件里每帧都要用的数据(当前动画、目标位置、能量)是“热”的,掉落物数据很少用,是“冷”的。把冷数据拆到旁边单独存放,热组件里只留一个指向它的指针,这样每帧载入缓存的都是有用的字节(除了那一个指针;也可以用热、冷两个并行数组按相同下标对应,连指针都省掉)。

下图对比两种布局:左边每个实体要跟随指针去找分散在堆上的组件,右边同类组件连续存放,循环顺序读取。

flowchart LR
  subgraph "指针追逐"
    E1[Entity 1] --> a1[AI*] & p1[Physics*]
    E2[Entity 2] --> a2[AI*] & p2[Physics*]
  end
  subgraph "连续数组"
    AA["AI 数组:AI1 AI2 AI3 …"]
    PA["Physics 数组:P1 P2 P3 …"]
  end

右边的布局让每次缓存行载入都带来下几个要处理的对象,左边则几乎每次访问都可能未命中。

何时使用

和多数优化一样,先确认有性能问题,并且问题确实由缓存未命中引起,否则没有帮助;优化不需要优化的代码只会让结果更复杂、更不灵活。简单的计时插桩看不出缓存问题,需要能报告缓存未命中次数和位置的性能分析器(免费选项如 Cachegrind,它在模拟的 CPU 与缓存层次上运行程序)。尽管不该过早花大量时间做缓存优化,在整个设计过程中仍要想着数据结构是否缓存友好。(Game Programming Patterns — data-locality)

取舍与坑

  • 抽象的代价:C++ 里用接口就意味着通过指针或引用访问对象,而指针跳转正是要避免的缓存未命中。越围绕数据局部性设计,就越要放弃继承、接口及其好处,没有银弹。(Game Programming Patterns — data-locality)
  • 虚方法调用也要查虚表再跟随指针,同样可能未命中。(Game Programming Patterns — data-locality)
  • 冷热的边界在真实游戏里很少那么清楚:有的字段只在某种模式或关卡某部分才用。这类优化介于“黑魔法”和“无底洞”之间,很容易陷进去无休止地挪数据测速度,需要经验判断该在哪里花力气。(Game Programming Patterns — data-locality)

设计决策(Game Programming Patterns — data-locality)

决策选项要点
如何处理多态不用安全、快、所有对象大小相同;不灵活
每种类型一个数组紧密打包、可以静态分派;要维护很多集合,并且要知道所有类型
指针集合灵活、开放;缓存不友好(非性能关键处无妨)
(补充)不用子类也能保留不少多态灵活性见 类型对象;编译器有时能去虚化 (devirtualization),JIT 中更常见
实体如何定义类,持有指向组件的指针组件可以放连续数组;但在内存中移动组件时要同步更新指针
类,持有组件 ID组件可以移动;更复杂、更慢,还要能访问组件“管理器”
实体本身只是一个 ID实体极小、无需管理生命周期;但实体里没有地方放状态了;查组件可能慢,可以用“实体 ID = 数组下标”加并行数组,但并行数组难以各自按不同标准排序

代码草图(C++)

按书中示例整理:

// 1. 连续数组:数组里是组件本身,不是指针
AIComponent* aiComponents = new AIComponent[MAX_ENTITIES];
PhysicsComponent* physicsComponents = new PhysicsComponent[MAX_ENTITIES];
RenderComponent* renderComponents = new RenderComponent[MAX_ENTITIES];

while (!gameOver) {
  for (int i = 0; i < numEntities; i++) aiComponents[i].update();       // 处理 AI
  for (int i = 0; i < numEntities; i++) physicsComponents[i].update();  // 更新物理
  for (int i = 0; i < numEntities; i++) renderComponents[i].render();   // 绘制
  // 其他计时用的游戏循环机制……
}

// 2. 打包数据:活跃粒子保持在前部
void ParticleSystem::activateParticle(int index) {
  assert(index >= numActive_);                 // 不应已经活跃
  Particle temp = particles_[numActive_];      // 与紧跟活跃区的第一个非活跃粒子交换
  particles_[numActive_] = particles_[index];
  particles_[index] = temp;
  numActive_++;
}

void ParticleSystem::deactivateParticle(int index) {
  assert(index < numActive_);                  // 不应已经非活跃
  numActive_--;
  Particle temp = particles_[numActive_];      // 与最后一个活跃粒子交换
  particles_[numActive_] = particles_[index];
  particles_[index] = temp;
}

// update 只遍历活跃区,无需检查标志
for (int i = 0; i < numActive_; i++) particles[i].update();

// 3. 冷热分离
class AIComponent {
public:
  // 方法……
private:
  Animation* animation_;   // 热数据:每帧使用
  double energy_;
  Vector goalPos_;
  LootDrop* loot_;         // 冷数据放在旁边
};

class LootDrop {
  friend class AIComponent;
  LootType drop_;
  int minDrops_;
  int maxDrops_;
  double chanceOfDrop_;
};

UE 与理论页中的对应

  • MassEntity → MassEntity 数据导向框架:实体只是一个 ID 加一组片段,组成相同的实体归入同一原型 (Archetype),并按内存块 (Chunk) 连续存放;无状态处理器通过实体查询成批遍历匹配的块,官方说这样能保证同原型片段的读取性能最佳。这正是上表中“实体本身只是一个 ID”加“同类数据连续存放”的组合。
  • 内存层次与缓存友好 → 多线程渲染与任务系统:该页(RTR4 第 18 章)讲了内存墙、空间局部性与时间局部性,以及“缓存访问模式可造成数量级的性能差异”,可以作为本模式的硬件背景。
  • Nystrom 推荐的入门读物是 Tony Albrecht 的《Pitfalls of Object-Oriented Programming》和 Noel Llopis 关于数据导向设计 (data-oriented design) 的博文;Richard Fabian 有专著《Data-Oriented Design》;Artemis 是较早、较知名的用简单 ID 表示实体的框架。(Game Programming Patterns — data-locality)
  1. Game Programming Patterns — data-locality
  2. Game Programming Patterns — data-locality(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) data-locality 章,gameprogrammingpatterns.com/data-locality.html

相关页面

游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发对象池Object Pool对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏开发 · UE5MassEntity 数据导向框架MassEntity FrameworkMassEntity 是 UE5 面向玩法的数据导向 (Data-Oriented) 计算框架,属于实体-组件-系统 (Entity Component System, ECS) 一类设计:实体 (Entity) 只是一个 ID 加上一组片段 (Fragment) 数据,相同组成的实体归入同一原型 (Archetype) 并按内存块 (Chunk) 连续存放,无状态的处理器 (Processor) 通过实体查询 (EntityQuery) 批量处理片段。它适合成千上万个同质对象(人群、交通),MassGameplay 插件在其上提供表现、生成、LOD、复制、StateTree 等功能。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发SIMD 向量化SIMD VectorizationSIMD(单指令多数据)用一条机器指令并行处理多个数据。x86 上从 MMX 发展到 SSE(128 位,4 个 float)、AVX(256 位,8 个)和 AVX-512(512 位,16 个)。GEA 用 SSE 内建函数演示:数组相加、点积、向量×矩阵、矩阵×矩阵,强调两点——避免寄存器内的水平相加(先转置数据),以及把 SIMD 寄存器看成 N 条并行的「通道 (lane)」而不只是一个 4D 向量;条件分支则用向量谓词化 (vector predication / select) 代替。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。游戏开发脏标记Dirty Flag脏标记 (Dirty Flag) 用一个布尔标记记录“派生数据是否已经过时”:原始数据变化时置位;需要派生数据时,如果标记已置位就重新计算并清除标记,否则直接用缓存的结果。场景图里由局部变换求世界变换是经典例子。它只在原始数据变化比派生数据被使用更频繁、而且难以增量更新时才划算,最大的风险是漏置标记导致使用过时数据。图形学波前路径追踪Wavefront Path Tracing波前 (wavefront) 架构把路径追踪拆成一串独立的 GPU 内核(生成相机光线、采样、求交、介质采样、逃逸光线、命中发光体、按材质分开的着色、阴影光线、写胶片),内核之间用工作队列传递数据;每个内核开始时线程都在做同一件事,执行是收敛的。代价是每个内核都要从显存读入、再写回全部状态,因此更受带宽限制。pbrt 4e 的 GPU 渲染路径 WavefrontPathIntegrator 就是这种结构,算法与 CPU 上的 VolPathIntegrator 完全相同(见 参与介质与体积散射),只改变计算的组织方式和数据布局。注意术语:这里的"波前"指整批光线按深度推进的处理方式,与 AM

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原