低延迟系统开发#6

内存对齐、布局与缓存和 DRAM 的几何

为什么热路径上的每个内存决定,最后都归结为一次访问碰哪几条缓存行、这些行住在哪。分三部分:对齐(填充、alignas、堆上的过对齐、两档分配器、pack),布局(AoS/SoA、代理视图、去规范化、字段顺序、字符数组、函数分组),硬件几何(从零讲组相联与关键步长、M1 实测、存储类别、内存条内部、刷新、多通道)。

约 32 分钟
本页目录

做低延迟的人常把“缓存友好”挂在嘴边,但一段代码到底友不友好,最后都要落到很具体的问题上。先从一个例子谈起:

int m[64][1024];             // 64 行,每行 1024 个 int
long long sum = 0;
for (int j = 0; j < 1024; ++j)
    for (int i = 0; i < 64; ++i)
        sum += m[i][j];      // 按列把整个矩阵加一遍

问题来了:把每一行从 1024 个 int 改成 1040 个——多出来的 16 个 int 一个都不读,循环次数一次都没变——速度会怎样?

直觉是“不变,甚至略慢,毕竟数组变大了”。实际上它往往会快好几倍。我在 M1 上做了同样的实验(M1 上对应的行宽是 16 KiB,原因后面讲),每行多填一条缓存行的版本,比不填的快了 4 倍。

多出来的字节根本不读,怎么会让程序变快?答案不在算法里,而在地址里。CPU 永远按整条 64 字节的缓存行搬数据;而地址的某几位,决定了这条行进缓存的哪个格子、住在内存条的哪个角落。这篇从对齐讲起,一路往下讲到 DRAM,讲完就能回答这个问题(答案在 3.2.1)。

先认一下路。一次 load 从上往下走:TLB 先把虚拟地址翻译成物理地址,然后依次问 L1D、L2、全核共享的 L3,都没有,就经内存控制器去内存条。后面讲的每一件事,都发生在这条路上的某个框里;整张硬件地图放在速查页,随着文章一起更新。

所以“这段代码快不快”,很大程度上是两个问题。第一,一次访问要碰几条行:一个值跨了两条行,就是两次;一个循环只用每条行的三分之一,就白搬了三分之二。这由对齐(第 1 部分)和布局(第 2 部分)决定,是写代码时能直接控制的。第二,这些行住在哪:访问模式要是总让地址的某几位相同,硬件就有一大半闲着、另一小半挤爆。这是第 3 部分,也是平时最容易看不见的那一层。

1. 对齐:让一个值待在一条行里

1.1 对齐是什么,CPU 为什么在乎

对齐(alignment)是说:一个值的起始地址必须是某个数 N 的整数倍,叫“按 N 对齐”;C++ 里 alignof(T) 就是类型 T 要求的那个 N。x86-64 上基本类型的对齐等于它自己的大小:char 是 1,int 是 4,double 和指针是 8。

CPU 在乎它,是因为缓存行。行的起点都是 64 的倍数,而 64 是 8 的倍数——这是关键的一句。一个按 8 对齐的 double,起点是 8 的倍数,所以在一条行里只可能从偏移 0、8、16……56 开始;最后一个 56 到 63 正好塞满。它不可能横跨两条行。int 按 4 对齐同理(64 也是 4 的倍数)。反过来,一个 double 要是落在偏移 60,前 4 字节在这一条行、后 4 字节在下一条,读它就得碰两条行。

横跨两条行,后果分一轻一重:

  • 轻的:变慢。 一次 load 要拆成两次缓存访问,叫行分裂(line split);两条行如果还分处两个 4 KiB 页,还要多一次地址翻译。x86 不会因此崩溃,只是悄悄变慢——所以对齐问题在 x86 上很难靠“崩了”发现。
  • 重的:原子操作。 原子读改写(lock 前缀的指令)之所以不会被打断,靠的是“指令做完之前一直独占这一条缓存行”(MESI 缓存一致性)。数据横跨两条行,一条行独占不住,CPU 只能退化成锁住整个内存总线,叫 split lock:比普通原子操作贵得多,还会拖住别的核;Linux 5.7 起内核能检测并报告它(split_lock_detect)。

还有一类是硬性要求:SIMD 的“对齐版” load/store(SSE 的 movaps 之类)要求地址 16 字节对齐,不满足直接崩。

最后要小心一个推广。“按自己的大小对齐就不跨行”,只对大小是 2 的幂、且不超过 64 的值成立。一个 12 字节、alignof 为 4 的结构体,起点只要求是 4 的倍数,落在行内偏移 56 或 60 就会横跨——把行内 16 个可能的起点枚举一遍,有 2 个跨行。把它 alignas(16)、补到 16 字节,4 个可能的起点一个也不跨。所以结构体会不会跨行,看的是它的大小和你给它的对齐,不是“对齐了”三个字。

1.2 编译器怎么做到:填充

编译器摆一个结构体的成员,规则三条:

  1. 成员按声明顺序从上往下摆,每个成员放在“当前位置往后、第一个是它自己对齐的倍数”的地址,中间的空隙塞填充字节(padding);
  2. 整个结构体的对齐 = 成员里最大的那个对齐;
  3. sizeof 最后补到自己对齐的倍数(尾部填充)。
struct DefaultAlignedStruct {
    char a;      // 偏移 0,之后 3 字节填充
    int b;       // 偏移 4
    double c;    // 偏移 8
};               // sizeof 16,alignof 8
static_assert(sizeof(DefaultAlignedStruct) == 16 && alignof(DefaultAlignedStruct) == 8);

走一遍:a 放偏移 0;b 要 4 的倍数,下一个是 4,所以 1–3 是 3 字节填充;b 占 4–7;c 要 8 的倍数,8 正好,占 8–15;结尾 16 已经是最大对齐 8 的倍数。

第 3 条为什么要有:因为数组。 T arr[N] 的第 i 个元素在 基址 + i × sizeof(T)。如果 sizeof 不是 alignof 的倍数,第 0 个元素对齐了,第 1 个就错位了。比如 {double; int;} 数据到 12 结束,不补到 16 的话,arr[1] 的 double 就落在偏移 12。所以 sizeof 永远是 alignof 的倍数,哪怕最后一个成员没占满。

成员的顺序会改变大小:

struct Bad  { char a; double b; char c; int d; };   // 0、8、16、20 → sizeof 24(11 字节填充)
struct Good { double b; int d; char a; char c; };   // 0、8、12、13 → sizeof 16
static_assert(sizeof(Bad) == 24 && sizeof(Good) == 16);

Bad:a@0;b 要 8 的倍数,跳到 8(填 7 字节);c@16;d 要 4 的倍数,跳到 20(填 3 字节);结尾 24。Good:b@0,d@8,a@12,c@13,结尾 14,尾部补到 16。同样四个成员,一个 24 字节、一个 16 字节。经验:同一个结构体里,按对齐从大到小排,填充最少。 放进数组就是实打实的差别:一条行放得下 64 ÷ 24 ≈ 2.7 个还是 64 ÷ 16 = 4 个,扫一遍数组要碰的行数差三分之一。

不过这只解决“省字节”。热路径上的结构体还要问“哪些成员总是一起被访问”——那是第 2 部分的事,两个目标冲突时怎么取舍,在 2.4 讲。

1.3 alignas:主动把对齐调大

alignas(N) 让你提出比默认更高的对齐要求。写在类型上和写在变量上,效果不一样:

struct alignas(32) AlignasType { int data[5]; };    // 数据 20 字节
static_assert(sizeof(AlignasType) == 32 && alignof(AlignasType) == 32);

alignas(64) char buffer[100];                       // 这个变量从一条行的起点开始
static_assert(sizeof(buffer) == 100 && alignof(decltype(buffer)) == 1);
  • 写在类型上:数据只有 20 字节,但类型的对齐变成 32,sizeof 按 1.2 第 3 条补到 32。这个类型的每个对象都从 32 的倍数开始、占 32 字节。
  • 写在变量上:只有这个变量的地址是 64 的倍数,sizeof 还是 100;类型 char[100] 自己的对齐没变。

N 取 64 最常见:对象从一条缓存行的起点开始;只要它的 sizeof 又是 64 的倍数(64、128、192……,即整数条行),它就独占自己占的那几条行,邻居的写不会和它抢同一条——这是拆伪共享(#2)用的办法,也是 MESI“单写者 / 多读者”规则在内存布局上的落地。

“sizeof 是 64 的倍数”是什么意思? 是的,对象可以比一条行大。这句话要的不是“对象大”,而是“对象的两头都落在行的边界上”:起点靠 alignas(64),终点靠 sizeof 是 64 的倍数。否则最后一条行只用了一部分,剩下的空位可能被后面的东西占走,两边的写就在这条行上抢起来。

类型上写 alignas(64) 时,编译器已经替你补好了:struct alignas(64) { char x[100]; } 是 128,占两整条行。真正要自己管的是成员上的 alignas(64)——它只管起点,不改 sizeof:

struct Two   { alignas(64) char a[100]; char b; };              // b 在偏移 100,和 a 的尾巴共用一条行
struct Three { alignas(64) char a[100]; alignas(64) char b; };  // b 在偏移 128,独占一条行
static_assert(offsetof(Two, b) == 100 && offsetof(Three, b) == 128);
Two:   alignas(64) char a[100]; char b;          Big:  struct alignas(64) { char x[100]; }
行 0   a[0..63]                                   行 0   x[0..63]
行 1   a[64..99] | b@100 | 空                     行 1   x[64..99] | 28 字节填充      ← 整条行都是它的
       ↑ a 的尾巴和 b 共用这条行                   行 2   下一个对象从这里开始

想可移植地拿到“行大小”,用 C++17 的 std::hardware_destructive_interference_size(<new>,标准库提供时)。代价也要说清:alignas(64) 会把一个 4 字节的计数器撑成 64 字节。这是主动拿空间换隔离。

1.4 堆上的对齐:malloc 和普通 new 不管你要 64

栈上和全局的对象,alignas 一写,编译器就替你办了。堆上不一样:malloc 和普通 new 只保证对齐到“任何基本类型都能放”的程度,x86-64 上是 16(alignof(std::max_align_t))。要 64 或更大,得换接口。

std::aligned_alloc(alignment, size)。 规则是 size 必须是 alignment 的整数倍,所以要先把请求向上取整:200 取到 64 的倍数是 256,300 是 320。不取整的后果是分配失败、返回空指针,所以要检查返回值。

#include <cstdlib>

void* make_buffer() {
    const size_t alignment = 64, requested = 300;
    size_t size = (requested + alignment - 1) / alignment * alignment;   // 320
    return std::aligned_alloc(alignment, size);                          // 用 std::free 释放
}

(Windows/MinGW 上没有 std::aligned_alloc,对应的是 _aligned_malloc,必须配 _aligned_free。)

对齐的 new(C++17)。 类型自己的对齐超过默认值(16)时,new T 自动改走 operator new(size_t, std::align_val_t),delete p 也自动走配套的释放函数——struct alignas(64) X 直接 new、普通 delete,没问题。

坑:对齐只写在 new 上,delete 不会跟着配对。

struct Plain { float v[4]; };                              // 类型自己只要求 4
auto* p = new (std::align_val_t{128}) Plain();             // 分配:带对齐的版本
delete p;                                                  // 释放:普通版本 —— 不配对,未定义行为

new 里显式传了 128,分配走带对齐的版本;可 delete p 看的是类型的对齐(没超过默认),走的是普通的 operator delete。分配和释放不是一对。实测在 Windows(g++,MinGW-w64)上这样写直接崩,退出码 0xC0000374(堆损坏)——很可能是带对齐的分配走 _aligned_malloc、普通 delete 走 free,两边不是一家。规则:要超过类型自己的对齐,要么把对齐写进类型,让 new/delete 自动配对;要么两边都手动用带 align_val_t 的版本:p->~Plain(); ::operator delete(p, std::align_val_t{128});。

1.5 两档对齐的分配器

一个生产用的 STL 分配器把上面这些封装起来,核心是这段分配逻辑:

#include <cstdlib>
#include <cstring>
#include <new>
#include <sys/mman.h>

void* allocate_bytes(size_t num_bytes) {
    void* ptr = nullptr;
    if (num_bytes <= (1 << 14)) {                        // 不超过 16 KiB:按缓存行对齐
        const size_t a = 64;
        size_t sz = (num_bytes + a - 1) & ~(a - 1);
        if ((ptr = std::aligned_alloc(a, sz))) std::memset(ptr, 0, sz);
    } else {                                             // 更大:按 2 MiB 对齐
        const size_t a = 1 << 21;
        size_t sz = (num_bytes + a - 1) & ~(a - 1);
        if ((ptr = std::aligned_alloc(a, sz))) {
#if defined(__linux__)
            madvise(ptr, sz, MADV_HUGEPAGE);             // 必须在第一次碰这块内存之前
#endif
            std::memset(ptr, 0, sz);
        }
    }
    if (!ptr) throw std::bad_alloc();
    return ptr;
}

按请求大小分两档:

  • ≤ 16 KiB:64 字节对齐,大小取整到 64 的倍数,清零。每次分配都从一条缓存行的起点开始、占整数条行,所以相邻两次分配出来的对象不会挤在同一条行里。
  • > 16 KiB:2 MiB 对齐,大小取整到 2 MiB 的倍数,Linux 上调 madvise(MADV_HUGEPAGE),然后清零。

取整用的掩码为什么成立。 (n + a - 1) & ~(a - 1):a 是 2 的幂时,a - 1 的低位全是 1,~(a - 1) 把低位清零,等于“向下取整到 a 的倍数”;先加 a - 1 再清,就成了向上取整。只对 2 的幂成立:拿 a = 48 对照除法版枚举 0 到 10 万,有 66,672 个数结果不同(第一个是 n = 1:除法版 48,掩码版 16);a = 64 是 0 个不同。这和环形队列容量取 2 的幂、用 & mask 代替 %(#4)是同一个原理。

为什么大块要对齐到 2 MiB。 2 MiB 是 x86-64 大页的大小,内核的透明大页(THP)只能把“按 2 MiB 对齐、长 2 MiB”的区间映射成一个大页。起点没对齐,头尾不足一个大页的部分只能用 4 KiB 页,TLB 的收益就打折;整块对齐到 2 MiB,整块都有资格。

madvise 为什么必须在 memset 前面。 页是第一次被碰的时候才分配的,内核在那一刻看这段地址上有没有“想要大页”的标记,来决定给一个大页还是 4 KiB 页。先清零、后 advise,页已经是小页了,小页不能原地变大页,只能等后台线程 khugepaged 以后复制合并——那时应用侧看到的就是一次不可预测的停顿。清零还有一个副作用:把每一页的首次缺页(在 #5 实测约 1.5 µs 一页)在分配那一刻付清,而不是留到热路径第一次用它的时候。

这个设计的两笔代价:

  1. 大档的空间放大。 请求 20 KiB 就进大档,取整到 2 MiB,清零 2 MiB——一个 20 KiB 的容器物理上占 2 MiB。它适合“少量大数组”,不适合大量中等大小的容器。
  2. THP + madvise 依赖系统开关。 HFT 实际更常用预留好的显式大页(mmap(MAP_HUGETLB)),并把 THP 关掉。系统 THP 开关是 never 时 madvise 不起作用。被问“分配器怎么拿大页”时要说清:显式大页更确定,THP + madvise 方便但依赖系统配置。

1.6 #pragma pack:反过来放弃对齐

#pragma pack(push, 1)
struct PackedStruct { char a; int b; double c; };   // 偏移 0、1、5;sizeof 13,alignof 1
#pragma pack(pop)
static_assert(sizeof(PackedStruct) == 13);

#pragma pack(push, 1) 告诉编译器“成员之间不许塞填充,对齐降到 1”。它把 1.1 的好处全反过来:b 和 c 不再落在自己对齐的地址上,随结构体在行里的位置不同,随时可能横跨两条行;数组里每个元素 13 字节,起点一路漂移,总有一部分横跨。对 c 取地址得到的是没对齐的 double*,按标准解引用它是未定义行为;x86 硬件不崩,别的架构可能崩。编译器对此不提醒:取 &p.b 转 int*、&p.c 转 double*,-Wall -Wextra -Wpedantic 下没有任何警告。在这类成员上做原子操作,还可能撞上 split lock。

那它有什么用?让结构体的字节布局和外部格式逐字节一致——网络协议报文、文件格式。它是“描述外面世界的布局”的工具,不是“省内存”的优化手段。读这类数据时,先把字段 memcpy 到对齐的局部变量里再用,编译器会把固定大小的 memcpy 优化成一条 load。


2. 布局:一次访问碰几条缓存行

布局优化问的是同一件事:这一次访问,要碰几条缓存行。 数据越紧凑地挤在要访问的那几条行里,碰的行越少。

2.1 AoS 还是 SoA:看一次循环读哪些成员

struct Point1 { float x, y, z; };
Point1 points1[1000];                                  // 结构体数组(AoS)

struct Point2 { float x[1000], y[1000], z[1000]; };
Point2 points2;                                        // 数组结构体(SoA)

AoS(array of structures)是数组的每个元素是一个完整结构体,同一个点的 x、y、z 相邻。SoA(structure of arrays)是每个成员各是一整条数组,所有 x 相邻。

只求所有 x 的和,两种布局拖进缓存的数据不一样。AoS 里每个元素 12 字节,x 只有 4 字节,另外 8 字节(y、z)跟着整条行被拉进来却用不上:1000 个点是 12,000 字节,要碰 188 条行。SoA 的 x 数组是 4,000 字节,63 条行。差三倍,就是“有用字节占 1/3”。

反过来,同一个点的 x、y、z 要一起用(比如算 x²+y²+z²),AoS 一个元素就在 1 条行里(最多 2 条),SoA 的 x、y、z 在三个数组里相隔 4,000 字节,要碰 3 条行。规则就是这个对称关系:同一个元素的所有成员一起用 → AoS;只用部分成员扫一遍 → SoA。

两处要小心:

  • 12 字节不是 2 的幂,AoS 回到了 1.1 的推广。 Point1 的 alignof 是 4,数组首地址 64 对齐时,每 16 个元素里有 2 个横跨两条行。SoA 的 float 数组 16 个一行,一个都不跨。
  • 这个例子只有 12 KB,放得进 L1D(常见 32 KiB)。 第一遍扫描要把 188 或 63 条行拉进来,第二遍起数据都在 L1 里,行数差不会变成耗时差。差距在工作集远大于缓存(每一遍都要从 L2/L3/内存重新拉行,带宽成了瓶颈)或者向量化时才显现:SoA 的 x 连续,一条 AVX 指令装 8 个 float,AoS 要隔着 y、z 一个个挑。

SoA 的代价:写法从 points[i].x 变成 x[i];增删元素要同时改 N 个数组,长度必须一起维护;按下标取一个完整元素要碰多条行。

2.2 AoS 视图 + SoA 存储:代理对象

想要 SoA 的内存布局,又不想让调用代码满是 x[i],就在 SoA 上面套一层“看起来像 AoS”的接口:

#include <cstddef>
#include <vector>

class ParticleSoA;
struct ParticleRef {                                   // 一个"虚拟元素":存储 + 下标
    ParticleSoA* storage;
    size_t index;
    float& x() const;
    float& vx() const;
};

class ParticleSoA {
    std::vector<float> x_, vx_;
    friend struct ParticleRef;
public:
    explicit ParticleSoA(size_t n) : x_(n), vx_(n) {}
    size_t size() const { return x_.size(); }
    ParticleRef operator[](size_t i) { return {this, i}; }
    std::vector<float>& x_array()  { return x_; }      // 热循环直接拿底层数组
    std::vector<float>& vx_array() { return vx_; }
};

inline float& ParticleRef::x()  const { return storage->x_[index]; }
inline float& ParticleRef::vx() const { return storage->vx_[index]; }

void step(ParticleSoA& p) {
    p[0].x() = 10.0f;                                  // 写起来像 AoS
    auto& x = p.x_array(); auto& vx = p.vx_array();
    for (size_t i = 0; i < p.size(); ++i) x[i] += vx[i];   // 跑起来是 SoA
}

要点:

  • operator[] 返回的代理对象只有“存储指针 + 下标”两个字段(16 字节),按值传很便宜。p[0].x() 返回的是底层数组元素的引用,所以通过代理写,改的是真数据——这是代理的设计,不是 bug。
  • 上层代码可读性像 AoS,内存布局是 SoA。真正追求性能的循环走第二种写法,拿到连续的 float 数组,才吃得到 SoA 的缓存和向量化收益——代理那一层每次访问都多一次下标计算,编译器能不能优化干净,看内联情况。

2.3 去规范化:用冗余换“少几次查找”

规范化是每份信息只存一处,用 id 去连接(像数据库)。检查一笔订单的风控上限:

#include <cstdint>
#include <unordered_map>

struct RiskProfile { uint32_t max_order_size; double max_position_value; };
struct Client      { uint32_t client_id; uint32_t risk_profile_id; };
struct Order       { uint64_t order_id; uint32_t client_id; uint32_t quantity; double price; };

std::unordered_map<uint32_t, RiskProfile> risk_profiles;
std::unordered_map<uint32_t, Client>      clients;
std::unordered_map<uint64_t, Order>       orders;

bool check_risk_normalized(uint64_t order_id) {
    const auto& order  = orders.at(order_id);                      // 可能的缺失 #1
    const auto& client = clients.at(order.client_id);              // 可能的缺失 #2:要等 #1 的结果
    const auto& risk   = risk_profiles.at(client.risk_profile_id); // 可能的缺失 #3:要等 #2 的结果
    return order.quantity <= risk.max_order_size;
}

这段代码的代价比“三次缺失”更重,原因有两个:

  • 三次查找互相依赖。 第 2 次的 key 要等第 1 次把 order.client_id 读出来才知道,第 3 次的 key 要等第 2 次。乱序执行只能把互相独立的访问重叠起来;这条链每一步的地址取决于上一步读到的数据,只能等一步、做一步。所以三次缺失的延迟是相加,不是取最大。这种“读出一个地址、再去读那个地址”的模式叫 pointer chasing。
  • 每次 unordered_map::at 自己就不止一次访存。 libstdc++ 的 unordered_map 是桶数组加上每个元素单独分配的节点:先读桶拿到节点指针,再读节点。“一次查找一个缺失”是低估。

去规范化是把后面要用的字段直接拷进订单:

struct EnrichedOrder {
    uint64_t order_id;
    uint32_t quantity;
    double   price;
    uint32_t client_id;
    uint32_t max_order_size;        // 从风控参数拷来
    double   max_position_value;    // 从风控参数拷来
};                                  // 40 字节,原来是 24
std::unordered_map<uint64_t, EnrichedOrder> enriched_orders;

bool check_risk_denormalized(uint64_t order_id) {
    const auto& o = enriched_orders.at(order_id);   // 唯一的一次查找
    return o.quantity <= o.max_order_size;
}

检查时只查一次,之后读的都是刚拉进来的那几条行。代价是冗余要同步。风控参数一改,有两条路:

  • 立刻同步所有副本:一次改动要改很多条订单(写放大),还要处理改到一半时的一致性。
  • 容忍副本过期:已有订单保留创建时的参数(快照语义),只有新订单用新值。没有写放大,代价是旧订单的参数可能是旧的。

所以判断标准不是“参数改得频不频繁”本身,而是副本要不要立刻同步、副本有多少。要求立刻同步、订单量又大(每秒几百万条),去规范化就把成本从读搬到了写,不适合;业务上能接受快照语义,它就仍然划算。

2.4 字段顺序和 alignas(64)

一个按“访问频率”从最热排到最冷的订单结构体:

struct alignas(64) OptimalOrder {
    uint64_t price;        // 最常访问
    uint32_t quantity;
    uint32_t orderId;
    uint64_t timestamp;    // 较少访问
    char symbol[8];        // 最少访问
};                         // 字段共 32 字节;因为类型上写了 alignas(64),sizeof 是 64
static_assert(sizeof(OptimalOrder) == 64);

字段加起来 32 字节,但类型上写了 alignas(64),按 1.3,sizeof 被补到 64:数据到偏移 32 结束,后面 32 字节是尾部填充。想要的效果不同,写法不同:

  • 一条行放两个、都不跨行:alignas(32)(sizeof 32)。如果干脆不写 alignas,类型 alignof 只有 8,能不能不跨行取决于数组首地址:落在 64 的倍数就不跨,落在偏移 16(malloc 只保证 16)就是每 2 个里有 1 个跨行。所以“32 字节恰好半行”成立的前提是首地址 32 对齐,这要靠 alignas(32) 或分配器给。适合一个线程顺序扫、或多线程只读:两个对象共用一条行没有害处,还省一半的行。
  • 每个订单独占一条行:alignas(64)。 适合不同线程各写各的订单(拆伪共享)。伪共享只在有写时才出现,多线程只读同一条行没问题。代价是每条行一半是填充,按顺序扫要多拉一倍的行。

再看“按访问频率排字段”。结构体不超过一条行时,字段在行里怎么排,都是同一次访问:整条行一起搬进缓存(行是最小搬运单位,不是字),所以 32 字节的结构体里排序基本不影响性能。排序真正有用,是结构体大于 64 字节:最热的几个字段要挤在第一条行里,冷字段放后面,热路径只碰一条行,冷的永远不被拉进来。

它和 1.2 的“按对齐从大到小排,填充最少”会冲突:一个要省字节,一个要热字段聚集。取舍:先保证热路径只碰一条行,再在这个约束里按大到小排去减填充;热路径上省 8 字节不如少碰一条行。冷字段很多时更彻底的办法是热冷拆分——冷字段搬到另一个结构体,热结构体里只留热字段。

2.5 字符数组 vs 指针

struct First  { int nIndex; char Name[32]; };          // sizeof 36,alignof 4
struct Second { int nIndex; const char* Name; };       // sizeof 16,alignof 8
static_assert(sizeof(First) == 36 && sizeof(Second) == 16);

用缓存行比较:

  • 只扫 nIndex:Second 一条行装 4 个;First 36 字节,64 不整除 36,每 16 个元素里有 8 个横跨两条行,而且每个元素 32 字节你根本不读也被拉进来。Second 紧凑得多。
  • 要读名字:First 的名字就在同一个对象里,零次额外跳转;Second 要沿指针去别处,一次依赖访存。字符串是字面量时,它们在只读数据段里连在一起,命中概率高;每个都单独 new、散在堆里,就是一次缺失。
  • const char* 不拥有字符串,生命周期由别人管,只适合指向常量数据。(std::string 另有小字符串优化,短串字节放在对象内部,和 First 的思路一样。)

2.6 函数分组:代码也走缓存

代码也在内存里,也要进缓存——指令缓存(L1I,通常 32 KiB)和指令 TLB。互相调用的函数放得近,热路径的代码就聚在更少的页、更少的行里。现在多靠编译器和链接器做:GCC 的 -freorder-functions 配合 __attribute__((hot)) / ((cold)),把热函数放进 .text.hot、冷的放进 .text.unlikely;PGO(按真实运行的剖面重排)和 BOLT 这类链接后优化也做这件事。收益在热路径的代码总量超过 L1I 时才显现;热路径只有几 KB 时,代码早就全在指令缓存里了。


3. 硬件几何:这些行住在哪

前两部分问的是“一次访问碰几条行”。这一部分往下一层:这些行在缓存里放在哪、在内存条上住在哪。 地址的某几位决定了它进缓存的哪个组、落到内存的哪个通道和 bank;访问模式如果总让这几位相同,硬件就有一大半闲着,另一小半挤爆。数字以典型 x86(Zen 3:L1D 32 KiB 8 路、L2 512 KiB 8 路)和 DDR4 为例。

3.1 组相联:地址的几位决定一条行能放在哪

3.1.1 在哪、要解决什么问题

在硬件地图上,这是“L1D 32 KiB · 8 路”那个框的内部;L2、L3 框里面也是同样的结构,只是尺寸不同。发生的时刻是一次 load 的第一步:CPU 拿着地址去 L1D 问“你有没有这条行”——组相联就是 L1D 怎么回答这个问题。

L1D 是一个 64 行 × 8 格的柜子:地址拆成 tag、组号和行内偏移;组号选中一行,8 个 tag 并行比较

前置一:缓存是一个柜子,每格放一整条缓存行。 L1D 32 KiB,一条行 64 B,一共能放 512 条行。每一格除了 64 B 数据,还记着“这是哪条行”的标签(tag),否则拿出来也认不出是谁。

前置二:地址怎么拆。 地址就是一个数。拿 10000 举例:行内偏移 = 10000 mod 64 = 16(要的字节在这条行的第 16 个字节);行号 = 10000 ÷ 64 = 156(它属于内存里的第 156 条行)。64 是 2⁶,所以“除以 64”就是去掉二进制最低 6 位,“mod 64”就是只留最低 6 位。10000 的二进制是 10 011100 010000,最低 6 位 010000 = 16,正是行内偏移。

要解决的问题:来了一个行号,去柜子的哪一格找? 三种做法:

  • 512 格随便放:每次读都要把 512 个 tag 比一遍,1 ns 内做不完。
  • 每个行号只能放固定的一格:两条常用的行偏偏分到同一格,就会互相踢个不停。
  • 折中就是组相联:把 512 格排成 64 组 × 8 路。行号决定去哪一组(组号 = 行号 mod 64),在这一组的 8 路里随便放。查的时候只比这一组的 8 个 tag,8 个比较器同时比。

这就是计算机组成原理课本里“主存到 cache 的三种映射”,只是换了说法:

课本名称柜子怎么排问题
直接映射512 组 × 1 路两条热行分到同一格就互相踢
全相联1 组 × 512 路每次比 512 个 tag,太慢
组相联64 组 × 8 路两者的折中;前两者是它路数取两头的特例

课本的“主存块”就是缓存行,“组号 = 主存块号 mod 组数”就是“组号 = 行号 mod 64”,“标记”就是 tag,“抖动/冲突”就是下面的冲突缺失。课本只画一层 cache,真实机器每一层都有自己的一套映射,拆的都是同一个地址。全相联也不只在课本里:L1 TLB 这类条目很少的小表常做成全相联,条目少,才能同时比完全部 tag。

3.1.2 一次查找的三步

继续拆 10000:组号 = 156 mod 64 = 28,也就是二进制再往上 6 位 011100;剩下的高位 10 = 2 就是 tag。一个地址拆成三段:tag(bit 12 以上)| 组号(bit 6–11)| 行内偏移(bit 0–5)。

  1. 用组号 28 直接走到第 28 组(算出来的,不用找);
  2. 这一组 8 格的 tag 同时和 2 比较;
  3. 有一格对上就命中,取第 16 个字节起的数据;都对不上就缺失,去 L2 拿整条行回来,放进第 28 组的某一格,8 格都满就踢掉最久没用的那一格。

3.1.3 关键步长从哪来

换个地址 10000 + 4096 = 14096,二进制 11 011100 010000:组号还是 011100 = 28,只有 tag 变成 3。地址加 4096 只改了 bit 12 以上,组号不变,所以地址相差 4096 的倍数就落进同一组。一组只有 8 格,第 9 个这样的地址进来就得踢人,哪怕另外 63 组全空着。这叫冲突缺失(conflict miss),和“缓存装不下”的容量缺失是两回事。

这个距离叫关键步长(critical stride)= 缓存总大小 ÷ 路数 = 32 KiB ÷ 8 = 4 KiB。同理 L2(512 KiB、8 路):8192 条行 ÷ 8 = 1024 组,关键步长 64 KiB。L3 很多实现会把地址高位做哈希再选组和切片,规律没这么干净。

3.1.4 为什么 L1 的关键步长恰好是页大小

L1 要快,它在 TLB 翻译地址的同时就开始用虚拟地址选组(VIPT,虚拟地址索引、物理地址标记)。这要求“选组用的位”在翻译前后不变,也就是落在页内偏移里:4 KiB 页的页内偏移是 bit 0–11,正好装下行内偏移 6 位 + 组号 6 位。于是 L1 的“组数 × 行大小”被卡在 4 KiB,想做大只能加路数:32 KiB = 4 KiB × 8 路。苹果 M1 用 16 KiB 页,大核 L1D 128 KiB = 16 KiB × 8 路,是同一个约束。

3.2 2 的幂步长怎么把缓存挤爆

有了关键步长,开头那个问题就好回答了。规律只有一条:一组同时要用的数据,地址两两相差关键步长的整数倍,就全挤进同一个组。 它常见的样子有两种:一个数组内部(3.2.1,就是开头的矩阵),和多个数组之间(3.2.2)。另有一个名字相近、机制不同的 bank 冲突,放在 3.2.3。

3.2.1 例子一:按列走矩阵

回到开头的循环。int m[64][1024] 每行正好 4096 字节,按列走,每一步地址 +4096。

为什么慢。 按列走,每一步地址加 4096,64 个元素全部落在同一个组。理想情况下,第 0 列把 64 条行拉进来,第 1–15 列的元素就在同样这 64 条行里(一条行装 16 个 int),应该全命中——64 条行才 4 KiB,L1 有 32 KiB。可它们只能挤一个 8 路的组,同时只留得住最后 8 条,于是下一列几乎每次都缺失。

怎么改:

  • 填充,按整条缓存行。 int m[64][1040](1040 × 4 = 4160 = 4096 + 64)。第 i 行的起点比“4096 × i”多出 64 × i 字节,组号每行往后挪 1,64 行正好铺满 64 个组,一组一条。只加 1 个 int([64][1025],每行 4100 字节)也能打散,但每行只挪 4 字节,连续 16 行还在同一组,64 行挤进 4 个组、每组 16 条,仍然超过 8 路;而且行首不再按 64 对齐。
  • 分块(tiling)。一次只处理一小块(比如 8 行 × 16 列),在被踢出去之前就把一条行里的 16 个元素都用完。矩阵乘法、转置的标准做法。
  • 换遍历顺序。 能按行走就按行走。

在 M1 上实测。 先按 M1 的参数推预期:大核 L1D 128 KiB、页 16 KiB、缓存行 128 B(sysctl 读出);按 8 路算(公开规格),关键步长 = 128 KiB ÷ 8 = 16 KiB,不是 x86 的 4 KiB。所以行宽到 16 KiB 时 64 行全挤一组,应该最慢;再往上也是同一组,不会更慢。测试:64 行、只走前 1024 列,每种行宽访问次数相同,只改“相邻两行的地址差”;对照组每行多填 128 B。(Apple M1 MacBook Air,Apple clang 21,-O2,QoS 设为 user-interactive、未绑核;每格取 7 次里最快的,跑两遍,差别在 ±0.05 ns 内。)

行宽(相邻两行地址差)按列 ns/次同行宽 + 128 B 填充按行 ns/次
1 KiB0.250.180.05
2 KiB0.270.180.05
4 KiB0.400.190.05
8 KiB0.620.210.07
16 KiB(M1 的关键步长)0.810.200.06
32 KiB0.780.200.06

怎么读:

  • 填充的那一列几乎是平的(约 0.2 ns):组号被打散,64 条行分到不同组,一直留在 L1 里。
  • 不填充的那一列随步长变大而变慢,到 16 KiB 封顶:步长 4 KiB 时 64 行落进 4 个组,最多留 4 × 8 = 32 条,装不下 64 条;8 KiB 时 2 个组、16 条;16 KiB 起只剩 1 个组、8 条。32 KiB 和 16 KiB 一样慢,说明 16 KiB 已经是“同一组”——和推算的关键步长一致。
  • 按行走始终约 0.05 ns:一条行里的 32 个 int 连着用完,还能被预取和向量化。

为什么只慢 4 倍? 测量是可靠的(每次计时约 1300 万次访问),数字小是因为它是吞吐(总时间 ÷ 访问次数),不是一次缺失的延迟:

  • M1 上一次 L2 命中约 5 ns。这个循环每次读的地址都是算出来的,不依赖上一次读到的值,CPU 能让好几个缺失同时在路上;5 ÷ 0.81 ≈ 6,相当于平均六七个缺失重叠。
  • 矩阵最大 2 MiB,全在 12 MiB 的 L2 里,被踢出 L1 的行只掉到 L2,没掉到内存。

差距会大得多的条件:读和读之间有依赖(下一次的地址要等上一次读回来,如链表、2.3 的 pointer chasing),缺失不能重叠,每次付满延迟;冲突再让 L2 也装不下,就掉到内存(约 100 ns)。所以:独立的读,组冲突是“吞吐慢几倍”;有依赖的读,组冲突暴露完整延迟。HFT 热路径上的查表、查订单多是后一种。

3.2.2 例子二:多个页对齐的数组一起扫

float* a[10];                          // 十个各自单独分配的大数组
for (size_t i = 0; i < n; ++i)
    out[i] = a[0][i] + a[1][i] + a[2][i] + a[3][i] + a[4][i]
           + a[5][i] + a[6][i] + a[7][i] + a[8][i] + a[9][i];

为什么慢。 大块分配的起点常常按页对齐,或者至少起点的低 12 位都一样:比如 glibc 的 malloc 遇到大请求(默认 128 KiB 以上)直接向内核要页,返回“页起点 + 固定 16 字节头”。于是:

  • 第 k 个数组的 a[k][i] 地址 = 起点ₖ + 4i,起点ₖ 的低 12 位都一样;
  • 所以对同一个 i,十个元素地址的 bit 6–11(组号)完全相同,十条行全挤进同一组;
  • 一组只有 8 格,十条行轮流进来,第 9、10 条一来就踢掉最久没用的那条——而被踢的恰好是下一步马上要用的。这种“循环访问的行比格子多”的模式,最近最少使用(LRU)的替换规则会让每一次都缺失。

i 往后走也没用:i 加 1 只是在同一条行里往后挪 4 字节,十个数组一起挪,永远同组。

怎么改。 让第 k 个数组的起点多错开 k 条缓存行,组号就各不相同:

#include <cstdlib>

constexpr int kArrays = 10;
constexpr size_t kLine = 64;
void*  raw[kArrays];
float* arr[kArrays];

void allocate(size_t bytes) {
    for (int k = 0; k < kArrays; ++k) {
        raw[k] = std::aligned_alloc(4096, bytes + 4096);                            // 多要一页,留出错开的余量
        arr[k] = reinterpret_cast<float*>(static_cast<char*>(raw[k]) + k * kLine);  // 第 k 个数组错开 k 条行
    }
}
void release() { for (int k = 0; k < kArrays; ++k) std::free(raw[k]); }             // 释放用原始指针

在 M1 上编译运行:十个数组的起点对 4096 取模依次是 0、64、128……576,组号 0–9 各占一个。

3.2.3 旁支:L1 bank 冲突(机制不同)

L1 内部分成几个可以并行访问的 bank,同一个周期发出的两条 load 落进同一个 bank 就要排队。它和组冲突不是一回事:组冲突是“格子不够、互相踢”,bank 冲突是“同一周期抢同一个端口”。这高度依赖微架构,老的 Intel 核(如 Sandy Bridge)明显,新核大多已经改掉;遇到再查具体型号的优化手册。

3.3 变量放在哪,决定它和谁共享缓存

按存储类别,用缓存的眼光过一遍:

  • 栈:函数反复调用用的是同一片地址,这片地址一直热在 L1 里。局部变量天然局部性好。
  • 寄存器:编译器挑常用的局部变量放进寄存器,不碰内存。能帮它的写法是:别对局部变量取地址到处传(取了地址,编译器就不敢只放寄存器)。
  • 全局 / 静态:它本身不慢——热的全局变量一样在 L1 里;问题是被谁挨着:链接器按自己的规则排,两个不同线程各写的全局变量可能落在同一条行上,就是伪共享,需要 alignas(64) 隔开。
  • volatile:只是让编译器每次真的去读写内存,不合并、不省略。它不是线程同步工具,不提供原子性和顺序;线程间共享用 std::atomic。它的用途是内存映射的硬件寄存器、信号处理之类。
  • 线程局部(thread_local):每个线程一份,天然没有伪共享。可执行文件里定义并使用的,在 x86-64 Linux 上就是一条基于 fs 段寄存器的访存;在动态库里定义、按通用模型访问时,可能要调 __tls_get_addr 查一次。热路径上反复用,先拷到局部变量里。
  • 堆:分配释放本身开销大且时间不确定,多次分配的对象散在各处,局部性取决于分配器。HFT 的做法是启动时预分配、用内存池(#3)。

3.4 内存条里面:channel、rank、bank、row

缓存是柜子,内存条其实也是“柜子套柜子”:内存控制器把物理地址拆成几段,决定去哪个通道、哪个 bank、哪一行。先把图放在这里,下面一层一层拆开讲;读完会发现,最贵的情况是同一个 bank 里换行。

内存条内部:内存控制器连两个通道;一个 rank 是 8 颗并排的芯片;每颗芯片 16 个 bank;一个 bank 是一张行表加一个行缓冲,行命中约 14 ns、行空约 28 ns、行冲突约 41 ns

3.4.1 在哪

在硬件地图上是 L3 下面的“内存控制器”和“内存条”两个框。一次 load 在 L1、L2、L3 都没命中,请求交给内存控制器(它在 CPU 芯片里,不在内存条上),控制器再通过主板上的线去内存条取那一整条 64 B 缓存行。这一节讲的就是最后这一段:内存条内部怎么找到这 64 B。

3.4.2 最小单元:bank 和行缓冲

DRAM 的一个存储格只是一个很小的电容加一个开关,有电荷是 1,没电荷是 0。电荷太少,不能单独把一个格子读出来,所以 DRAM 的读法是:

  1. 一个 bank 就是一张大表,几万行,每行约 1 KiB(一颗芯片里的量)。
  2. 要读表里任何一个字节,先打开它所在的那一整行:把这一行所有格子的电荷同时接到一排放大器上,放大、锁存。这一排放大器就叫行缓冲(row buffer)。
  3. 然后按列号,从行缓冲里取出要的那几个字节。

每个 bank 只有一个行缓冲,所以同一时刻只能开着一行。想读同一个 bank 的另一行,得先把当前这行关掉(写回电容、放大器复位,叫预充电),再打开新行。可以把行缓冲想成书桌:书架(bank)上的书一次只能整层搬到桌上(开行),桌上只放得下一层;要另一层的书,先把桌上这层搬回去(关行)。

3.4.3 一次读的三种情况

开行、读列、关行,三个动作在 DDR4-3200 CL22 上各约 22 个时钟 × 0.625 ns ≈ 14 ns。一次读要做几个动作,看行缓冲里此刻是哪一行:

  • 行命中:要的那一行已经开着 → 只读列,约 14 ns。
  • 行空:这个 bank 没开任何行 → 开行 + 读列,约 28 ns。
  • 行冲突:开着的是同一个 bank 的另一行 → 关行 + 开行 + 读列,约 41 ns。

这只是内存条内部的时间。再加上控制器排队、片上互连、前面 L1/L2/L3 逐级查找缺失的时间,一次完整的内存访问通常 80–100 ns。

3.4.4 往上数:芯片 → rank → 通道

  • 芯片和 rank:一个通道的数据线是 64 位(8 字节)宽,一颗常见的芯片一次只出 8 位。所以 8 颗芯片并排,同一时刻各出 8 位,拼成 64 位——这一组一起响应的芯片就叫一个 rank。一条 64 B 缓存行是连续 8 拍 × 8 字节,每颗芯片各贡献其中 8 字节;读它时 8 颗芯片打开的是同一个行号、同一个列号。
  • bank 在哪:每颗芯片内部有 16 个 bank(DDR4)。不同 bank 可以各自开行、各自准备数据:bank 0 在开行的那 14 ns 里,bank 1 可以正在传数据。这让内存条内部能同时处理好几个请求。
  • 一根内存条上可以有 1–2 个 rank(常见是正反面各一组)。同一通道上的几个 rank 共用那 64 根数据线,同一时刻只能有一个在传,轮流用。所以通道和 rank 不是一一对应的:一个通道上可以插 1–2 根内存条、每根 1–2 个 rank,合起来就是一个通道挂 1–4 个 rank(上图通道 0 挂了 2 个)。
  • 通道(channel):每个通道是一套独立的 64 位数据线和命令线,通道之间同时各传各的。所以总带宽 ≈ 通道数 × 单通道带宽:台式机常见 2 个通道,服务器 6–12 个。通道多不会让单次访问变快,只是同时能跑的请求更多。

3.4.5 内存控制器怎么拆地址

和 L1 用 bit 6–11 选组(3.1)是同一个思路:内存控制器把物理地址拆成几段,分别决定哪个通道、哪个 rank、哪个 bank、哪一行、哪一列。规律是低位给列,所以连续的地址落在同一个 DRAM 行里,顺序访问一路吃行命中;通道和 bank 的位往往夹在中间,还会做哈希,让连续的大块数据均匀散到各个通道和 bank 上。具体哪几位是什么,因平台而异、厂商也不公开。

3.4.6 例子:按列走大矩阵,五层一起吃亏

int matrix[31250][2048];     // 每行 2048 个 int = 8 KiB,共 256 MB
long long sum = 0;
// 按行:地址连续,快
for (int i = 0; i < 31250; ++i)
    for (int j = 0; j < 2048; ++j)
        sum += matrix[i][j];
// 按列:每步跳 8 KiB,慢
for (int j = 0; j < 2048; ++j)
    for (int i = 0; i < 31250; ++i)
        sum += matrix[i][j];

matrix[i][j] 的地址 = 起点 + i × 8192 + j × 4。按行走,每步地址 +4;按列走,每步地址 +8192。从上往下,每一层都在吃亏:

  1. 缓存行只用了 1/16。 每一步都落在一条新的 64 B 缓存行上,但只用其中 4 字节。同一条行里另外 15 个 int 是第 j+1 … j+15 列的,要等走完一整列(31,250 步)之后才用得上。
  2. 到那时,这些行早被踢光了。 一整列碰过的行有 31,250 条、约 2 MB,本来就比 L1、L2 大;再加上 8 KiB 是 4 KiB 的倍数,一整列在 L1 里全挤进同一个组(3.1.3),在 L2(关键步长 64 KiB)里也只占得到 8 个组 × 8 路 = 64 条行。
  3. 每一步都换一个页。 步长 8 KiB 大于 4 KiB 页,一列碰 31,250 个页,TLB 只有一两千项,几乎每次都要查页表。
  4. 预取器帮不上。 硬件预取器一般只在一个 4 KiB 页内往前猜,不跨页。每一步都换页,它没机会提前取。
  5. 到了 DRAM,还是行冲突。 前四条把几乎每次访问都送到了内存条。相邻两次访问差 8 KiB,往往落在同一个 bank 的不同行上:每次都是“关行 + 开行 + 读列”,约 41 ns,而不是行命中的 14 ns。

对比按行走:连续 8 KiB 的数据大多落在同一个 DRAM 行里,开一次行就能连着读出很多条缓存行;每条缓存行的 16 个 int 全用上;预取器看出是顺序访问,提前去取;TLB 每 1024 个 int 才换一次页。所以按列慢是五层叠加,行缓冲只是最底下那一层。 改法还是 3.2.1 那几个:换遍历顺序(或先转置再按行走),或者分块。

3.5 DRAM 刷新:软件消除不了的毛刺

DRAM 用电容存数据,电荷会漏,控制器必须定期把每一行读出来再写回,叫刷新(refresh)。标准是 64 ms 内刷完所有行,分成 8192 批,所以大约每 7.8 µs 发一次刷新命令(tREFI);每次刷新期间,被刷的 rank 有几百 ns 不能访问(tRFC,8 Gb 的 DDR4 芯片约 350 ns)。

估一下:350 ÷ 7800 ≈ 4.5%。一次随机的 DRAM 访问,约有 4.5% 的概率撞上刷新、多等最多几百 ns(按全 bank 刷新算)。平均值上看不出来,但会出现在 P99 / P99.9 的尾延迟里。软件没法关掉它,只能绕:

  • 热数据集留在缓存里,热路径根本不去 DRAM——这是最有效的。
  • 用支持细粒度刷新(FGR)或按 bank 刷新的内存,把每次阻塞变短。
  • 测延迟时看 P99 / P99.9,看到周期约 7.8 µs 的毛刺要想到刷新。

3.6 多通道的负载均衡

多个通道要同时忙起来,带宽才能叠加。控制器把连续地址按某个粒度轮流分到各个通道上,叫通道交织(interleave)。用最简化的模型说明问题:粒度 64 字节,通道号 = (地址 ÷ 64) mod N。

假设 4 个通道,一个数组里每个对象 256 字节(4 条行),热路径只读每个对象的第一条行:

  • 第 i 个对象的第一条行是第 4i 条行,通道号 = 4i mod 4 = 0。所有对象的热行都在通道 0,另外 3 个通道闲着。
  • 对象大小按行数算,只要和通道数互质,热行就会轮流落到各个通道。把对象补到 5 条行(320 字节):第 i 个对象的热行是第 5i 条,5i mod 4 依次是 0、1、2、3、0……,四个通道轮着来。

规则是:对象大小按缓存行取整成 L 条行,再调整 L,让 L 和通道数 N 互质——N 是 2 或 4 时,就是让 L 为奇数。3 通道天然均衡,因为 2 的幂条行和 3 总是互质。

两点现实:

  • 真实的控制器大多不是简单取模,会把地址高位做异或哈希再选通道和 bank,目的就是打散这类规律,而且交织粒度因平台而异。手工填充之前,先确认平台的映射方式,再测。
  • 只有带宽受限的场景(大批对象扫描、热行分散在大工作集里)才值得想这件事。热数据集在缓存里的话,根本到不了通道这一层。

应用

1.3 节用 alignas(64) 让对象独占缓存行,是“B6 伪共享”这张卡片里的修法;卡片还写了在 perf 里怎么认出伪共享、怎么验证修好了。

B6 伪共享

  • 信号:c2c 某行 Tot Hitm > 5%,同一行里不同偏移被不同线程写;1 核时不显眼,多核才冒出来;以同一 socket 内的 HITM 为主。
  • 原因:缓存行(通常 64 字节)是一致性的最小单位。A 线程写字段 X,B 线程那份整行也失效,要重新从 L3 或内存读。
  • 修法分三步:
    • 先多垫 padding,把打架的字段推到不同的行。这一步只为证明诊断对,先不管省空间;
    • 用 c2c 复测,该行 HITM 降到 1% 以下、吞吐上来才算对。降不下来说明诊断错了:要么是真共享,要么还有别的字段卷进来;
    • 再按访问模式重排:每个线程常写的字段各占一行,多线程只读的字段可以共用一行,很少碰的字段填空位。
  • 更彻底的做法:把线程私有的字段挪进每线程一份的结构体。
  • 验证:c2c 前后对比;用 _Static_assert 守住结构体大小。
  • 原理:#2 第 2 节 伪共享;两个核各写自己的变量时,这条行的状态怎么来回变,见 MESI 专题第 6 节;让对象独占整条行的 alignas(64) 见 #6 1.3。