Contents

Memory Alignment Strategies From CPU Word Boundaries to Virtual Memory Paging

这篇笔记系统梳理了三层内存对齐与性能优化:编译器层面的结构体对齐与内存填充(4/8B),硬件层面的缓存行与伪共享(64B),以及操作系统层面的页与大页(4KB/2MB/1GB)。给出可操作的代码示例与实践清单,帮助在高性能与并发场景中稳定压低延迟、提升吞吐。


经常遇到系统突然卡顿、变慢的问题,也对这类问题背后的原理好奇,所以本期笔记系统整理了一下内存对齐与性能优化的知识。

我的小主机系统架构:

  • CPU 架构 x86_64, 13th Gen Intel(R) Core(TM) i9-13900HK
  • 6 P-core (大核心/性能核) + 8 E-core (小核心/能效核),其中大核超线程开启 总共 12+8 20核, L3 缓存 24MiB
  • 内存 16Gx2 DDR5 5200MHz

https://img1.kiosk007.top/static/images/blog/20251124001322-memory-alignment.png

我的Linux小主机上面运行了 VMware虚拟机、Docker、Jetbrains 等诸多应用,虽然硬件不弱,但是还是经常遇到卡顿等性能问题, 还是想深究一番。 比如最近装了一个 Windows11 虚拟机,真的非常卡!! 🤡

由此,我决定从硬件、编译器、操作系统三个层面,系统梳理一下内存对齐与性能优化的知识。


为什么“对齐”决定性能

在高性能系统中,内存不是中性载体,而是性能的第一现场, 是影响吞吐(Throughput)和延迟(Latency)的关键路径。CPU、缓存、OS 以不同粒度观察与操作内存:

  • 总线/字长:4/8 字节
  • 缓存行:64 字节(x86-64 常见)
  • 页:4KB(Linux 默认),以及 Huge Pages 2MB/1GB

忽视任一层的对齐与局部性,都会换来隐藏的延迟与抖动。

一、编译器层面:结构体对齐与内存填充(4/8B)

结构体大小通常≠字段大小之和。为了让 CPU 以对齐方式高效取数,编译器会对字段与整体大小进行填充(padding)。

示例:为什么 1 + 4 ≠ 5?

struct Demo {
    char a; // 1B
    int  b; // 4B
};

若 b 未按 4B 对齐,可能触发跨边界两次总线访问。编译器会在 a 后填充 3B,使 b 从 4B 边界开始。

为什么编译器需要做字段对齐?

  1. 硬件限制和性能提升

CPU 通常以其字长(Word Size) 或缓存行(Cache Line) 的整数倍来存取内存。

a. 满足硬件原子存取要求

  • 许多 CPU 架构(尤其是 RISC 架构,但现代 x86 也倾向于此)要求多字节数据类型(如 4 字节的 int 或 8 字节的 double)必须从其大小的整数倍地址开始存储。
    • 例如,一个 4 字节的 int 必须存储在内存地址 $0, 4, 8, 12$ 等位置。
    • 一个 8 字节的 double 必须存储在地址 $0, 8, 16, 24$ 等位置(即 8 字节对齐)。
  • 如果不满足对齐: 如果数据跨越了两个 CPU 字(或两个缓存行)的边界,CPU 必须执行两次内存读取操作,并在内部将这两部分数据合并。这不仅速度慢,而且操作复杂,可能导致硬件错误或非原子性 问题。

b. 提高缓存效率(Cache Line Alignment)

  • 现代 CPU 的缓存(L1, L2, L3)通常以 $64$ 字节的缓存行为单位进行数据传输。
  • 如果一个 8 字节的数据字段正好跨越两个缓存行的边界,那么加载这个字段需要加载两个完整的缓存行到 CPU,大大浪费了带宽和缓存空间。
  • 通过将数据对齐到合适的边界(通常是 $4$ 字节、$8$ 字节或 $16$ 字节),可以确保数据能够被一次性高效地加载到缓存中。
  1. 保证原子操作的正确性

在多线程编程中,某些数据类型(特别是 64 位整数或指针)的读写需要是原子性的,即操作要么完全成功,要么完全失败,不能被中断。

a. 如果 8 字节数据未对齐: 如果一个 64 位的数据跨越了 8 字节的边界,CPU 必须分两次 4 字节操作来读写它。在两次操作之间,另一个 CPU 核心可能修改了这部分数据,导致数据损坏或不一致(Tearing)。

b. 对齐的作用: 8 字节对齐保证了 CPU 硬件可以在一个总线周期内完成对该 8 字节数据的读写,从而确保了操作的原子性。

如何对齐结构体

```c
// ❌ 低效
struct Sparse {
    char c1; // 1B
    int  i;  // 4B,需要在 c1 后填充 3B
    char c2; // 1B,再次引入尾部填充
}; // 约 12B(有效仅 6B)

// ✅ 高效(大到小排列)
struct Packed {
    int  i;  // 4B
    char c1; // 1B
    char c2; // 1B
    // 尾部填充 2B 到 8B
}; // 约 8B

工程建议:大量实例的结构体按“从大到小”排序,整体更省内存、更 cache 友好。参考:The Lost Art of Structure Packing

语言差异要点(速览)

  • C/C++:遵循 ABI,无自动重排;含虚表或继承的布局需额外留意
  • Go:64 位字段在 32 位平台仅 4B 对齐;零长字段行为特殊
  • Rust:默认可能重排,repr(C) 遵循 C 布局;通常信任编译器
  • C#:Sequential 模式类 C;Pack 可控对齐;Auto 可能重排
  • Java/Swift:JNI或厂商实现细节不同,JVM 常见更激进填充

二、硬件层面:缓存行与伪共享(64B)

多核并发中,缓存一致性协议按“缓存行”粒度工作。两个变量若落在同一 64B 行并被不同核频繁修改,会导致该行在核间来回失效与转移——这就是伪共享(false sharing)。

复现实例与规避

struct alignas(64) AlignedCounter {
    volatile long value;
    // 结构体会被补齐到 64B,独占一行
};

AlignedCounter counters[NUM_THREADS];
// counters[i] 与 counters[i+1] 不会共享同一 cache line

要点:让热写字段独占缓存行,或将读多写少与写多的字段分离在不同缓存行。

现实案例:内核调度与网络栈会重排“热字段”以规避行级竞争,减少一致性流量与缓存冲突。

  • 例如将频繁读取的调度统计与竞争激烈的锁字段分离在不同缓存行,路径周期下降、冲突减少。
  • 参考 LKML 讨论与补丁:sched 字段重排

配置:重排内核调度器核心结构体(struct rq)的字段,把频繁访问的热点字段归类集中到同一缓存行,且隔离读写密集的锁字段。优化效果:调度器热点路径 CPU 周期少 4.6%,缓存冲突减少,应用整体性能提升。

下面是使用 gemini 生成的一个 伪共享代码的 demo,用来测试验证CPU伪共享的性能影响。https://gist.github.com/kiosk404)/false_sharing_demo.c

先看看执行效果:

worker@VM-xxxxx ~/Projects                                            [10:21:49]
> $ gcc false_sharing_demo.c -o demo -pthread

worker@VM-xxxxx ~/Projects                                            [10:22:01]
> $ ./demo
--- CPU 缓存伪共享 (False Sharing) 演示 ---
线程数: 4, 每次迭代数: 200000000

[场景 1: 有伪共享] 开始运行...
[场景 1: 有伪共享] 完成。耗时: 3.2346 秒

[场景 2: 无伪共享] (使用 padding) 开始运行...
[场景 2: 无伪共享] (使用 padding) 完成。耗时: 0.4948 秒

结论: 场景 2 (无伪共享) 应该比场景 1 (有伪共享) 运行得更快。

在 Demo 中,创建了数组 shared_counters,数组长度为线程个数。

struct Counter
{
    long long value; // 8 字节
};

struct Counter shared_counters[NUM_THREADS]; // 4 个连续的 8 字节计数器
  • long long value 占用 8 字节。
  • NUM_THREADS 是 4,所以 shared_counters 数组在内存中是 4 个连续的 8 字节整数。
  • CPU 缓存行大小 CACHE_LINE_SIZE 定义为 64 字节。

这意味着:所有这 4 个计数器(总共 32 字节)很可能全部位于或至少跨越了同一个 64 字节的 CPU 缓存行。

每个线程(运行在不同的 CPU 核心上)只负责操作数组中属于自己的那一个元素,例如线程 0 操作 shared_counters[0],线程 1 操作 shared_counters[1]。 从代码逻辑上看,线程之间应该是互不影响。

伪共享和验证 (False Sharing & Verification)

然而,由于这些变量在物理内存上靠得太近,共享了同一个缓存行,导致了“伪共享”:

  • 线程 0 (核心 A) 增加 shared_counters[0] 时,它需要将包含这个数据的整个 64 字节缓存行加载到核心 A 的 L1 缓存中,并将其状态标记为“修改” (Modified)。
  • 线程 1 (核心 B) 尝试增加 shared_counters[1] 时,核心 B 发现这个缓存行已经被核心 A 标记为“修改”。
  • 根据缓存一致性协议(如 MESI),核心 A 必须将这个缓存行写回主内存或直接发送给核心 B,并且核心 A 自己的缓存行会被标记为无效 (Invalidated)。
  • 紧接着,核心 A 要再次操作 shared_counters[0],它发现自己的缓存行无效了,必须重新从核心 B 或主内存加载整个 64 字节的缓存行。

尽管线程们操作的变量不同,但它们在缓存行层面却不断地“争抢”同一块数据,导致频繁的缓存行无效化 (invalidation)同步/重载 (synchronization/reload),这极大地拖慢了执行速度。

但是在 PaddedCounter 中由于通过 56 字节的填充,强制让一个计数器独占一个缓存行,这样就消除了伪共享,进而可以看到时间有 6.5 倍的提升。


三、操作系统层面:页与大页(4KB/2MB/1GB)

Linux 以 4KB 页为最小管理单位,通过页表为每个进程提供私有的虚拟地址空间,并支持按需调页、权限保护与 mmap 文件映射。

典型需要“页对齐”的场景

  1. DMA 零拷贝:缓冲区需页对齐,避免跨页造成物理不连续
  2. mmap 文件映射:偏移与长度通常需为页大小的整数倍
  3. 内存保护:以页为单位设置只读或不可执行

为什么有“大页”(Huge Pages)

目的:降低 TLB miss。2MB/1GB 页显著减少需要覆盖同一区域的 TLB 表项数量,提高命中率,从而降低地址翻译开销。

大页优势:

  • 2MB 覆盖原本 512 个 4KB 页,一个 TLB 表项覆盖更大片内存
  • 明显降低 iTLB/dTLB miss,提升吞吐与稳定性

大页权衡:

  • 内部碎片:小量内存也会独占 2MB/1GB
  • 分配困难:需要物理连续的大块内存,长时间运行后更难
  • 交换成本:换出 2MB 的代价远高于 4KB 粒度

性能诊断:TLB 指标解读

若观察到 iTLB/dTLB miss 比例异常高,说明地址翻译成为瓶颈。

$ perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses -p 1384931
...
# 99%+ 的 iTLB/dTLB 访问为 miss(示例输出)

应对策略:

  • 尝试启用 Huge Pages 或 transparent huge pages(评估工作集特征)
  • 降低虚拟页工作集规模:更好的数据局部性、结构体压缩、池化
  • 减少随机访问与跨页跳转

结语

对齐不是微优化,而是贯穿编译器、硬件与操作系统的系统性工程。以测量为导向,结合数据布局、缓存亲和与页粒度的选择,才能在真实负载中把延迟稳稳压住、把吞吐拉满。