CPU下的下一代C++优化
- 十年硬件演进
- [多 Tile/多 Die 设计](#多 Tile/多 Die 设计)
- 缓存变化
- Optimization
- Superscalar
- 内存访问与缓存
- [Branchless Transformation](#Branchless Transformation)
- [NUMA Node](#NUMA Node)
- [False Sharing](#False Sharing)
- 跨节点数据共享的代价
- [Surprising ------边界案例](#Surprising ——边界案例)
[附录: Linux 目录结构](#附录: Linux 目录结构)
| Linux | 一切皆文件 |
|---|---|
/bin |
普通用户的基础命令 |
/sbin |
超级管理员专属命令 |
/boot |
内核文件、开机引导程序 |
/dev |
设备文件目录 |
/etc |
系统配置文件 |
/proc |
虚拟动态目录(内核数据) |
/home |
普通用户的家目录 |
/tmp |
系统临时文件(重启清空) |
/root |
超级管理员 root 的家目录 |
/run |
系统运行时临时文件(关机清空) |
/usr |
系统最大的软件安装目录 |
/var |
动态数据(日志、缓存、数据库) |
/lib |
系统依赖库(删掉系统瘫痪) |
/mnt |
手动挂载目录 |
/opt |
第三方软件安装目录 |
优先级:
/etc--- 系统配置(最常修改)/boot--- 启动文件(系统存亡)/lib--- 依赖库(删了系统瘫痪)/home--- 用户数据(日常接触最多)
CPU下的下一代C++优化
- 现代 CPU(Intel/AMD)架构演进对 C++ 性能优化的影响
第一部分:现代 CPU 硬件变化
1.1 十年硬件演进
| 规格 | Haswell(左侧,~10年前) | Sapphire Rapids / EPYC Genoa(右侧,当前) |
|---|---|---|
| Die 数量 | 单 Die | 多 Die/Tile(Intel 称为 Tile) |
| 单 Die 核心数 | 典型 2-16 核 | 每个 Tile 15 核(如 EPYC 4 代) |
| 晶体管数 | ~数十亿 | ~400 亿(仅 CPU 部分) |
| 缓存层级 | L3 位于同一 Die 上 | L3 可能位于独立 Die 上 |
| 时钟频率 | 同等范围 | 同等范围 |
| 架构特点 | 所有核心共享 L3 | 多级 NUMA 层次结构 |
1.2 多 Tile/多 Die 设计的意义
┌─────────────────────────────────────────────┐
│ Package(封装) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Tile 0 │ │ Tile 1 │ │ Tile 2 │ ... │
│ │ 15核 │ │ 15核 │ │ 15核 │ │
│ │ L1/L2 │ │ L1/L2 │ │ L1/L2 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ L3 Cache Die(独立) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
- 许多基于
cpuinfo的程序在此类 CPU 上有 bug,因为核心数不再是简单的 2 的次方 - 问题:跨 Tile/Die 访问 L3 缓存或内存会导致显著的延迟差异
1.3 缓存变化
| 缓存级别 | 容量变化 | 访问延迟 |
|---|---|---|
| L1 Cache | 从 ~32KB 增长到更大,但仍保持 3-4 周期(时钟频率级别) | 基本不变 |
| L2 Cache | 显著增大 | 略有增加 |
| L3 Cache | 大幅增大 | 取决于是否跨 Die/跨 NUMA 节点 |
缓存容量变大是为了弥补内存速度的相对滞后。CPU 计算能力增长远快于内存速度增长
- 更多计算能力(更多核心 + 超标量)------如果不用就是浪费
- 更大缓存------但需要正确使用才能发挥作用
- 更复杂内部状态------破坏缓存和流水线会付出更高代价
- 多级 NUMA 层次------跨节点访问代价高昂
第二部分:显而易见的优化
2.1 Superscalar
现代 CPU 能够在**同一周期内执行多条指令**:
a1 = a1 + a2
a1 = a1 * a3
a1 = a1 >> a4
这些操作在寄存器上进行,现代 CPU 可以同时执行它们。
Skylake vs Ice Lake 实测对比
测试方法:在一条加法指令的时间内,能否"免费"执行其他操作(乘法、移位、比较等)
| 操作组合 | Skylake 性能 | Ice Lake 性能 |
|---|---|---|
| 1个加法 | 100% | 100% |
| 8个混合操作 | ~60%(开始变慢) | >90%(轻松处理) |
- 如果有足够多的工作负载,现代 CPU 可以
同时执行更多操作 - 限制:数据依赖和内存访问
- 优化 :重构代码,
将对同一数据的计算集中在一起
2.2 内存访问优化
预取器(Prefetcher)的作用
内存有两个性能特征:
- 带宽(Bandwidth):持续流式传输数据的速度
- 延迟(Latency):发出请求到收到数据的时间
预取器机制:在你到达某个地址之前就开始从内存读取数据,使数据在你需要时已经在缓存中。
预取器失效的场景:随机访问
顺序访问 vs 随机访问实测
测试条件:访问 100 万个 8 字节数字(总计 8MB)
| 数据结构 | 访问模式 | 延迟 |
|---|---|---|
std::vector |
顺序访问 | 基准 |
std::list |
随机访问(通过指针) | 38倍更慢(Skylake)/ 近100倍更慢(EPYC) |
cpp
// 顺序访问 - 高效
for (size_t i = 0; i < container.size(); ++i) {
process(container[i]);
}
// 随机访问 - 极其低效
for (auto it = container.begin(); it != container.end(); ++it) {
process(*it); // 每个元素都需要跟随指针
}
2.3 缓存的重要性
禁用 L1 缓存
cpp
// 这个技巧会禁用 L1 缓存
volatile char* p = ...;
char c = *p; // volatile 阻止编译器优化,导致每次都从内存读取
比较 memcpy:
| CPU | 无缓存 vs 有缓存 |
|---|---|
| Skylake | ~6倍更慢 |
| EPYC | ~22倍更慢 |
缓存现在比 10 年前重要得多,破坏缓存的代价更高。
2.4 分支预测与流水线
流水线(Pipeline)打破数据依赖
cpp
// 原始代码有数据依赖
result[i] = (a[i] + b[i]) * c[i]; // 每一步依赖前一步的结果
// CPU 通过流水线并行处理多个迭代
// i=0: 加法 -> 乘法
// i=1: 加法 -> 乘法(与 i=0 的乘法同时进行)
投机执行(Speculative Execution)
CPU 使用分支预测器预测条件分支的结果,提前获取和解码指令:
if (condition) {
do A;
} else {
do B;
}
// CPU 预测哪个分支更可能执行,提前开始执行
// 只有在条件确定后,才确认预测是否正确
Misprediction的代价
| 阶段 | 发生的事情 |
|---|---|
| 预测正确 | 流水线完美运行,性能正常 |
| 预测失败 | 必须丢弃流水线中的所有工作,可能丢弃已执行的内存写入 |
投机执行阶段可能发生不可逆的操作:
- 越界内存读取(如果指针被预测为非空)
- 写入你无权写入的内存
- 遇到未定义行为
实测:预测分支 vs 预测失败分支
- 预测失败分支的代价是预测正确分支的 5-7 倍
2.5 分支less转换(Branchless Transformation)
对于无法预测分支的情况,可以消除分支:
二进制幂模运算
cpp
// 经典二进制幂算法
while (exp > 0) {
if (exp & 1) { // ← 不可预测的分支
result = (result * base) % mod;
}
exp >>= 1;
base = (base * base) % mod;
}
分支less版本
cpp
// 使用条件数据而非条件指令
while (exp > 0) {
result = (result * base) % mod; // 总是计算
base = (base * base) % mod;
exp >>= 1;
}
// 更好的分支less方式:使用数组索引选择
int cond = exp & 1; // 0 或 1
result = result * ((cond ? base : 1) + (cond ? 1 : base) * ...)
// 或者:
int options[2] = { 1, base };
result = (result * options[cond]) % mod;
- 条件指令:分支,CPU 不知道接下来要读取什么
- 条件数据:只是选择一个寄存器值,CPU 可以流水线执行
编译器行为
- 简单情况(如
return x > 0 ? x : 0):编译器自动转换为分支less - 复杂情况(如二进制幂):没有编译器自动转换,需要手动优化
register关键字被废弃后,编译器需要自己推断变量是否可能取地址
代价与收益
分支less转换的代价:总是计算两个备选方案
分支版本:条件为真时,只做一件事
分支less版本:总是做两件事,但可能更快
决策依据:
- 现代 CPU 超标量能力强悍,能吸收额外计算
- 如果
%是expensive操作,可能会抵消收益 - 这是按需优化(on-demand optimization),不要prematurely优化
实测对比
| CPU | 分支版本性能 | 分支less版本性能 |
|---|---|---|
| Skylake | 基准 | 几乎相同(96%) |
| Ice Lake | 基准 | 3.3倍更快 |
现代 CPU 的特点:
- 超标量能力强,如果你有足够工作,可以同时做更多事
- 缓存更大,破坏缓存的代价也比以前更高
- 流水线更长,破坏流水线的代价也更高
- ARM 和 x86 都适用,虽然具体tradeoff不同,但基本原理相同
第三部分:Less Obvious ------NUMA
3.1 什么是 NUMA
NUMA = Non-Uniform Memory Access(非均匀内存访问)
传统架构 vs NUMA 架构
传统架构(已过时):
┌──────────────────────────────────────┐
│ 所有核心 ←→ 共享内存 │
└──────────────────────────────────────┘
NUMA 架构:
┌─────────────────┐ ┌─────────────────┐
│ CPU Socket 0 │ │ CPU Socket 1 │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ Cores │ │ │ │ Cores │ │
│ └───────────┘ │ │ └───────────┘ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ 内存控制器│ │ QPI/ │ │ 内存控制器│ │
│ │ 本地内存 │ │ UPI │ │ 本地内存 │ │
│ └───────────┘ │ │ └───────────┘ │
└─────────────────┘ └─────────────────┘
- 每个 CPU Socket 有自己的本地内存
- 访问远程内存需要通过高速互联(Intel QPI/UPI,AMD Infinity Fabric)
对程序呈现统一的虚拟地址空间,但物理访问延迟不同
现代系统的多层 NUMA
┌────────────────────────────────────────────────────┐
│ Package │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Tile 0 │ │ Tile 1 │ │ Tile 2 │ ← 各Tile │
│ │ 独立内存│ │ 独立内存│ │ 独立内存│ 有自己的 │
│ │ 控制器 │ │ 控制器 │ │ 控制器 │ 内存接口 │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │ │
│ 内部互联 │
│ │ │
└─────────────────────┼───────────────────────────────┘
│
插入主板(多插槽)
另一层 NUMA
现代系统有两层 NUMA:
- Package 内部多个 Tile/Die 之间
- 多个 Package/Socket 之间
3.2 NUMA Node
- 一个 NUMA 节点 = 一个 CPU(或 Tile)+ 它的本地内存 + L3 缓存
- 线程运行在特定核心上 → 属于特定 NUMA 节点
- 内存分配位置取决于分配策略
- 跨节点访问内存 = 远程访问
3.3 远程内存访问的代价
带宽
| 访问类型 | 带宽 |
|---|---|
| 本地内存访问 | 基准(100%) |
| 跨节点内存访问 | 约 50-70%(10年来相对稳定) |
延迟
| 指标 | 数值 |
|---|---|
| QPI/UPI 互联延迟 | ~160 纳秒 |
| 新系统绝对延迟更高 | 不仅相对延迟增加 |
缓存行为
- L1/L2 缓存是每核心私有
- L3 缓存是每个 NUMA 节点一份
- 缓存带宽不受 NUMA 影响(本地访问)
- NUMA 系统中,缓存局部性变得更加重要
3.4 假共享(False Sharing)
缓存行级别的假共享
cpp
// 线程 0
void worker0(std::atomic<int>& counter) {
for (int i = 0; i < N; ++i) {
++counter; // 所有线程都在修改同一位置
}
}
// 线程 1
void worker1(std::atomic<int>& counter) {
for (int i = 0; i < N; ++i) {
++counter; // ← 假共享!
}
}
问题 :不同线程访问同一缓存行的不同部分
- 获得所有数据共享的惩罚
- 但没有任何数据共享的好处
- 解决:Padding
cpp
struct alignas(64) ThreadLocalCounter {
std::atomic<int> count;
char padding[64 - sizeof(std::atomic<int>)]; // 填满一个缓存行
};
// 每个线程使用自己的 Counter
std::vector<ThreadLocalCounter> counters(num_threads);
页面级别的假共享
在 NUMA 系统上,同一问题扩展到页面级别 :
如果多个 NUMA 节点上的线程访问同一页面:
- 页面的"所有权"会在节点间转移
- 转移粒度 = 页面大小(4KB)
- 代价比缓存行更大
3.5 跨节点数据共享的代价
单节点内共享数据
Core 0 ←→ L1/L2 ←→ L3(共享)←→ L1/L2 ←→ Core 1
↓
内存控制器
跨节点共享数据
Core 0 Core 1
↓ ↓
L1/L2 L1/L2
↓ ↓
L3 ← 被标记为 dirty
↓
互联(QPI/UPI)← ~160ns 延迟
↓
L3 ← 其他节点的 L3
↓
内存 或 等待对方提交
- 当一个核心修改数据时,该缓存行被标记为 dirty
- 另一个核心要访问时,必须等待缓存一致性协议完成
- 跨节点时,缓存行需要从远程节点的 L3 或内存获取
- 如果 L3 本身是 dirty 的,实际上要访问内存或等对方提交
VTune 的误导:VTune 可能将此报告为"前端停顿"(Front End Stall),实际上根源是 NUMA 跨节点访问。
3.6 原子操作在 NUMA 上的表现
测试:原子递增性能
测试场景:
1. 所有线程在同一节点,内存在节点0
2. 所有线程在同一节点,内存在另一节点(跨节点读取)
3. 所有线程分散在两个节点,内存也在两个节点
| 配置 | 性能 |
|---|---|
| 本地内存,1线程 | 基准 |
| 本地内存,多线程 | 有竞争但可接受 |
| 跨节点内存 | 显著下降 |
| 分散线程+分散内存 | 最差,需要跨节点获取绝对真相 |
结论:
- 读-改-写操作(如原子递增)在 NUMA 上代价高昂
- 原子加载(只读)代价较小
- 维护跨节点的一致全局共享状态极其昂贵
3.7 NUMA 感知编程策略
策略一:分层计数器
问题场景:线程池需要跟踪剩余任务数
cpp
// 问题:单一共用计数器成为瓶颈
std::atomic<uint64_t> global_counter; // 所有线程竞争
// 解决方案:每个 NUMA 节点一个计数器
struct PerNodeCounters {
alignas(64) std::atomic<uint64_t> count; // 每个节点独立计数
char padding[64 - sizeof(std::atomic<uint64_t>)];
};
std::vector<PerNodeCounters> node_counters(num_nodes);
// 线程使用本地计数器
void worker(int node_id) {
while (node_counters[node_id].count-- > 0) {
process_task();
}
}
// 主线程收集结果(只在需要时跨节点访问)
uint64_t total = 0;
for (auto& nc : node_counters) {
total += nc.count.load(); // 最小化跨节点访问
}
策略二:本地提交 + 定期聚合
cpp
// 每个节点维护本地状态
struct NodeState {
alignas(64) uint64_t tasks_completed;
uint64_t padding[7];
};
// 定期聚合(主线程做,减少跨节点干扰)
void collect_progress() {
uint64_t total = 0;
for (int i = 0; i < num_nodes; ++i) {
total += node_states[i].tasks_completed;
}
report_progress(total);
}
策略三:批量任务分发
cpp
// 问题:主线程频繁跨节点提交任务
// 解决:批量提交给本地"提交者"线程
void submit_tasks_batch(std::span<Task> tasks, int target_node) {
// 主线程只需一次跨节点操作
task_queues[target_node].enqueue_batch(tasks);
}
// 或者使用消息传递
实测:NUMA 感知线程池性能
| 配置 | 吞吐量(百万任务/秒) |
|---|---|
| 非 NUMA 感知,1线程 | ~2 |
| 非 NUMA 感知,32线程 | ~2(竞争严重) |
| NUMA 感知,32线程 | ~42(提升21倍) |
3.8 Linux NUMA 控制工具
命令行工具:numactl
bash
# 限制程序只在 node 0 上运行
numactl --cpunodebind=0 --membind=0 ./my_program
# 允许使用所有节点,但优先本地
numactl --preferred=0 ./my_program
# 交错分配到所有节点
numactl --interleave=all ./my_program
# 查看 NUMA 拓扑
numactl --hardware
编程接口:libnuma
cpp
#include <numa.h>
#include <numaif.h>
// 限制线程到特定节点
numa_run_on_node(node_id);
// 限制内存分配到特定节点
numa_set_membind(node_mask);
numa_bind(nodemask);
// 请求迁移现有内存
move_pages(pid, num_pages, addresses, nodes, modes, flags);
// 检查内存位置
get_mempolicy(int *policy, unsigned long *nodemask, ...);
检测 NUMA 敏感性的步骤
- 绑定所有线程到 node 0,内存也分配在 node 0 → 测 baseline
- 绑定所有线程到 node 0,内存分配在 node 1 → 测试内存远程性
- 绑定一半线程到 node 0,一半到 node 1,内存在一处 → 测试跨节点数据共享
- 根据结果决定是否需要 NUMA 感知编程
3.9 NUMA 总结
NUMA无处不在,所有大型服务器都是 NUMA 系统- 跨节点访问代价高,带宽减半,延迟增加 160ns+
- 假共享扩展到页面级别,不只是缓存行
- 数据共享极度昂贵,需要层次化数据结构
- 分层计数器和批量操作,是有效的优化模式
第四部分:Surprising ------边界情况
4.1 案例一:TLB Shootdown
症状
- 新硬件(更快)上程序运行更慢
- 减速集中在某些代码位置
- 减速可达 10 倍
- 代码特征:快速遍历大量数据结构,只访问每个结构的开头几个字节(如链表遍历)
- 减速与 NUMA 相关
原因:TLB(Translation Lookaside Buffer)抖动
虚拟地址空间 物理地址空间
┌───────────┐ ┌───────────┐
│ Page 0 │ ←────→ │ Frame 0 │
│ Page 1 │ ←────→ │ Frame 1 │
│ Page 2 │ ←────→ │ Frame 2 │
│ ... │ │ ... │
└───────────┘ └───────────┘
↑ ↑
CPU 只知道 实际数据
虚拟地址 存储位置
- MMU(内存管理单元)执行地址转换
- TLB 是 MMU 的硬件缓存,存储最近使用的页表项
- TLB 是每 CPU/NUMA 节点的(与缓存不同,没有硬件一致性协议)
触发 TLB shootdown 的场景
NUMA 页面迁移:
1. 线程 A 频繁访问页面 P(位于 Node 0)
2. 内核决定将页面迁移到 Node 1(更接近当前访问者)
3. Node 1 的 TLB 需要更新映射
4. Node 0 的 TLB 中的旧映射必须失效
5. 内核执行 TLB shootdown:停止所有核心,刷新 TLB,重新建立映射
性能影响:
- TLB shootdown 处理函数占 runtime 的 90%
- 用户代码只占 10%
- 内核代码(
flush_tlb、page_migration相关函数)成为主要 CPU 用户
解决方案:HugePages
bash
# 检查当前 hugepage 配置
cat /proc/meminfo | grep -i huge
# 设置 2MB 大页
sysctl vm.nr_hugepages=1024
# 或在程序启动时
echo 1024 > /proc/sys/vm/nr_hugepages
原理:
- 更大的页大小 = 更少的页表项 = TLB 可以缓存更多映射
- 减少 TLB miss = 减少页表遍历 = 减少内核介入
注意:
- 大页内存必须预分配
- 页面迁移时,内核会将大页透明拆分为小页
- 拆分后不会自动恢复
4.2 案例二:内核迁移开销
-
新硬件上程序整体变慢
-
不是某个特定位置,而是整体 CPU 利用率低(~70%)
-
操作系统级别测量显示大量空闲时间
-
256 核心的机器只用了约 150 核心
应用:多线程分布式应用
│
├── 线程定期需要与网络通信
│
└── 网络卡物理连接在 PCIe 上
↓
连接在 Node 0
↓
线程与网卡通信时 → 被迁移到 Node 0
↓
通信结束后 → 仍然停留在 Node 0(内核迁移阻力高)
↓
Node 0 过载,其他节点空闲
内核参数:
numa_balancing相关参数控制迁移 aggressiveness- 需要降低迁移成本参数以允许更频繁的迁移
IO 交互(网络通信)本身时间和流量都可忽略,但因为与 NUMA 系统的不对称性导致了 30% 的整体性能损失。
4.3 案例三:功耗限制
症状
- 程序运行在最高并行度部分时性能下降
- CPU 利用率显示 100%
- 但时钟频率从预期的 3.3GHz 降到 2.x GHz
CPU 规格:
- 最大睿频:4.x GHz
- 持续运行频率:3.3 GHz
- 功耗限制:240W
问题:
- 所有核心同时运行在高性能模式时
- 240W 不足以维持 3.3 GHz
- 触发功耗限制 → 降频到 2.x GHz
解决
查看规格说明中的"低延迟"(Low Latency)标记:
- 一些 CPU 针对突发性能优化,而非持续吞吐量
- 这类 CPU 更便宜,但在持续高负载下性能不达标
验证方法
对比测试:
- 240W CPU:降频,性能下降
- 320W CPU:满血运行,无降频
4.4 案例四(预告):列主序访问绕过缓存
(讲师表示如有观众留下,会演示为什么列主序访问会完全绕过缓存)
第五部分:综合建议
5.1 现代 CPU 的偏好
现代 CPU 喜欢:可预测的内存访问和分支,平滑、连续的执行流,长流水线能被充分利用
现代 CPU 不喜欢:随机内存访问,不可预测的分支,NUMA 不友好的数据分布,频繁的内核交互(如 TLB shootdown)
5.2 优化
- 缓存局部性:spatial + temporal locality
- 分支可预测性:结构化代码,避免随机分支
- NUMA 感知:多节点系统上尤其重要
- 避免跨节点共享:使用分层/分片数据结构
cpp
// 集成到应用中的监控
class PerformanceMonitor {
std::thread monitor_thread;
std::atomic<bool> running{true};
void collect_metrics() {
while (running) {
read_cpu_stats();
read_cpu_frequency();
read_memory_bandwidth();
read_network_stats();
read_numa_stats();
std::this_thread::sleep_for(3min); // 非侵入性间隔
}
}
};
中优先级
- 超标量利用:重组代码以提供足够 ILP
- 大页:减少 TLB 开销
- 预取友好:顺序访问模式
按需优化(Profiling 驱动)
- 分支less转换
- SIMD 向量化
- 其他微优化
5.3 监控与基准测试
- Profiler 帮助定位已知问题
- 监控帮助你发现问题和收集上下文
建立基准测试库
针对每个新 CPU 收集的数据:
- 内存带宽(顺序 vs 随机)
- 原子操作性能
- 分支预测失败代价
- 跨 NUMA 节点访问代价
- TLB 相关指标
5.4 处理器选择
不再是简单的"买最快的 CPU":
- 检查功耗限制(PL1 vs PL2)
- 确认是"高吞吐量"还是"低延迟"优化
- 了解 NUMA 拓扑
- 考虑 IO 设备位置对 NUMA 的影响
5.5 numactl 流程
bash
# 1. 了解系统拓扑
numactl --hardware
# 输出示例:
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 ... 31
# node 1 cpus: 32 33 34 35 ... 63
# node 0 size: 131072 MB
# node 1 size: 131072 MB
# 2. 测试内存局部性影响
numactl --cpunodebind=0 --membind=0 ./benchmark # 全部本地
numactl --cpunodebind=0 --membind=1 ./benchmark # 跨节点内存
# 3. 测试线程分布影响
numactl --cpunodebind=0 ./benchmark # 全部在 node 0
numactl --interleave=all ./benchmark # 交错到所有节点
# 4. 对比结果决定优化方向
Q: 编译器会做分支less优化吗?
A: 简单情况会(如 return x > 0 ? x : 0),复杂情况不会。编译器非常保守,不会冒着执行不必要计算的风险做转换。
Q: 条件移动(CMOV)能避免分支吗?
A: 条件移动是单条指令,但它会等待条件确定后才执行,无法与前一条指令并行。在现代 CPU 上,条件数据(数组索引选择)比分支或条件移动都快。
Q: 如何在开发时模拟 NUMA 环境?
A:
- 云服务(如 DigitalOcean)可以租用两代以前的 CPU,价格便宜
- 找厂商借测
- 使用
numactl限制资源
Q: 有没有 NUMA 感知的库?
A: 测试了 Intel TBB 等常见库,在 NUMA 系统上性能比优化的 NUMA 感知线程池差约 50 倍。建议自己实现或寻找专门针对 NUMA 优化的库。
Q: Push 和 Pull 哪个更好?
A: 没有通用答案,取决于具体架构。对于读-改-写操作,push(本地更新后通知)通常比pull(远程读取聚合)好,因为减少跨节点中断
总结
缓存和流水线比以往更重要,破坏它们的代价更高NUMA是默认,所有大型服务器都是 NUMA 系统,需要从设计阶段考虑- 监控是关键 ,
集成监控到应用中,不要只靠 Profiler 可预测性是性能,现代 CPU 的一切优化都基于可预测性测试驱动优化,凭经验猜测在新硬件上可能完全错误
| 类别 | 检查 |
|---|---|
| 数据布局 | 避免假共享(padding)、NUMA 感知分配 |
| 访问模式 | 顺序访问优先、保持数据局部性 |
| 分支 | 评估可预测性、必要时用分支less |
| 内存 | 使用大页、避免频繁页迁移 |
| 线程 | 线程亲和性、减少跨节点同步 |
| 监控 | CPU 利用率、时钟频率、NUMA 统计 |