
聊CPU和系统性能优化的朋友不管你是做后端、搞数据库还是写编译器这两年一定绕不开两个词超标量Superscalar和NUMA。前者是单颗计算芯片内部让人眼花缭乱的并行魔法后者是整机层面多核多路服务器必须面对的内存访问现实。这两个东西看着分属“芯片设计”和“系统软件”两个世界但实际上是一条并行路线上的不同高度超标量在指令级把一个个单线程压出更多并行度NUMA在多核多路之间把内存访问的“不公平”摆上台面逼着我们用拓扑感知的方式重新思考分配和调度。这篇文章想聊的就是这两条技术路线的底层逻辑、关键设计以及你在一台真实的机器上做性能调优时怎么把“超标量CPU”和“NUMA拓扑”这两个因素像工具箱里的扳手一样用起来。适合谁看后端开发、性能工程、数据库管理员以及所有曾经遇到“明明加了核性能却上不去”“内存带宽忽高忽低”这类问题的同学。我不打算堆教科书就按我实际调机器、看流水线、抓NUMA问题时的思路来讲。1. 先看CPU内部的并行超标量流水线到底在解决什么1.1 一条指令是怎么被吃下去的——流水线与发射宽度的基本盘很多人最开始学体系结构时脑子里对CPU的印象其实是“顺序执行”取指令、解码、执行、写回一步步来。这种模型逻辑上没错但它只对应最简单的单片机或者早期单发射标量处理器。你看今天的通用计算芯片哪怕是手机里那颗低功耗核心内部也早就不“老实”排着队干活了。超标量的核心定义其实非常简单每个时钟周期CPU能够发射并执行多条指令。假设一个四发射超标量核心理论IPC每周期指令数就能到4。注意“理论”两个字真想让流水线一个周期塞进4条指令需要前端取指令、解码、重命名、分派、执行、提交这一整条链路都能并行处理任何一个环节卡住理论吞吐就打折扣。这里最容易被忽略的一点是发射宽度并不是越高越好。从电路设计的角度每加一发射寄存器堆的读端口、旁路网络、指令队列的项数都得跟着扩这几样东西加在一起功耗和面积都是呈超线性增长的。所以你在市面上看到的通用处理器最高的也就是六发射、八发射左右很少有谁狂堆到十六发射。因为跑到那个程度分支预测错了代价、指令调度的复杂性、验证的难度都会失控功耗烤得散热都压不住。我举个通俗的类比超标量的发射宽度就像一个餐厅的出菜窗口数量。窗口少了高峰排队窗口多了后厨执行单元就得同时备很多锅菜还要保证不会把两个菜炒成一锅。CPU里的“后厨”就是整数ALU、浮点单元、访存单元、分支单元而“菜”就是彼此有依赖关系的指令。所以超标量真正难的不是多开几个窗口而是怎么处理指令之间的数据依赖、资源竞争和控制依赖。1.2 乱序执行、寄存器重命名与分支预测三个逆天设计单靠增加发射宽度很快你会碰到一个墙指令之间有依赖有些还得等内存数据回来后边的指令没法立刻执行。教科书对此有个专门的称呼——数据冒险。为了解决这个墙现代超标量处理器普遍采用乱序执行Out-of-Order Execution。乱序执行的思路本质上就是“不等了”后面的指令如果在资源上没冲突、数据上也没依赖前面的结果就可以越过前面的指令先执行。这个想法在1960年代就有理论雏形真正大规模商用是1990年代之后。它带来的最大收益是容忍了访存延迟Cache Miss之后要等几百个周期取数这段时间里只要还有不相关的指令处理器就能继续运行不浪费流水线。乱序执行会引出两个新问题。第一个是WAR/WAW冒险这靠寄存器重命名解决。每一路架构寄存器会被映射到一大堆物理寄存器里处理器会动态地把“旧名字”改成“新名字”保证指令间看起来没有冲突。你可以理解为酒店前台用不同房间应对同名客人同一姓名张三住801的和住802的实际上是不同人重命名做的就是分发房卡。第二个是分支指令造成的控制冒险这靠分支预测解决。预测正确流水线保持满载预测错了整个流水线前端的取指、解码全部作废重来。现代处理器的分支预测器能到95%以上的准确率但这5%的miss代价就非常吓人深流水线一下要冲刷掉几十个周期的活儿。学习超标量时最值得记住的一组关系是发射宽度决定理论最高IPC乱序窗口决定能跑多远分支预测决定大部分时候能不能维持满载。单纯看单核跑分时大家比的就是这三件事。低功耗高能效的芯片往往靠加大乱序窗口和预测器来补发射宽度低的短板而服务器芯片更倾向于平衡三者。1.3 超标量不是万能的从IPC到实际能耗的账前阵子调一个纯数值计算程序我盯着perf的输出看IPC只有0.5左右完全没跑出芯片标称的四发射水平。这正好引出一个关键结论超标量提供的只是“能力上限”实际IPC主要看代码本身的指令级并行度ILP。比如一串串行的指针链表遍历算完一个地址才能取下一个数据再宽的超标量也救不了你。流水线里真正能并行的只是那些没有依赖链的独立计算。所以你在优化单线程性能时真正常见的杀招是减少分支序列、拆解长依赖、用SIMD把多个数据操作打包而不是期望CPU替你解决一切。超标量和你之间是合作关系——你把并行性显式暴露出来CPU用硬件调度把我藏着的并行也挖出来两者叠加才能压出最高IPC。另一个账是能耗。动态调度、物理寄存器堆、重排序缓冲ROB、多端口存储单元这些都是实打实消耗晶体管和功耗的家伙。在电池供电的移动场景或者数据中心的算力密度指标面前“聪明”是要按瓦特买单的。这也是为什么这些年又冒出一堆走“静态调度、极简乱序”路线的架构它们未必追求极限宽发射而是追求单位功耗下的并行收益最大化。2. 同一条路上的两个选择超标量与VLIW的路线之争2.1 VLIW把调度交给编译器的逻辑以及它为什么有尴尬讲超标量的时候绕不开它最大的“对手”——VLIW超长指令字。你要是第一次听这名字可能会觉得“这不就是另一种并行吗”。对目标是一样的一个周期内同时执行多条操作。区别在于谁来安排这些操作。在超标量处理器里“一周期能并行几条、哪几条拼在一起”是硬件动态调度的而在VLIW处理器里指令字本身极长一条指令里就打包了许多个操作槽位。编译器在生成机器码之前就静态分析完依赖关系把能并行的操作填进同一个超长指令的不同槽位。硬件不用做复杂的调度取到一条超长指令直接分发到对应执行单元就行。这设计在纸面上非常优雅省掉了乱序调度的硬件功耗低、频率可以拉得更极限更适合嵌入式、信号处理这类计算模式固定的场景。很多早期DSP和部分专用加速器就采用类似思想。但一到通用计算VLIW就遇到几个“社死”问题编译器在现场才能知道的分支概率、缓存命中情况它看不见因此静态调度经常被实际情况打脸一旦代码路径变化指令槽位大量空闲并行度反而比不过动态调度任何一次指令集升级二进制兼容性都是一场灾难——因为指令编码里写死了并行的组织方式。通用计算生态系统最终大面积选择了超标量路线不是因为超标量电路更简单而是因为它是把“不确定”留给硬件动态处理对编译器友好得多。你能想象今天所有应用都强制重新编译才能跑新CPU的场面吗所以从这个维度上说超标量的胜利很大程度是生态兼容性的胜利而不是单纯的电路效率胜利。2.2 超标量为什么最终统治了通用计算在我看来超标量能够成为通用计算的主流有三个决定性原因。第一是它把“兼容”和“进取”解耦了。新的CPU只管提升发射宽度、加深乱序窗口、改进分支预测旧二进制照样能跑而且自动享受到新硬件的调度红利。这对软件生态来说是最低摩擦的演进方式。VLIW那种把调度责任全压到编译器的方案反而逼着整个软件链路上的每一个玩家都得跟上哪怕只是用了一个老库性能也会瞬间被打回原形。第二是分支预测器的智商持续进化。今天的混合分支预测器能同时跟踪局部历史、全局历史还能用感知机模型做投机判断准确率甚至高到让乱序执行的“错误代价”变得很小。有准确的预测器保驾护航深流水线、宽发射的乱序机器才有机会真正满载工作。第三是硅工艺给了硬件调度足够多的预算。超标量确实费晶体管但今天数亿、数十亿晶体管的芯片规模已经能轻松容纳庞大的物理寄存器堆和复杂的调度网络。随着工艺进步这个成本被摊薄VLIW“省晶体管”的优势就越来越不性感。当然这不意味着VLIW思想消失了。恰恰相反在针对特定领域比如AI矩阵乘加、通信基带的专用芯片里数据流规律分支极少编译器可以大胆静态编排这时候VLIW式的宽指令和乱序逃脱都能带来可观的能效优势。真正被淘汰的只是“用VLIW打通用市场”那套梦想而已。2.3 如果从CPU发展到GPU和AI芯片这条路线发生了什么聊到这里你可能会问现在炙手可热的GPU、AI加速芯片它们是不是也在走“超标量”或者“VLIW”的路子答案是两者都有影子但最核心的武器早已换成了另一个维度——线程级并行TLP和SIMT。GPU里每个CUDA核心往往就是一个很朴素的小核心乱序能力有限甚至完全没有但它靠数千个线程的并发切换来填满流水线。当某个线程在等显存数据时调度器直接换上另一个线程组这种共享硬件多线程和超标量在本质上都是“用并行隐藏延迟”只不过并行来源从指令级换成了线程级。这也是为什么近些年CPU设计也开始大举引入同步多线程反正乱序调度器闲着也是闲着多塞一组线程的上下文让发射槽位更满。AI芯片则在另一条路上走得更远。很多AI加速器干脆采用“专用矩阵指令 标量辅助核”的组合矩阵乘一条大指令本质上属于数据级并行处理器内部的超标量部分只用来看控制流、处理标量逻辑。所以说超标量并没有“过时”它作为一个底层管道依然存在于几乎所有通用处理器中只是并行度的主战场已经往更宏观的维度转移。这正好衔接到了下一篇文章的大主题当CPU从单核变成多核再从单路变成多路并行战的胜负手就从单颗芯片内部移到了整个系统的内存架构上而NUMA就是那个你绕不开的下一站。3. 出了单个核之后轮到NUMA登台3.1 从单核到多核多路内存访问为什么会变得不均匀早年的多核服务器其实是SMP统一内存访问模型。所有CPU核心通过一条前端总线和北桥上的内存控制器相连访问内存的延迟基本一致。这种UMAUniform Memory Access模型对软件隐身开发人员不需要关心数据在哪个内存条上。但它有个物理死穴总线和北桥的带宽有限。核一多大家都挤同一条独木桥去取数延迟立刻恶化。于是硬件工程师做了个很自然的动作把内存控制器从北桥搬进每个CPU内部让每个CPU只管理距离自己最近的那部分内存。插上多路CPU以后每个CPU和它的本地内存在一起构成一个NUMA节点CPU既能访问自己的本地内存也能通过片间互连总线访问其他节点上的远程内存。本地访问延迟低、带宽大远程访问延迟高、带宽受片间互连限制这就形成了非一致内存访问。一个很关键的事实NUMA并不是为了麻烦软件而生的它本质上是牺牲“公平性”来换“总带宽”。让每个CPU先管好自己的本地内存绝大多数访问在本地完成片间总线只处理跨节点数据这样整机的内存带宽才能随着节点数扩展。只要本地命中率高NUMA的性能潜力反而巨大怕的是代码既不清楚拓扑又随机访问整个地址空间那就等于把本地访问硬生生变成远程访问性能血崩。3.2 NUMA的三个基础概念node、distance与memory policy我调试NUMA问题的时候头脑里始终装着三个概念。第一个是NUMA节点Node。一个节点不仅是一颗CPU还包括它直接控制的内存条和PCIe等设备。现代一台双路机器上你会在系统里看到node0和node1两个节点各自拥有若干原生核和若干GB物理内存。第二个是节点距离Node Distance。这是一个相对值表示从一个节点访问另一个节点上资源的“远近”。比如同一节点内部距离是10同主板的另一个CPU也许就是20再远一点的节点可能是30。距离越大延迟越高带宽通常也越低。许多调度器就是根据distance矩阵决定“把线程放哪、内存从哪分配”。第三个是内存分配策略Memory Policy。内核在分配物理页时可以按不同策略决定物理页落在哪个节点。最常见的四种依次是本地优先local、绑定到指定节点bind、全部节点交叠分配interleave、以及按进程绑定的CPU位置就近分配preferred。理解这四种策略是调优的第一个坎因为大多数性能抖动问题不是你代码写得慢而是物理页放在了一千公里之外的“内存”上。我见过有人为了让“内存分配更均匀”而全局设置interleave之后反而拖慢所有工作负载的。interleave策略更适合访问模式对节点敏感、且每个线程都吃多个节点带宽的场景。如果你的线程划分本来就是数据局部的优先绑定就近内存才是正道。“均匀”并不是绝对的好词在NUMA下每一分本地性都很值钱。3.3 硬件怎么知道要“远程”访问——从北桥到片上内存控制器硬件层面还有一个特别值得理解的细节一次内存访问到底是怎么决定是本地还是远程的在集成内存控制器的架构里每个CPU内部有一个路由逻辑根据物理地址的区间映射判断目标地址归哪个节点的内存控制器管。如果归自己直接发到片上内存控制器如果不归自己则把这个访问打包通过CPU之间的点对点高速互连总线不同厂商叫法不一样发到目标节点再由那边的内存控制器完成访问。这个过程对软件是透明的但延迟差异是实打实的本地访问可能80纳秒远程却要140到250纳秒带宽也可能只有本地的一半甚至更低。一个线程如果吃满本地带宽迁移到另一个节点后内存吞吐可能缩水40%以上。更隐蔽的是PCIe设备的中断和DMA也有NUMA属性。如果你把跑网卡收包的应用绑到node0但网卡物理插在node1那么每次收包都会发生跨节点中断处理和DMA数据访问小包场景下额外开销尤其明显。所以许多高端系统会把网卡、GPU绑在对应CPU的PCIe控制器下确保“设备靠近CPU”这种拓扑设计从硬件角度尽量挽回了一些不公平。到这里你应该明白NUMA的本质就是承认“所有节点生而不平等”然后把这个不平等的结果显式暴露给操作系统和程序员。跟上一条超标量的“不同指令生而不平等”有依赖和没依赖几乎是同一个哲学——更好的性能来自于识别不平等并分别对待而不是假装大家都一样。4. NUMA环境下的实际调优亲和性、内存分配与性能陷阱4.1 先看自己的机器是什么NUMA拓扑在纸上理解了NUMA之后第一件事永远是看自己手里的机器到底是什么拓扑。我常用的检查工具是lscpu、numactl和lstopo。打开lscpu你会看到NUMA node信息以及每个节点的CPU列表。真正干活之前先跑一个numactl --hardware它会列出每个节点的可用内存和距离矩阵。别看这一步简单很多人栽就栽在“我以为”比如在一台两路机器上把32个逻辑核当成了一个大池子均匀分配线程结果线程被随意调度到两个节点上又开了自动NUMA平衡numa_balancing1内核会隔一段时间就迁移你的内存页到线程所在节点。听起来很智能但迁移本身要消耗带宽而且在不确定的工作负载下它经常把页面追来赶去反而放大抖动。所以我一般建议在明确长期运行且访问模式稳定的服务上直接关闭自动NUMA平衡改用显式的亲和性和内存绑定策略。别怕这会让系统“失去自动优化”等你亲眼看到线程切换导致SF每秒请求数从20万掉到12万的时候就知道显式控制有多香了。4.2 关键操作线程/进程绑定和内存绑定这里我们就进入实操环节。假设我手上有一台双路机器node0是CPU 0-3逻辑核0-7node1是CPU 4-7逻辑核8-15。我需要启动一个多线程内存密集应用。一个常见的错误是直接裸跑让系统帮你决定一切。我们来看正确做法。第一步绑定CPU。让需要访问大量内存的每一个工作线程固定到与它内存页面相同节点内的CPU上。taskset -c 0-7 ./my_app这是把进程CPU绑定到node0的核上。更好的方式是用libnuma接口在程序内部按线程绑定因为那样可以更细粒度控制每个线程各自的节点。第二步绑定内存。用numactl --membind0让进程的所有内存分配都落在node0。numactl --cpunodebind0 --membind0 ./my_app就是同时绑定CPU和内存到node0。这样的效果是进程在node0上运行第一接触的内存全部是本地内存访问延迟最低。第三步如果你确实需要同时用两个节点上的所有CPU和内存那可以考虑“交错”搭配numactl --interleaveall运行让分配时物理页面轮流落在node0和node1。这种模式的好处是总带宽上限高坏处是每个线程访问内存时有一半概率落到远程节点。它适合像流式计算这样数据访问没有明显局部性、且两个节点带宽都吃不满的场景。还有一点必须提醒除了进程分配内存的时刻线程启动之前调用set_mempolicy或者绑定亲和性才能保证第一次缺页时页面落在期望节点。很多程序是先启动线程再初始化数据集合结果线程已经跑起来了页面早就在别的节点上扎堆之后你再绑定CPU也救不回来了。为此可以设计一个“初始化后迁移”的流程启动进程时先绑一个核分配完数据再设置亲和性或者干脆用numa API在代码里做。4.3 一个真实场景某内存数据库迁移到双路服务器后的波动我想分享一个具体的案例。某公司有一个自研的键值存储引擎原本跑在四路老机器上非常平稳。迁移到新的双路服务器之后同一份压测脚本延迟曲线变成了锯齿状平均延迟看着还好P99却时不时冲到原先的三倍多。最开始大家怀疑是CPU频率发挥不出来后来查perf发现大量local memory miss事件和跨片总线流量这才锁定是NUMA问题。根因非常典型引擎用的是共享哈希索引启动阶段由一个主线程加载全部索引数据物理页面全部落在主线程所在节点。但随着运行工作线程又被系统轻飘飘地调度到另一个节点大量索引读取就从本地访问变成了远程访问。线程一多跨节点的内部互联总线直接被读请求打满延迟自然爆炸。当时的修复分两步。第一步所有线程显式绑定节点索引分片按节点划分每个节点上的线程只处理自己节点内驻留的那部分索引。第二步启动阶段并行化数据加载每个节点上的线程各自分配各自的分片数据确保首触点first touch落在正确节点。压测之后结果非常漂亮P99恢复到迁移前的水平跨节点流量降到了原来的两成总吞吐还因为本地命中率提高比老机器高出一截。这个案例里有几个值得复用的经验多线程服务想要发挥NUMA的性能数据结构和内存分配策略要一起改。光绑线程不绑内存没用光绑内存不绑线程也没用。绑定策略必须跟数据分片逻辑匹配否则“绑定”反而变成“死绑”。4.4 常见问题与排查笔记做NUMA调优久了很多现象一眼就能判断个大概。我整理了一张速查表遇到问题可以先对着自查。现象可能原因快速排查/解法延迟曲线锯齿状抖动页面被迁移或线程跨节点漂移关闭自动NUMA平衡显式cpunodebindmembind内存总带宽很低但单节点带宽高interleave策略开得过早远程访问占比大改local策略按节点分片分配数据网卡收包性能差CPU忙但吞吐低网卡所在NUMA节点和收包线程所在节点不一致把中断处理线程绑定到网卡所在节点启动时分配正常运行后持续远程访问主线程首触发了大量共享内存子线程后续读远端改为并行初始化确保首触点在访问者所在节点numastat里remote_node_hit占比高数据放到了错误的节点检查启动环节是否有单线程加载、内存扎堆关于numastat的解读我最关注两个字段local_node和other_node。如果其他节点的访问占比明显高说明要么内存布局不符合线程访问模式要么线程被调度偏移了。结合perf里关于总线流量的事件统计基本就能把责任方揪出来。另外一个容易被漏的细节是HugePages。开启HugePages后内存分配颗粒变大分配策略对物理大页位置的影响也更大。配置错误的话可能出现“大页全部落在node0node1上的线程全在远程访问大页”的惨剧。因此配置大页时最好按节点分别预留比如node0和node1各预留一部分再从代码中绑定到各自节点。5. 超标量 NUMA今天的方向从片内并行到系统级并行5.1 整机视野每个层次都有各自的并行控制把这一整篇文章的内容合起来你会得到一个非常立体的图景。层次最底层是超标量流水线在单个核心内部抓指令级并行往上一层是多核和线程级并行再往上一层是多路系统和NUMA在内存系统级抓拓扑并行。做性能调优的时候这三层哪个都不能少。我过去习惯先看指令级性能后来发现很多服务的瓶颈根本不在单核IPC而在远程内存访问的等待上。单核IPC再高等一次远程访问的几百纳秒也要命中内存流水线的“气泡”。顶层设计如果不感知NUMA底层的超标量性能就是空中楼阁。反过来NUMA调优做得再好代码本身没有足够的ILP单节点上的单线程仍是慢。所以“超标量NUMA”这两个词放在一起让我看到的不是两个孤立的知识点而是性能工程的完整方法论先用硬件机制理解为什么再用系统方法解决怎么调。5.2 Chiplet、CXL与“NUMA层级”的扩展目前计算芯片里还有两个趋势会让这个主题变得越来越重要。第一个是Chiplet化CPU不再是一整块大芯片而是由多个小芯粒通过先进封装拼在一起不同芯粒之间可以有自己的缓存一致性和内存访问路径。从操作系统的角度看它可能会呈现出更多层次的NUMA同一封装内距离较近跨封装距离较远。以前相隔两个CPU的记忆体才有延迟差异现在一颗CPU内部就像小集群一样了。这对调度器的挑战更细但也给了更多调控手段。第二个是CXL内存扩展。基于CXL协议的设备可以给服务器挂上低成本的大容量内存池。这些东西在操作系统视角里往往也会变成新的NUMA节点但它们的延迟可能比本地内存高出好几倍。你启用这部分内存本质上是在“容量”和“性能”之间做抉择。未来会出现更多异构内存层次的NUMA系统软件如果不能理解拓扑性能会非常不可控。这些趋势只会让“NUMA思维”更加重要。现在如果不去理解节点距离、本地性、一致性等到下一代硬件出来可能连基础性能都优化不明白。5.3 做性能工程的几个心得文章写到这里分享几个我在实践中的个人习惯算是给读者的一点礼物。第一永远带着拓扑信息看数据。不要只看平均延迟要拆开看这个线程跑在哪个CPU上它分配的内存页面在哪个节点如果平均延迟高远程访问占了多大比例这些信息变化的方向往往比具体数值更快告诉你问题所在。第二默认不开自动NUMA平衡。对数据库、内存缓存、HPC这类负载显式策略通常胜于内核的通用策略。自动平衡是给通用桌面和未知负载兜底用的不适合追求稳定延迟的重要服务。第三用“分片”而不是“共享”来设计高并发内存结构。共享意味着谁都能访问也就意味着谁都可能踩到远程节点去访问别人家的内存。分片之后每个线程守着本地的一份数据既降低锁竞争又天然契合NUMA的物理格局。第四测试时不要只压单个节点。真正的生产环境往往是两路、四路甚至更多路。准备一个双路机器做基准专门看跨节点访问对性能的影响这样你写的代码从一开始就带上了NUMA意识。这些心得不是教科书上抄的都是我踩过坑之后慢慢定型的操作习惯。性能调优和硬件架构不是死记硬背的两门课而是同一个问题的两面你要么理解硬件的“偏心”然后善用这种“偏心”要么对它视而不见然后在夹缝中被它悄悄惩罚。最后再补一句靠谱的话别把超线程、乱序执行、NUMA当作可以盲目信任或者盲目关闭的黑盒。每个机制都有它的适用边界。实验是判断真理的唯一方式——绑一下看数据不绑再看数据。用数据说话比我在这写再多都有说服力。