CPU下的下一代C++优化

CPU下的下一代C++优化

[附录: Linux 目录结构](#附录: Linux 目录结构)

Linux 一切皆文件
/bin 普通用户的基础命令
/sbin 超级管理员专属命令
/boot 内核文件、开机引导程序
/dev 设备文件目录
/etc 系统配置文件
/proc 虚拟动态目录(内核数据)
/home 普通用户的家目录
/tmp 系统临时文件(重启清空)
/root 超级管理员 root 的家目录
/run 系统运行时临时文件(关机清空)
/usr 系统最大的软件安装目录
/var 动态数据(日志、缓存、数据库)
/lib 系统依赖库(删掉系统瘫痪)
/mnt 手动挂载目录
/opt 第三方软件安装目录

优先级:

  1. /etc --- 系统配置(最常修改)
  2. /boot --- 启动文件(系统存亡)
  3. /lib --- 依赖库(删了系统瘫痪)
  4. /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 计算能力增长远快于内存速度增长

  1. 更多计算能力(更多核心 + 超标量)------如果不用就是浪费
  2. 更大缓存------但需要正确使用才能发挥作用
  3. 更复杂内部状态------破坏缓存和流水线会付出更高代价
  4. 多级 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 的特点:

  1. 超标量能力强,如果你有足够工作,可以同时做更多事
  2. 缓存更大,破坏缓存的代价也比以前更高
  3. 流水线更长,破坏流水线的代价也更高
  4. 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

  1. Package 内部多个 Tile/Die 之间
  2. 多个 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
           ↓
        内存 或 等待对方提交
  1. 当一个核心修改数据时,该缓存行被标记为 dirty
  2. 另一个核心要访问时,必须等待缓存一致性协议完成
  3. 跨节点时,缓存行需要从远程节点的 L3 或内存获取
  4. 如果 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 敏感性的步骤
  1. 绑定所有线程到 node 0,内存也分配在 node 0 → 测 baseline
  2. 绑定所有线程到 node 0,内存分配在 node 1 → 测试内存远程性
  3. 绑定一半线程到 node 0,一半到 node 1,内存在一处 → 测试跨节点数据共享
  4. 根据结果决定是否需要 NUMA 感知编程

3.9 NUMA 总结

  1. NUMA 无处不在,所有大型服务器都是 NUMA 系统
  2. 跨节点访问代价高,带宽减半,延迟增加 160ns+
  3. 假共享扩展到页面级别,不只是缓存行
  4. 数据共享极度昂贵,需要层次化数据结构
  5. 分层计数器和批量操作,是有效的优化模式

第四部分: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_tlbpage_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 优化

  1. 缓存局部性:spatial + temporal locality
  2. 分支可预测性:结构化代码,避免随机分支
  3. NUMA 感知:多节点系统上尤其重要
  4. 避免跨节点共享:使用分层/分片数据结构
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);  // 非侵入性间隔
        }
    }
};

中优先级

  1. 超标量利用:重组代码以提供足够 ILP
  2. 大页:减少 TLB 开销
  3. 预取友好:顺序访问模式

按需优化(Profiling 驱动)

  1. 分支less转换
  2. SIMD 向量化
  3. 其他微优化

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(远程读取聚合)好,因为减少跨节点中断


总结

  1. 缓存流水线比以往更重要,破坏它们的代价更高
  2. NUMA 是默认,所有大型服务器都是 NUMA 系统,需要从设计阶段考虑
  3. 监控是关键集成监控到应用中,不要只靠 Profiler
  4. 可预测性是性能,现代 CPU 的一切优化都基于可预测性
  5. 测试驱动优化,凭经验猜测在新硬件上可能完全错误
类别 检查
数据布局 避免假共享(padding)、NUMA 感知分配
访问模式 顺序访问优先、保持数据局部性
分支 评估可预测性、必要时用分支less
内存 使用大页、避免频繁页迁移
线程 线程亲和性、减少跨节点同步
监控 CPU 利用率、时钟频率、NUMA 统计