CppCon 2025 的演讲我全程没敢走神,尤其是那场 “More Speed & Simplicity: Practical Data-Oriented Design in C++”。干 C++ 高性能开发这些年,“Data-Oriented Design”(后面统一叫 DOD)已经从一个偏门词汇变成了实打实的主流方法论,但太多人只把它当成“性能黑魔法”,觉得无非就是把结构体拆成数组,其实远远不够。这场演讲最吸引我的点,就是把 DOD 从“大概那么回事”讲成了“怎么做、为什么这么做、做完怎么验收”的完整工程流程。
这篇文章不是演讲的逐字稿转写,而是我结合自己实际项目里的优化经验,把 DOD 的核心逻辑重新拆开,加上一堆可复现的代码和踩坑记录聊一遍。如果你是做游戏引擎、图形渲染、数据库内核、后端网关这类对延迟和吞吐敏感的 C++ 项目,或者单纯想知道“为什么数组遍历永远比链表快、但优化后还能再快几倍”,那这篇非常适合当入门加实践参考。
1. 为什么性能问题出在“数据”而不是“代码”上
1.1 一个让我重新审视性能本源的基准测试
两年前我给一个内部工具做过一次粒子系统重写。老的实现是非常标准的面向对象写法,一堆Particle对象躺在std::vector<Particle>里,每个对象包含了位置、速度、颜色、生命值、贴图下标、状态标记等十几个字段。更新逻辑很简单:遍历所有粒子,如果还活着,就更新位置和速度,颜色偶尔用一下,其它字段基本不动。
单看这段逻辑,代码完全没有问题,循环简单,也没有复杂的虚函数调用。然后我试了一次把结构体拆成多个平行数组(即 SoA 布局),同样是更新一万个粒子,两边差了接近五倍的耗时。当时我就意识到一个长期被程序员忽略的事实:CPU 花在“等数据从内存搬到寄存器”上的时间,远比“执行加法指令”的时间多。这也就是所谓的内存墙。
1.2 缓存命中和“内存墙”的基本盘
为了让新读者也能跟得上,我简单把访存成本讲透:
- CPU 执行指令时,数据不是直接来自主存,而是从 L1/L2/L3 多级缓存里加载。
- L1 缓存命中通常只要 3~5 个时钟周期,L2 大概 10~15 个,L3 可能 30~80 个,而主存则需要 200~300 个周期。一次主存访问,CPU 能干几百次加法。
- 所以一旦循环体内频繁发生缓存未命中,性能就会被访存延迟死死卡住。
用生活里的事打比方:你在厨房炒菜,调料瓶就放在灶台边,拿取只需要一秒;但如果调料都在楼下仓库,每炒一道菜都得跑一趟,那不管你的颠勺技巧多好,整桌饭的时间都耗在爬楼梯上了。缓存的命中率对应“调料在不在手边”,数据布局决定了你需要跑几趟仓库。
常见误区在于,很多人以为“数组比链表快”是因为数组的内存是连续的。那只是一个中间结论。真正的底层原因是:数组遍历时,CPU 可以一次性把一个 64 字节的缓存行拉进来,然后顺序访问好几个元素;链表则会让 CPU 跟着指针到处跳,几乎每次访问都可能是一个新缓存行,导致大量未命中。DOD 做的事情,就是让“热数据”能被这种批量读取方式吃满。
1.3 面向对象思维为什么会制造出“缓存不友好”的代码
传统面向对象设计的思路是:把现实世界实体抽象成类,把相关状态和行为捆在一起。这个思路在业务逻辑层没有任何问题,但到了性能敏感的循环层,它会造成两种麻烦。
第一种麻烦是“一个类管太多字段”。游戏里一个实体可能包含几十个属性,但某个系统运行时只关心其中两三个字段。如果所有属性都住在同一个结构体里,那么即使你只想读一个vx,CPU 也把整个结构体都装进缓存行。结构体越大,有用的数据占比就越低,缓存的有效利用率就越差。第二种麻烦是虚函数和多态带来的间接性,间接跳转本身成本不高,但会打乱分支预测,并且编译器很难跨虚函数做内联优化。
DOD 的核心思想也不神秘:先搞清楚程序运行时的“真实访问模式”,再围绕这些访问模式去设计数据结构。不是先想“对象”有什么属性和行为,而是先想“哪个循环在什么时候、按什么顺序、访问哪些字段”,然后把访问频率相近的数据在内存里排到一起。一句话总结就是:数据形状跟着访问路径走,而不是跟着实体概念走。
2. 从理念到 C++ 实现:DOD 的几把核心扳手
2.1 核心手法一:把 AoS 改成 SoA
这是 DOD 最常见的入门操作。对比非常直观:
用面向对象的方式写一个粒子结构:
struct Particle { float x, y, z; float vx, vy, vz; float r, g, b, a; bool alive; }; std::vector<Particle> particles;这是典型的 AoS,也就是Array of Structures,每个粒子对象把全部字段随身携带。改成 DOD 的 SoA,即Structure of Arrays,就是把每种字段拉出来单独放一个数组:
struct ParticleSystem { std::vector<float> x, y, z; std::vector<float> vx, vy, vz; std::vector<float> r, g, b, a; std::vector<uint8_t> alive; };如果更新逻辑只用位置和速度,那么遍历x/y/z/vx/vy/vz六个数组时,每来一个缓存行,拿到的都是马上要用的数据。老版本里缓存行里塞满了颜色、状态和其它暂时无关的字段,两者一对比,性能差异就非常明显。
但这里我必须强调一句:SoA 不是银弹,不要项目里所有类都无脑拆成 SoA。如果某个处理环节会读取结构体里的大部分字段,AoS 反而更好,因为字段集中在同一个缓存行内,一次缓存未命中就能拿到“一整块完整的处理素材”。选择布局的核心依据是“热点路径的字段访问带宽”,而不是某种教条。官方演讲里也多次强调这一点。
2.2 核心手法二:热路径与冷路径分离
DOD 里经常说 “hot” 和 “cold” 数据,意思是高频访问的热数据以及低频访问的冷数据。把它俩混在一起,等于让冷数据持续挤占缓存空间。
打个比方:一个玩家实体可能有位置、朝向这些每帧都更新的热数据,同时也有名字、创建时间、成就列表这些读取频率很低的冷数据。如果在内存布局里热数据和冷数据贴着放,那么每次加载热数据都会顺带把冷数据也拉进缓存行,白白浪费带宽和容量。
工程上最干净的做法是把一条“逻辑实体”拆成几块:一个核心热数据块,由系统高频读改写;一个或多个冷数据块,只在需要时加载。一层一层地做“冷热分离”。实际操作时并不一定追求极致的字面对齐,只要把高频字段集中到结构体开头、低频字段挪到结构体尾部,或者直接拆成两块数据结构,就能获得大部分收益。
2.3 核心手法三:用索引迭代,而不是用指针迭代
很多新手在重构时会把std::vector<Particle*>改成std::vector<Particle>,然后觉得优化完成了。实际上如果还需要删除某些元素,直接在一个连续数组里做插入删除会带来 O(n) 拷贝,有人就换回指针或链表,又回到缓存不友好。
DOD 社区比较推荐的折中方案是:主数据始终放在连续数组里,用一个std::vector<uint32_t> activeIndices保存当前活跃元素的索引。循环时遍历索引数组,真正需要删除时,把最后一个元素交换到被删除的位置,再让activeIndices缩一位,也就是“swap-and-pop”技巧,整个过程完全没有开销很大的中间复制,而且数组本身的连续性保住了。
这种写法我第一次看觉得绕,但用熟之后会发现它在缓存、分配、删除三方面都做得非常均衡,是那种“吊打面试题、上线更安心”的经典方案。
2.4 DOD 不必然牺牲可读性
一些团队不敢碰 DOD,是怕代码变得像“一堆平行的神秘数组”,后期完全看不懂。这个担心确实存在,但不是 DOD 自己的锅。合理的做法是保留业务语义层,比如写一个轻量视图层:
struct ParticleView { float& x; float& y; float& z; };系统内部用底层数组写高性能循环,对外呈现的视图仍具有直观的角色感。只要封装边界清楚,DOD 完全可以做到既快又不难看。C++17 之后有了std::span,这个视图层的写法还能更现代一点,后面实操部分我会展示。
3. 实操落地:把粒子系统用 DOD 重新写一遍
3.1 场景定义和原始版本基准
假设我们要模拟一万个粒子,每个粒子有位置、速度、颜色、生命标记。业务逻辑是:
- 每帧更新存活粒子的位置:
pos += vel * dt; - 每帧给存活标记更新一个递减的生命值;
- 渲染时读出颜色和位置。
先给一个最直白的 AoS 版本,并把基准数据记录下来。我的测试环境是 Windows 11,MSVC 2022,编译参数为x64 /O2,用一万个粒子跑 1000 帧,统计总耗时:
struct Particle { float x, y, z; float vx, vy, vz; float r, g, b, a; float life; bool alive; }; std::vector<Particle> particles; void UpdateAoS(float dt) { for (auto& p : particles) { if (!p.alive) continue; p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt; p.life -= dt; if (p.life <= 0.0f) p.alive = false; } }如果粒子数量少,比如一百个以内,AoS 完全够用,没必要折腾。但当粒子数量涨到十万级别,循环体里不仅访问了相邻的x/y/z,还在同一个结构体里反复加载vx/vy/vz,缓存行 64 字节装不下一个Particle,于是每个粒子至少要产生两次以上的缓存行加载。实测下来,一万粒子跑一千帧大约耗时 22 毫秒左右。注意,这只是一个相对值,不同 CPU 差异会很大,但不能否认“内存布局带来的可感知性能差”。
3.2 DOD 版本代码和步骤拆解
接下来是 SoA 版本。定义如下:
struct ParticleSystem { std::vector<float> x, y, z; std::vector<float> vx, vy, vz; std::vector<float> r, g, b, a; std::vector<float> life; std::vector<uint8_t> alive; size_t count = 0; }; void UpdateSoA(ParticleSystem& ps, float dt) { for (size_t i = 0; i < ps.count; ++i) { if (!ps.alive[i]) continue; ps.x[i] += ps.vx[i] * dt; ps.y[i] += ps.vy[i] * dt; ps.z[i] += ps.vz[i] * dt; ps.life[i] -= dt; if (ps.life[i] <= 0.0f) ps.alive[i] = 0; } }从代码量来看,SoA 版本几乎没有增加复杂度,甚至循环里的行数更少了。关键是访问连续内存,编译器容易识别出这是一个可以向量化的循环。
为了进一步提升,可以在局部变量里缓冲数据,减少别名造成的优化障碍:
void UpdateSoAVec(ParticleSystem& ps, float dt) { float* px = ps.x.data(); float* py = ps.y.data(); float* pz = ps.z.data(); float* pvx = ps.vx.data(); float* pvy = ps.vy.data(); float* pvz = ps.vz.data(); float* pl = ps.life.data(); uint8_t* pa = ps.alive.data(); for (size_t i = 0; i < ps.count; ++i) { if (!pa[i]) continue; px[i] += pvx[i] * dt; py[i] += pvy[i] * dt; pz[i] += pvz[i] * dt; pl[i] -= dt; if (pl[i] <= 0.0f) pa[i] = 0; } }这里把data()指针提到循环外面,是避免每次循环重复计算vector的data()(虽然data()通常非常便宜,但在极致热点循环里,提前取指针可以减少冗余检查)。如果编译器允许扩展语法,还可以在函数签名或局部声明中加__restrict:
float* __restrict px = ps.x.data();它的意思是告诉编译器“这个指针没有其他指针指向同一块内存”,从而允许更激进的重排和向量化。不过一定要保证事实确实如此,否则属于未定义行为,优化器可能生成错误结果。
最后对比一下同一台机器上的测试结果。AoS 一万粒子一千帧约 22ms,SoA 版本约 8ms,提前取指针的版本约 5ms。我在不同机器上复测过几次,差距约 3 到 5 倍,完全符合 CppCon 演讲中展示的普遍量级。而且我这里只是粒子系统,如果换成需要频繁遍历大对象集合的碰撞检测或者渲染场景管理,收益会被放得更大。
3.3 为什么连续内存还能带来“代码简洁”
有一类声音会说:“DOD 是牺牲可读性换性能。”但以我实际感受,恰恰相反。面向对象风格容易让每个系统都封装得非常“高内聚”,然后一场更新函数往往要跨好几个 manager 去拿数据。而 SoA 把“一次只处理一组同质数据”显式表达出来了,业务变成“一个循环、一块数组、一段逻辑”,热点路径的阅读难度反而降低了。
比如把上面更新函数放到ParticleSystem内部,再提供必要的PushBack、RemoveAt,对外接口是清晰的。内部热循环之所以直接,是因为它本来就是个批处理任务,不需要隐藏什么复杂的对象间关系。你大概率再也不会在一个两千行的类里翻来翻去找某个字段在哪里被修改。
3.4 演示一下 span 封装加 facade
如果你的项目里多个系统都要操作粒子数据,直接暴露六个vector确实容易出问题。我用std::span做一个逻辑视图:
struct ParticleHandle { size_t index; }; class ParticleSystem { public: ParticleHandle Spawn(...); void Kill(ParticleHandle h); float X(ParticleHandle h) const { return xs_[h.index]; } float Y(ParticleHandle h) const { return ys_[h.index]; } // ... private: std::vector<float> xs_, ys_, zs_, vxs_, vys_, vzs_; // ... };游戏逻辑层调用X(handle)、SetPosition(handle, ...)时,依然是面向对象的直观体验;底层引擎在批量更新时,直接访问原始数组。这种“双重视图”方案是我觉得 DOD 在真实商业项目里最容易推广的方式。
4. 多线程下的隐形杀手:伪共享,以及如何保持封装
4.1 灾难现场:线程一更新粒子 0,线程二更新粒子 1,性能竟暴跌
如果你把 SoA 数组交给多个线程并行处理,需要考虑另一个问题:伪共享(False Sharing)。两个线程各自更新不同的变量,但这两个变量坐落在同一个 64 字节缓存行里。硬件为了保证缓存一致性,会让两个核之间反复争夺这个缓存行的所有权,造成比普通缓存未命中更可怕的性能损耗。
举一个反例:线程 A 更新particles[0],线程 B 更新particles[1],如果粒子 0 和粒子 1 的数据在内存里紧挨着,两个线程就频繁互相失效对方的缓存行。表面上它们根本没有共享数据,但性能可能比单线程还差。
缓解手段主要有几种:
- 把数组按线程分块,每个线程只处理连续的一段,不需要互相抢同一个缓存行;
- 如果确实要按元素粒度并发,可以把数组按 64 字节对齐来分区,例如下标
i负责i * 64偏移的数据; - 使用
alignas(64)强制缓存行对齐。
需要注意,alignas(64)不是万能的,它会引入大量 padding,内存占用显著增加。优先选“按块分配任务”的方案,通常更简单有效。
4.2 多线程版本的分块处理示例
假如四个线程并行处理一万粒子,最朴素的做法是每 2500 个粒子分一段:
void UpdateParallel(ParticleSystem& ps, float dt) { const size_t total = ps.count; std::atomic<size_t> next{0}; auto worker = [&]() { size_t begin, end; while (true) { size_t cur = next.fetch_add(2500); if (cur >= total) break; begin = cur; end = std::min(cur + 2500, total); for (size_t i = begin; i < end; ++i) { // 更新逻辑 } } }; // 启动若干线程并 join,具体写法取决于线程库的封装 }这种分块方式自然避免了同一缓存行被不同线程反复读写。实战中还可以考虑 OpenMP 的#pragma omp parallel for schedule(static),效果类似,但控制粒度没那么细。
4.3 DOD 并不等于“抛弃面向对象”
这一点我想专门拿出来聊。很多团队对 DOD 望而却步,是因为觉得放弃面向对象就要推翻重写整个代码库,风险极高。但真正成熟的落地方式是:架构层面可以继续用模块化、系统化组织代码,DOD 只在“热点数据结构”上生效。
换句话说,类名、函数名、系统分层这些面向对象工具完全可以保留,只是“一个对象拥有十几个字段”这种建模习惯需要调整。演讲里也有一句话我非常认同:DOD 不是让你去消灭抽象,而是让抽象建立在数据访问模式的上层,不要让它伤害到性能关键路径。
5. 常见问题、误区和排查技巧
5.1 误区一:项目里所有结构体都改成 SoA
如果你什么都不测就把全部逻辑改成 SoA,大概率会白费力气。优化的第一原则永远是 Profile。先用 perf、Cachegrind、VTune 或者最简单的耗时采样定位热点,然后把注意力放在耗时最高的几个循环上。如果某个循环本来占总时间不到 3%,再优化内存布局意义也不大。
我见过有人在完全不相关的配置加载逻辑里搞 SoA,结果代码难看了不少,收益却忽略不计,最后被团队驳回,反而连累了 DOD 的口碑。
5.2 误区二:没注意std::vector<bool>的坑
SoA 布局中,如果有一个布尔字段,很容易被写成std::vector<bool> alive。这玩意儿是一个出了名的坑,C++ 标准允许它使用位压缩来节省空间,于是它不再是一块普通的 bool 数组。迭代vector<bool>时操作的不是真正的 bool 引用,性能尤其不稳定,而且很难被向量化。
我的建议是:DOD 里需要高性能的 bool 标志位就老实写std::vector<uint8_t>。空间代价完全可以接受,换来的是兼容性、稳定性和可预测的遍历速度。
5.3 误区三:把 Alignment 搞过头
为了制造大缓存行对齐,有人会在每个结构体前加alignas(64)。如果结构体本身只有 8 字节,这种对齐会让每个元素之间空出 56 字节,数组的内存密度剧烈下降,情况往往比不对齐更糟。
对齐只在两种场景下收益明显:一是数据要在多线程中被不同核心分别访问,需要避免伪共享;二是要使用 SIMD 指令时,要求数据按 16 字节或 32 字节对齐。对齐不是越猛越好,而是越“符合访存模式和硬件宽度”越好。
5.4 实际排查步骤:如何判断是缓存问题
遇到性能问题,我一般按下面这个步骤排查:
- 先用
std::chrono或 perf 记录当前函数耗时; - 用
perf stat或者 VTune 看 IPC/Cache Miss 指标。IPC 低于 0.5 基本属于访存瓶颈,高于 1.5 说明计算密集; - 在可疑循环体前减少数据量,比如只处理前 100 个元素,观察耗时是否近似线性;
- 尝试一次最轻量的 DOD 改造,比如把高频字段拆成一个单独的数组,再测 IPC;
- 如果 IPC 明显上涨且耗时下降,说明方向正确,再决定要不要推倒重写。
我自己的经验是:不要在 Debug 模式下分析性能,因为编译器会关掉大量优化,热循环的表现和 Release 完全不同。所有对比必须基于 Release + 优化开关打开,同时保证批次数量、迭代次数足够大,才能看出内存布局带来的差异。
5.5 工具推荐清单
| 工具 | 用途 | 适用场景 |
|---|---|---|
| perf / perf stat | 统计 cache-misses、IPC | Linux 命令行快速确认访存瓶颈 |
| valgrind --tool=cachegrind | 模拟缓存命中率 | 离线精细分析缓存行为 |
| Intel VTune | 热点定位、访存分析、伪共享检测 | Windows/Linux 图形化分析 |
| flamegraph | 耗时分布可视化 | 快速定位热点函数 |
| std::chrono | 自定义基准计时 | 日常对比 AoS 与 SoA 改造前后 |
对于 C++ 项目,尤其初学者,如果不想在工具的安装配置上花费太多时间,先用std::chrono做自己的基准测试就够了。缓存命中率只是解释性能差异的手段,最终判断依然应该以耗时为准。
最后分享两个小经验
第一个经验关于“改造节奏”。不要把 DOD 当作一次性的重写活动,而是当作一个持续的反馈循环。先挑一个最痛的点,改完测一遍,记录前后数据,再挑下一个。我见过最成功的团队,不是花两个月重构整个引擎,而是每周拿一个系统来优化数据布局,积累一段时间后,整体性能发生质变。
第二个经验关于“如何说服队友”。很多同事不接受 SoA,是因为觉得“数组下标访问比对象访问丑”。这时候不要争论理念,直接把两个版本放在同一个分支里,跑同一个 benchmark,让他们亲手看数据。性能差摆在那里,比任何道理都有效。CppCon 2025 这场演讲之所以能打动我,也正是因为它方法讲得足够具体,不空谈理论,而是用可复现的案例一步一步展示收益路径。如果你打算把 DOD 引入自己项目,我的建议也一样:找一个真实热点,用上面的步骤做一次改造,再根据结果决定下一步走向。