一个很反常的事实:你只读 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 都重要,不是因为工程师该迷信底层,而是因为机器从来没有免费读过你的对象。
