CPU下的下一代C++优化 CPU下的下一代C优化十年硬件演进多 Tile/多 Die 设计缓存变化OptimizationSuperscalar内存访问与缓存Branchless TransformationNUMA NodeFalse Sharing跨节点数据共享的代价Surprising ——边界案例TLB Shootdown内核迁移开销功耗限制列主序访问绕过缓存附录: Linux 目录结构Linux一切皆文件/bin普通用户的基础命令/sbin超级管理员专属命令/boot内核文件、开机引导程序/dev设备文件目录/etc系统配置文件/proc虚拟动态目录内核数据/home普通用户的家目录/tmp系统临时文件重启清空/root超级管理员 root 的家目录/run系统运行时临时文件关机清空/usr系统最大的软件安装目录/var动态数据日志、缓存、数据库/lib系统依赖库删掉系统瘫痪/mnt手动挂载目录/opt第三方软件安装目录优先级/etc— 系统配置最常修改/boot— 启动文件系统存亡/lib— 依赖库删了系统瘫痪/home— 用户数据日常接触最多CPU下的下一代C优化现代 CPUIntel/AMD架构演进对 C 性能优化的影响第一部分现代 CPU 硬件变化1.1 十年硬件演进规格Haswell左侧~10年前Sapphire Rapids / EPYC Genoa右侧当前Die 数量单 Die多 Die/TileIntel 称为 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// 顺序访问 - 高效for(size_t i0;icontainer.size();i){process(container[i]);}// 随机访问 - 极其低效for(autoitcontainer.begin();it!container.end();it){process(*it);// 每个元素都需要跟随指针}2.3 缓存的重要性禁用 L1 缓存// 这个技巧会禁用 L1 缓存volatilechar*p...;charc*p;// volatile 阻止编译器优化导致每次都从内存读取比较 memcpyCPU无缓存 vs 有缓存Skylake~6倍更慢EPYC~22倍更慢缓存现在比 10 年前重要得多破坏缓存的代价更高。2.4 分支预测与流水线流水线Pipeline打破数据依赖// 原始代码有数据依赖result[i](a[i]b[i])*c[i];// 每一步依赖前一步的结果// CPU 通过流水线并行处理多个迭代// i0: 加法 - 乘法// i1: 加法 - 乘法与 i0 的乘法同时进行投机执行Speculative ExecutionCPU 使用分支预测器预测条件分支的结果提前获取和解码指令if (condition) { do A; } else { do B; } // CPU 预测哪个分支更可能执行提前开始执行 // 只有在条件确定后才确认预测是否正确Misprediction的代价阶段发生的事情预测正确流水线完美运行性能正常预测失败必须丢弃流水线中的所有工作可能丢弃已执行的内存写入投机执行阶段可能发生不可逆的操作越界内存读取如果指针被预测为非空写入你无权写入的内存遇到未定义行为实测预测分支 vs 预测失败分支预测失败分支的代价是预测正确分支的5-7 倍2.5 分支less转换Branchless Transformation对于无法预测分支的情况可以消除分支二进制幂模运算// 经典二进制幂算法while(exp0){if(exp1){// ← 不可预测的分支result(result*base)%mod;}exp1;base(base*base)%mod;}分支less版本// 使用条件数据而非条件指令while(exp0){result(result*base)%mod;// 总是计算base(base*base)%mod;exp1;}// 更好的分支less方式使用数组索引选择intcondexp1;// 0 或 1resultresult*((cond?base:1)(cond?1:base)*...)// 或者intoptions[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 ——NUMA3.1 什么是 NUMANUMA Non-Uniform Memory Access非均匀内存访问传统架构 vs NUMA 架构传统架构已过时 ┌──────────────────────────────────────┐ │ 所有核心 ←→ 共享内存 │ └──────────────────────────────────────┘ NUMA 架构 ┌─────────────────┐ ┌─────────────────┐ │ CPU Socket 0 │ │ CPU Socket 1 │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │ Cores │ │ │ │ Cores │ │ │ └───────────┘ │ │ └───────────┘ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │ 内存控制器│ │ QPI/ │ │ 内存控制器│ │ │ │ 本地内存 │ │ UPI │ │ 本地内存 │ │ │ └───────────┘ │ │ └───────────┘ │ └─────────────────┘ └─────────────────┘每个 CPU Socket 有自己的本地内存访问远程内存需要通过高速互联Intel QPI/UPIAMD Infinity Fabric对程序呈现统一的虚拟地址空间但物理访问延迟不同现代系统的多层 NUMA┌────────────────────────────────────────────────────┐ │ Package │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Tile 0 │ │ Tile 1 │ │ Tile 2 │ ← 各Tile │ │ │ 独立内存│ │ 独立内存│ │ 独立内存│ 有自己的 │ │ │ 控制器 │ │ 控制器 │ │ 控制器 │ 内存接口 │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ 内部互联 │ │ │ │ └─────────────────────┼───────────────────────────────┘ │ 插入主板多插槽 另一层 NUMA现代系统有两层 NUMAPackage 内部多个 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缓存行级别的假共享// 线程 0voidworker0(std::atomicintcounter){for(inti0;iN;i){counter;// 所有线程都在修改同一位置}}// 线程 1voidworker1(std::atomicintcounter){for(inti0;iN;i){counter;// ← 假共享}}问题不同线程访问同一缓存行的不同部分获得所有数据共享的惩罚但没有任何数据共享的好处解决Paddingstructalignas(64)ThreadLocalCounter{std::atomicintcount;charpadding[64-sizeof(std::atomicint)];// 填满一个缓存行};// 每个线程使用自己的 Counterstd::vectorThreadLocalCountercounters(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 感知编程策略策略一分层计数器问题场景线程池需要跟踪剩余任务数// 问题单一共用计数器成为瓶颈std::atomicuint64_tglobal_counter;// 所有线程竞争// 解决方案每个 NUMA 节点一个计数器structPerNodeCounters{alignas(64)std::atomicuint64_tcount;// 每个节点独立计数charpadding[64-sizeof(std::atomicuint64_t)];};std::vectorPerNodeCountersnode_counters(num_nodes);// 线程使用本地计数器voidworker(intnode_id){while(node_counters[node_id].count--0){process_task();}}// 主线程收集结果只在需要时跨节点访问uint64_ttotal0;for(autonc:node_counters){totalnc.count.load();// 最小化跨节点访问}策略二本地提交 定期聚合// 每个节点维护本地状态structNodeState{alignas(64)uint64_ttasks_completed;uint64_tpadding[7];};// 定期聚合主线程做减少跨节点干扰voidcollect_progress(){uint64_ttotal0;for(inti0;inum_nodes;i){totalnode_states[i].tasks_completed;}report_progress(total);}策略三批量任务分发// 问题主线程频繁跨节点提交任务// 解决批量提交给本地提交者线程voidsubmit_tasks_batch(std::spanTasktasks,inttarget_node){// 主线程只需一次跨节点操作task_queues[target_node].enqueue_batch(tasks);}// 或者使用消息传递实测NUMA 感知线程池性能配置吞吐量百万任务/秒非 NUMA 感知1线程~2非 NUMA 感知32线程~2竞争严重NUMA 感知32线程~42提升21倍3.8 Linux NUMA 控制工具命令行工具numactl# 限制程序只在 node 0 上运行numactl--cpunodebind0--membind0./my_program# 允许使用所有节点但优先本地numactl--preferred0./my_program# 交错分配到所有节点numactl--interleaveall ./my_program# 查看 NUMA 拓扑numactl--hardware编程接口libnuma#includenuma.h#includenumaif.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,unsignedlong*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 相关原因TLBTranslation 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# 检查当前 hugepage 配置cat/proc/meminfo|grep-ihuge# 设置 2MB 大页sysctlvm.nr_hugepages1024# 或在程序启动时echo1024/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 GHzCPU 规格最大睿频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 shootdown5.2 优化缓存局部性spatial temporal locality分支可预测性结构化代码避免随机分支NUMA 感知多节点系统上尤其重要避免跨节点共享使用分层/分片数据结构// 集成到应用中的监控classPerformanceMonitor{std::thread monitor_thread;std::atomicboolrunning{true};voidcollect_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 流程# 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--cpunodebind0--membind0./benchmark# 全部本地numactl--cpunodebind0--membind1./benchmark# 跨节点内存# 3. 测试线程分布影响numactl--cpunodebind0./benchmark# 全部在 node 0numactl--interleaveall ./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 统计