一个很反常的事实:你只读 1 个字节,CPU 可能已经替你搬来了 64 个字节。

原文用一个 Monster 结构体讲这件事。结构体刚好 64B,里面有 id、坐标、速度、血量、攻击、防御、名字等字段。但遍历时真正要看的,只有 1B 的 is_alive

如果用传统 AoS,也就是 Monster[],每读一个怪物的存活状态,就把整条缓存行搬进来。你要的是 1B,机器按 64B 结账。

这篇文章最有价值的地方,不是喊一句“Big O 不够”。算法复杂度当然重要。O(N²) 该慢还是慢。它提醒的是:在同一个 O(N) 里,字段大小、结构体布局、工作集是否落进缓存,会把实际吞吐拉开到非常难看的程度。

64B 缓存行:读一个字段,也可能搬来一整块

原文机器上的锚点大概是:L1d 约 35KiB/core,缓存行 64B。再往下是 L2、L3、DRAM。容量逐级变大,延迟也逐级变高。

具体缓存容量和延迟会随机器变化。64B 缓存行也不是所有硬件的绝对真理。但在常见服务器和桌面 CPU 上,这个数量级足够解释很多性能差异。

层级大致特征对代码的含义
L1d每核几十 KiB,最快热数据放得下,循环很顺
L2/L3更大,也更慢工作集变大,延迟开始显形
DRAM容量大,延迟高随机访问会被明显拖慢

所以,遍历 N 个元素并不等于“成本一样”。每次访问到底用了多少有效字节,浪费了多少缓存行,差别很大。

Monster 例子里,AoS 的问题很直接:64B 里只有 1B 的 is_alive 真正在干活。其余字段被一起带进缓存,但这次循环用不上。

SoA 换一种摆法:ids[]xs[]hps[]is_alives[] 分开存。只遍历存活状态时,is_alives[] 可以让 64 个 alive 标记落在同一条缓存行里。

同样扫一遍,硬件看到的是两种完全不同的账单。

布局适合场景遍历 is_alive 的代价
AoS:Monster[]经常围绕一个对象读写多个字段每个对象可能搬入一整条结构体数据
SoA:字段分列数组热路径只批量访问少数字段一条缓存行可装下更多目标字段

原文基准里,在 Monster 结构体变到 1KiB 的特定场景下,差距最高接近 30x。

这不是通用结论。不能拿它去吓唬所有业务代码。它依赖结构体大小、访问模式、编译器、CPU、基准写法。但它至少说明:所谓“常数项”,在现代硬件上经常不是一个小常数,而是一段缓存阶梯。

随机访问:预取器帮不上忙,工作集开始说话

顺序访问时,CPU 预取器还能帮你。它能猜到下一段内存大概率会被读取,于是提前把缓存线拉进来。

很多看起来内存密集的循环,靠的就是这个优势。代码没那么聪明,硬件在背后替你铺路。

随机访问就冷得多。

Hash map、树、图、链表、指针追逐,访问位置不可预测。CPU 猜不到下一步,就没法提前准备。此时速度不只取决于访问次数,还取决于整个工作集掉在哪一层缓存里。

原文的指针追逐基准很直观:同样 512 个节点,如果每个节点 64B,总工作集约 32KiB,还可能卡进 L1d;如果每个节点变成 128B,总工作集就到 64KiB,更早掉到慢一层。

变化直接结果性能后果
字段变多单对象变大同样 N,占用更多缓存
64B 变 128B工作集翻倍更早跌出 L1/L2
顺序变随机预取器失效延迟阶梯被完整暴露

这对两类人最直接。

后端工程师如果在热路径里堆 hash map、对象包装、链式结构,就要警惕尾延迟。尤其是请求量上来后,CPU 看似没打满,延迟却不稳定,问题可能不在业务逻辑,而在内存访问形状。

游戏、系统和高性能计算工程师更应该把实体过滤、物理模拟、批处理计算拆开看。热字段能不能连续?冷字段能不能挪开?一帧里扫的到底是对象,还是少数几个字段?这些问题比“再封一层类”更接近性能真相。

Java 和典型 OOP 项目还有一层麻烦。对象头、引用间接访问、GC 移动或整理、包装对象,都会让真实布局更不透明。代码里只是加字段、加对象,机器看到的是更多字节、更多跳转、更多缓存未命中。

C、C++、Rust 也逃不掉。它们只是让你更有机会控制布局。控制权不是免费午餐。结构体对齐、padding、Vec 里的元素大小、指针集合的离散分布,照样会把热路径拖下缓存。

“天下熙熙,皆为利来。”放到这里也成立:抽象为可维护性带来收益,但硬件会按字节收成本。收益是真的,成本也是真的。

别迷信 SoA,先把热路径量出来

我不太买账的一种读法是:看完这类文章,就把 SoA 当性能银弹。

SoA 不是永远更快。业务经常围绕单个对象读多个字段时,AoS 很自然,也可能更省事、更局部。比如一次更新同时需要位置、速度、血量、状态,字段被一起使用,拆得太散反而增加管理成本。

SoA 更稳定占优的地方,是字段访问集中、批处理明显、热路径固定。比如批量过滤 alive、列式计算、向量化处理、实体组件系统、高性能服务里的紧凑扫描。

真正该改的不是信仰,而是排查习惯。

可以按这个顺序做:

要做什么看什么信号可能动作
找热路径profiler 里耗时最高的循环、函数、请求路径不先改全局结构,只盯最热的 5%
看访问字段循环里到底读了哪些字段热字段集中时,考虑拆列或压缩结构体
估工作集元素大小 × 活跃元素数判断是否会跌出 L1/L2/L3
验缓存问题cache miss、load latency、IPC、perf/VTune 等硬件计数器用数据确认,不靠感觉重构
小步改布局AoS、SoA、AoSoA、字段重排、冷热拆分保留可读性边界,避免全项目拆迁

最现实的动作很朴素:不要一上来重构整个对象模型。先量热路径。再看字段访问。再算工作集。最后用硬件计数器验证。

如果只是普通 CRUD,数据量小,热点不明显,强行 SoA 多半是给团队添乱。可如果是在游戏主循环、行情撮合、日志扫描、图计算、向量检索前处理、低延迟服务里,继续把所有状态塞进大对象,就不是抽象优雅,是把账单推给 CPU。

这也是我对这篇文章的核心判断:现代性能问题很多不输在算法课,而输在工程师没把内存当成有层级的系统。

早期铁路最贵的不是车头,而是轨道怎么铺。站点、线路、调度方式一旦定下来,后面几十年的运输成本都被写进地面。代码里的数据布局也类似。API 名字能改,方法能挪,内存布局一旦被对象模型和业务假设钉死,再想动就不是优化,是拆迁。

模型看着优雅,数据在内存里站得很散。代码看着抽象,硬件在下面一趟趟搬空箱子。

开头那个 1B 和 64B 的差距,就是整件事的缩影。每个 byte 都重要,不是因为工程师该迷信底层,而是因为机器从来没有免费读过你的对象。