数据导向设计入门
数据导向设计入门
数据导向设计通过优先考虑内存布局而非对象层次结构来最大化性能
数据导向设计(DOD)是一种软件设计方法,它将焦点从"对象"转移到数据在内存中的读写方式。通过根据数据的访问模式而非其概念身份来组织数据,开发者可以大幅减少 CPU 缓存未命中,并简化多线程和硬件卸载(例如到 GPU 或 APU)的实现。
性能差距:内存延迟 vs. CPU 速度
现代 CPU 性能受内存延迟的严重瓶颈限制。虽然 CPU 可以在几个周期内执行指令,但从主内存获取数据可能需要数百个周期。
- L1/L2 Cache: 1–2 周期
- 主内存: 最多 600 周期(在 3.2 GHz)
当数据在内存中分散时,CPU 大部分时间花在等待数据到达而非执行计算。DOD 通过确保特定操作所需的数据在内存中连续存储,来让 CPU 始终得到数据供给。
面向对象设计(OOD) vs. 数据导向设计(DOD)
OOD 问题:缓存未命中和未使用的数据
在面向对象设计中,数据通常被分组到类中。例如,一个 Bot 类可能包含位置、修饰符和瞄准方向。当程序为 1000 个机器人更新瞄准方向时,它会为每个机器人将整个 Bot 对象加载到缓存中。
这导致了两个主要的低效率:
- 数据未命中: CPU 加载整个对象,包括当前操作不需要的数据(例如,在仅更新其
aimDirection时加载Bot的名称或库存)。 - 指令缓存未命中: 在许多不同对象上调用如
updateAim()的方法可能导致代码执行频繁跳转,从而导致 iCache 未命中。
DOD 解决方案:线性数组和变换
DOD 将系统视为一系列变换:数据输入 → 变换 → 数据输出。DOD 不使用 Bot 对象,而是将属性存储在独立的线性数组中(即结构体数组而非数组的结构体)。
示例:更新机器人瞄准
- OOD 方法: 遍历
Bot对象数组并调用bot.updateAim()。 - DOD 方法: 将
positions的线性数组、modifiers的线性数组和目标目标传递给一个函数。该函数线性遍历这些数组,将结果写入aimDirections的线性数组。
这确保了加载到 CPU 缓存行(通常为 128 字节)的每个字节都用于当前计算,消除了浪费的内存带宽并降低了延迟。
关键实施原则
从后往前设计
开发者应先定义所需的输出数据,然后确定生成该输出所需的最小输入数据量。这可以防止在性能关键路径中包含不必要的数据。
本地数据 vs. 源数据
数据应预先格式化以适配其运行的硬件。例如,虽然源数据可能以链表形式存储以便编辑,但本地运行时数据应转换为连续数组以确保缓存效率。
并行性和硬件卸载
由于 DOD 将数据与代码分离并以线性方式组织,因此并行化变得容易得多。当数据已知且隔离时,可以将其卸载到如 GPU 或 SPU 这样的协处理器,而无需复杂的锁,因为数据访问模式是可预测且自包含的。
关键视角和权衡
虽然 DOD 能带来巨大的性能提升,但它也带来了特定的架构挑战:
- 灵活性 vs. 性能: 一些实践者认为,当需求频繁变化时,DOD 比 OOD 缺乏灵活性。因为数据布局与特定算法紧密耦合,改变“问题”可能需要完全重新设计数据结构。
- 适用性: DOD 在处理大规模并行数据的系统中最有效,例如物理引擎、3D 渲染器和游戏引擎。对于较小的数据集,管理独立数组的开销可能超过性能收益。
- 与 ECS 的关系: 实体组件系统(ECS)常被用作实现 DOD 的框架。虽然它不是每个问题的完美解决方案,但与深层 OOD 层次结构相比,ECS 通常更具可塑性,以实现接近最优的性能。
"如果你有不同的数据,那么你就有不同的问题。” — 见解源自对 Mike Acton 方法的讨论。