程序员必备的内存优化指南
Published:
一、硬件基础:访问延迟数值
因为 CPU 与主存之间存在 50–100 倍的延迟差距 → 所以 性能优化的第一要务是最大化 Cache 命中率,一切数据结构和算法设计都应围绕此展开。
DRAM 的另一特性:顺序突发传输(Burst Mode) 远比随机访问高效,这也是 Cache Line 设计为 64 字节的根本原因。
二、Cache 结构与行为
Cache Line 是原子单元
因为 Cache 以 64 字节 Cache Line 为最小操作单位,读 1 字节也会加载整行 → 所以 将频繁同时访问的数据紧凑排列在相邻内存中;链表节点随机散布导致每步必然 Cache Miss,而数组遍历可以充分复用每条 Cache Line。
Set 冲突(Conflict Miss)
Cache 地址按 [ Tag | Set Index | Offset ] 三段拆分,Set Index 相同的地址竞争同一组 Cache Set。
因为 步长恰好等于 Cache 大小整数倍(如每隔 4096 字节访问),所有元素落入同一 Set → 所以 即使 Cache 未满也会大量 Miss(Conflict Miss)。避免方法:对大步长数组添加少量 Padding 错开地址,或调整数据布局。
Write-Back 与 Non-Temporal 写
- 现代处理器默认 Write-Back:Cache Line 修改后标记 Dirty,仅在逐出时写回主存,大幅节省带宽。
- 因为 对只写一次、不再读取的大块数据(视频帧、大矩阵),装入 Cache 后再写出纯属浪费 → 所以 使用 Non-Temporal Store(
_mm_stream_si128)绕过 Cache 直接写主存,并触发 Write-Combining(同一 Cache Line 的多次写合并成一次传输)。使用后须调用_mm_sfence()。
MESI 协议与多核写代价
多核通过 MESI 协议维护 Cache 一致性(Modified / Exclusive / Shared / Invalid)。
因为 一个核写 Shared 状态的 Cache Line 时,必须先广播 RFO(Request For Ownership) 消息,等所有其他核确认失效 → 所以 多核写同一 Cache Line 代价极高;多线程程序中 RFO 消息是主要性能瓶颈之一。
现代补充:在 Intel Mesh 或 AMD 多 CCD 架构中,跨 CCD 的 RFO 需经过片上网络路由,代价更高。
Critical Word(关键字优先)
因为 Cache Line 从主存加载时按 64-bit 块逐步传输,若所需字段在末尾,程序需等待全部传输完毕 → 所以 将结构体中最先被访问的字段放在开头,使其成为 Cache Line 的第一个字,最早可用。
三、TLB 与虚拟内存
TLB 基础
每次内存访问都需将虚拟地址翻译为物理地址(4 级页表需最多 4 次内存访问)。TLB 缓存翻译结果,但容量极小:
因为 工作集跨越的虚拟页越多,TLB 越快溢出,Miss 惩罚和 Cache Miss 量级相当 → 所以 减少页面数量与减少 Cache Miss 同等重要:紧凑排布数据,使用 Huge Page。
Huge Page(大页)
因为 2MB 大页让每个 TLB 条目覆盖 512 倍内存(vs 4KB 普通页) → 所以 对大内存工作集(数据库、科学计算),使用大页可显著降低 TLB Miss:
不要将同一物理地址映射到多个虚拟地址
因为 L1d/L1i 使用虚拟地址标记(VIPT/VIVT),同一物理页有多个 VA 别名时,Cache 会持有多份副本,写其中一个不会自动使另一个失效 → 所以 同一进程内避免用 mmap/shmat 将同一物理页映射到两个以上虚拟地址;若无法避免,确保两个 VA 的页内偏移相同。
TLB 与上下文切换
因为 TLB 是核级全局资源,进程切换(不同页表树)须刷新 TLB → 所以 同进程的多线程切换不刷 TLB(好);频繁进程切换代价高。现代 CPU 的 PCID 机制为每个地址空间打标,避免全量刷新,大幅降低进程切换代价。
虚拟化下的额外代价
因为 传统虚拟化(Shadow Page Table)每次 Guest 修改页表须陷入 VMM,TLB Miss 代价翻倍 → 所以 虚拟化环境中所有 Cache/TLB 优化的收益成倍放大。Intel EPT / AMD NPT 已大幅缓解此问题,但开销仍高于裸机。
四、NUMA 架构
因为 线程使用的内存若不在其运行 CPU 的 NUMA 节点上,每次访问都要经过处理器间互联(UPI / Infinity Fabric)→ 所以 核心原则:数据靠近计算,线程绑定 CPU。
五、数据访问优化
5.1 矩阵乘法:顺序访问 + Cache 分块
朴素矩阵乘法对第二个矩阵按列访问,产生大量 Cache Miss。优化路径:转置(两矩阵均顺序)+ 分块版(SM×SM 子矩阵始终在 L1d)+SIMD
因为 子矩阵分块确保每次计算所需数据始终在 L1d/L2 内 → 所以 对任何二维数据的嵌套循环,均可应用此”Tiling”思路。分块大小应通过 sysconf(_SC_LEVEL1_DCACHE_LINESIZE) 动态获取,而非硬编码。
5.2 结构体布局
消除 Padding 空洞: 因为 字段对齐插入的 Padding 浪费 Cache Line 空间 → 所以 重排字段,将小字段填入大字段后的空洞。用 pahole -C MyStruct ./binary 检查布局,极端情况可将结构体从 2 个 Cache Line 压缩到 1 个。
热冷字段分离: 因为 访问热字段时会把冷字段(如大型字符串、日志信息)也加载入 Cache → 所以 将频繁访问的热字段提取到独立的小结构体,冷字段单独存放。
5.3 对齐
因为 未对齐访问可能横跨两个 Cache Line,需两次加载;SIMD 指令要求严格对齐 → 所以 使用 posix_memalign / aligned_alloc / __attribute__((aligned(64))) 保证 Cache Line 对齐。
5.4 Non-Temporal 写(大数据流)
因为 流式写入(初始化大缓冲区、视频帧复制)的数据写后不再读,污染 Cache 无意义 → 所以 用 _mm_stream_si128 等 Non-Temporal Store,绕过 Cache 直接写主存,并触发 Write-Combining。使用后须调用 _mm_sfence()。
六、指令 Cache 优化
因为 过度内联 / 循环展开增大代码体积,L1i 压力上升,还会逐出其他热点函数 → 所以:
- 多处调用的大函数用
__attribute__((noinline))禁止内联,节省 L1i 空间 - 用
likely()/unlikely()+-freorder-blocks将冷路径移出主执行路径,保持热路径线性 -Os优化代码体积;-falign-functions=64对齐函数入口
PGO:用 -fprofile-generate 插桩 → 代表性负载运行 → -fprofile-use 重编译,让编译器基于真实数据优化代码布局和内联决策。
七、预取
因为 硬件预取器只能识别顺序 / 等步长访问,且不能跨越虚拟页边界 → 所以 链表 / 树遍历、大步长随机访问、每个新页的首次访问均需软件预取:
_mm_prefetch(addr, _MM_HINT_T0); // 预取到 L1d(马上用)
_mm_prefetch(addr, _MM_HINT_T1); // 预取到 L2(稍后用)
_mm_prefetch(addr, _MM_HINT_NTA); // 非时序(只用一次,不污染 Cache)
预取距离 = ⌈主存延迟 / 每元素处理时间⌉。例:每节点处理 160 cycles、主存延迟 200 cycles → 提前预取 5 个节点较为安全。
因为 软件预取逻辑与计算逻辑混写增加复杂度 → 所以 可将超线程(SMT)的一个逻辑核专用作预取辅助线程,提前填充 L2/L3,主线程直接命中。
八、多线程优化
8.1 False Sharing(伪共享)—— 最常见的多线程性能杀手
因为 MESI 协议以 Cache Line(64B) 为粒度操作,不同线程写同一 Cache Line 上的不同变量,同样触发 RFO → 所以 被不同线程写入的变量,必须独占独立的 Cache Line:
// ❌ 4 个计数器共享 1 条 Cache Line → 4 线程比单线程慢 11 倍
long counters[4];
// ✅ 每个计数器独占 1 条 Cache Line(加 Padding 填满 64 字节)
struct alignas(64) PaddedCounter { long value; char pad[56]; };
PaddedCounter counters[4];
8.2 变量按读写特性分组
| 类型 | 处理方式 |
|---|---|
| 只读 / 常量 | const → 放入 .rodata,所有核安全共享(Cache S 状态) |
| 多线程写入 | alignas(64) + Padding,独占 Cache Line |
| 线程私有 | __thread / thread_local(TLS),彻底消除共享 |
8.3 原子操作:选最小代价的原语
因为 CAS 循环失败时反复产生 RFO;原生原子加法只需 1 次 RFO → 所以 用 std::atomic<T>::fetch_add / __sync_add_and_fetch 而非手写 CAS 循环。。
8.4 线程调度策略
- 协作线程(共享数据集) → 绑定到同一处理器的不同核(共享 LLC,数据只需从主存加载一次)
- 独立线程(各自数据集) → 绑定到不同处理器(各自独享内存带宽,避免相互逐出 Cache 数据)
九、Page Fault 优化
因为 mmap 只建立虚拟地址映射,物理页在首次访问时才分配(Page Fault),内核介入代价高(数千 cycles)→ 所以:使用 posix_madvise 提示内核预加载。
因为 程序启动时,相邻调用的函数若分布在不同页上,每次都触发 Page Fault → 所以 用 PGO 或链接器脚本将热点调用链的函数排布在相邻页上;实测可减少启动时间 5%,TLB 效率也同步提升。
十、工具速查
| 工具 | 用途 | 关键命令 |
|---|---|---|
perf stat | Cache / TLB Miss 统计 | perf stat -e L1-dcache-load-misses,LLC-load-misses,dTLB-load-misses ./prog |
perf c2c | False Sharing 检测 | perf c2c record ./prog && perf c2c report |
perf record | 热点定位 | perf record -e LLC-load-misses ./prog && perf report |
cachegrind | Cache Miss 模拟(逐行) | valgrind --tool=cachegrind ./prog |
massif | 堆分配分析 | valgrind --tool=massif ./prog |
pahole | 结构体 Padding 分析 | pahole -C MyStruct ./binary |
numastat | NUMA 本地 / 远端访问比 | numastat -p PID |
/usr/bin/time -v | Page Fault 计数 | 输出末尾 Major/Minor page faults |
十一、速查总结
核心建议
| 问题 | 根因 | 解法 |
|---|---|---|
| L1/L2 Cache Miss 多 | 随机访问,数据分散 | 顺序访问;结构体热冷分离;Cache 分块(Tiling) |
| Conflict Miss | 步长 = Cache 大小整数倍 | 避免 2 的幂次步长;加 Padding 错开 Set |
| TLB Miss 多 | 工作集跨页太多 | Huge Page;数据 Footprint 紧凑 |
| False Sharing | MESI 以 Cache Line 为粒度 | 热写变量独占 Cache Line + Padding |
| 同 PA 多 VA 别名 | L1 虚拟地址标记,副本不一致 | 同进程内避免多次 mmap 同一物理页 |
| 流式写污染 Cache | 写后不读但占用 Cache Line | Non-Temporal Store(_mm_stream_*) |
| 指令 Cache 压力 | 内联 / 展开导致代码膨胀 | -Os;likely/unlikely;PGO |
| 原子操作慢 | CAS 循环失败多次 RFO | 改用原生原子算术指令 |
| NUMA 远端延迟 | 内存不在本地节点 | mbind + pthread_setaffinity_np |
| 启动 Page Fault 多 | 冷页懒分配 + 函数布局分散 | MAP_POPULATE;函数重排(PGO);Huge Page |
