资讯动态

NUMA架构下C++并行算法性能优化:负载分配与工作窃取

发布时间:2026/10/21 7:03:02 来源:尧图企业网站定制
写这篇东西的起因是我在一台双路服务器上跑一个并行计算任务时亲眼见证了同样的代码跑出4倍性能差距。那段差距就来源于一个很容易被忽略的事实这台机器是多NUMA节点的而我写的std::ranges并行算法压根没往这个方向想过。很多人把std::ranges、std::execution::par当成了“并行万能药”但恰恰是在多路服务器、云主机大规格实例这些典型NUMA环境里负载分配策略和调度器的行为会决定性能下限有多高、上限有多远。这篇文章不打算停留在语法层面我会把范围算法、并行执行策略、工作窃取机制这三件事放在NUMA架构这个真实背景下拆开讲分析负载分配背后真正影响性能的因素顺带分享我在实际项目中踩过的坑和反复调整后固定下来的方案希望对同样在服务端做C并行计算的同行有一点点参考价值。1. 先把三件事拆清楚ranges算法、并行执行策略和NUMA的关联1.1 从一段常见的并行代码说起先看一个很典型的例子逻辑非常简单读入一个巨大的浮点数组对每个元素做一轮正弦余弦变换输出到另一个数组。传统写法加上并行执行策略之后长这样#include algorithm #include execution #include ranges #include vector int main() { constexpr std::size_t N 100000000; std::vectordouble input(N, 1.0); std::vectordouble output(N, 0.0); auto indices std::views::iota(0uz, N); std::for_each(std::execution::par, indices.begin(), indices.end(), [](std::size_t i) { output[i] std::sin(input[i]) * std::cos(input[i]); }); }这段代码在单路八核的台式机上跑得很顺畅多核利用率也相当理想。可一旦搬进双路Xeon、四路EPYC这种服务器里性能就可能出现雪崩式下降甚至比单线程还慢。原因不复杂std::execution::par只是给了标准库一个“你可以并行”的许可至于工作怎么切分、切完的任务怎么发给各个线程、各个线程从哪块内存读数据标准库完全不承诺。这些都由底层的线程池和调度器决定。std::ranges在这里承担的角色是“懒惰视图抽象”。iota生成的索引序列不需要一次性物化到内存里而是在迭代时按需生成。这让调度器有机会把整个索引空间切分成任意大小的块分发给不同线程处理。问题恰恰出在这个“任意”上如果不考虑内存物理分布那切分就纯凭调度器心情。而这个“心情”在NUMA架构下就是性能波动的最大来源。1.2 NUMA到底影响了什么NUMA的全称是Non-Uniform Memory Access非一致性内存访问。这个词的核心含义是现代多路服务器里每个CPU插槽自带一组内存控制器直连的内存条只能被本插槽的CPU以最低延迟、最高带宽访问访问别的插槽的内存就得走跨插槽的互连链路比如UPI延迟通常会高出一到两倍带宽也可能腰斩。关键问题在于操作系统不会替你操心“这段数据放在哪个节点”。内存页第一次被哪个CPU写物理页就落在那个CPU的本地内存上这个策略有个专门的名字叫first-touch。第一次接触决定归属之后哪怕这个页被其他节点的线程频繁读取它也不会迁移。这个特性对并行算法有残酷的后果假设线程A绑在Node 0线程B绑在Node 1数组初始化这段代码如果由线程A串行完成那么整块大数组的物理页全部落在Node 0等并行阶段开始Node 1的线程B去算后半段数据时每次访问都要穿过UPI去Node 0拿数据。计算量越大跨节点访问的代价就越明显。在做项目之前我特意用一个小工具量过本地与远程内存读写带宽的差距。在这台双路机器上本地读带宽大约能到80GB/s跨节点读只有45GB/s延迟也从不到100ns涨到近200ns。算法如果本身是大规模扫描型负载差距直接体现在吞吐上。1.3 标准库并行算法在NUMA面前是“睁眼瞎”这里必须说一句大实话标准库的并行算法设计之初就带有强烈的“单节点假设”。std::execution::par表达的是算法级并行语义它会向默认线程池提交任务但不会感知当前进程绑在哪个NUMA节点上更不会根据数据所在节点来决定任务该发给哪条线程。换个直白的说法std::for_each(std::execution::par, ...)在NUMA环境里表现得像一个个头巨大的盲人——它能看到任务空间能看到线程池但看不到内存物理分布。这本身不是标准的缺陷因为标准库的车轮不该为所有硬件细节量身定制但它意味着一旦你追求极致性能就必须在标准库之上再做一层调度策略。实际开发中我总结出一条经验标准库并行算法适合三种场景。第一计算量远大于访存量的场景比如CPU密集的数值变换第二数据规模远小于NUMA节点内存容量的场景跨节点访问的影响被缓存吸收第三运行环境是单插槽虚拟机宿主机已经把NUMA拓扑隐藏到几乎不可见。除此之外涉及大规模内存扫描、归约、排序的并行代码单纯依赖std::execution::par很容易出现性能折损。2. 负载分配策略在NUMA环境中的三个层次2.1 静态分块最简单却最容易踩中内存拓扑陷阱负载分配第一个层次是静态分块。调度器拿到std::ranges视图后按迭代器数量均分成若干区间每个区间绑定一个工作线程。举个例子100个任务、4条线程那就是线程0处理前25个线程1处理接下来25个以此类推。这种做法优点是切分开销几乎为零缺点则是完全不关心每个块的数据落在哪。放到NUMA环境里静态分块最典型的问题是“索引顺序假设”。假设任务索引和数据下标一一对应而且数据整体按顺序初始化那么线程0负责索引0到24线程1负责25到49。如果整个数组物理页都落在Node 0上那么无论哪个线程处理哪个块访问的都是Node 0的远端内存。静态分块根本没有能力根据内存页的真实分布去对齐任务块边界。我做过一个实验在一块4GB的double数组上跑std::transform使用std::execution::par静态分块。初始化阶段绑定单线程在Node 0完成然后并行阶段用四线程跑。结果发现四线程的吞吐反而只有单线程的1.6倍远低于理论上的4倍。把数组改为每个线程初始化自己负责的区段让first-touch把数据物理页散到两个节点再把线程绑定到各自节点同样四线程吞吐立刻提升到3.5倍左右。这段对比赤裸裸地说明静态分块本身没问题有问题的是“数据与线程的物理位置不一致”。处理静态分块时有个实用技巧尽量让每个NUMA节点上的线程只创建并初始化它自己后续要处理的数据区段。最理想的情况是数组每段初始化的代码和最终并行计算使用同一组线程、同样的节点绑定。这套做法还有个名字叫NUMA-aware initialization本质上就是把first-touch这把双刃剑握在自己手里。2.2 动态调度可变块大小与自适应的代价负载分配第二个层次是动态调度。最常见的实现是维护一个全局任务队列线程每处理完当前块就去队列领取下一块。块大小可以是固定值也可以根据运行时的执行速度动态调整。动态调度牺牲了一点点调度开销换取更均衡的负载分布。尤其在任务执行时间参差不齐的场景里动态调度能把原本被拖慢的尾巴缩短。但动态调度在NUMA环境下有个隐患线程会在不同时间段处理来自不同内存节点的数据块。如果线程本身绑在一个节点而任务块的数据在另一个节点它就陷入了“任务位置漂移”。这种漂移会让本来已经跨节点访问的数据变得更跨越因为调度器根本不跟踪数据的物理位置。更隐蔽的问题是全局任务队列本身会成为所有线程的竞争热点。队列位于某个特定节点的内存上所有线程往里取任务时都绕不开原子操作。当线程数不多时影响不大可一旦线程数超过16队列的缓存行争用就可能成为新的瓶颈。我在一个项目中观察到全动态调度下26线程跑3亿元素的归约任务性能从40ms退化到70ms检查后发现就是全局原子计数器在作祟。如果必须使用动态调度我建议给每个NUMA节点单独建一个任务队列线程只从本地节点队列取任务只有当本地队列空了才去远端偷。这样既保留动态负载均衡能力又把跨节点任务迁移控制在一个相对小的范围内。2.3 工作窃取高性能调度器的双刃剑负载分配第三个层次也是目前主流并行运行时采用最多的方式就是工作窃取。每个线程维护一个本地双端队列新的任务压入队尾自己从队尾取任务执行这样能最大程度利用缓存局部性。当某个线程的本地队列空了它会随机或轮询地挑一个其他线程的队列从队头偷走一批任务。这个设计的初衷是余额较忙的线程不会长时间空闲负载自然向空闲线程回流。工作窃取在单NUMA节点上表现近乎完美是线程池调度器的默认选择。但到了NUMA架构下工作窃取有一个致命的暗面偷任务的同时把“内存位置的关联性”也偷走了。一个绑定在Node 0的线程去Node 1偷了一批任务这批任务处理的是Node 1内嵌的数据页。结果就是Node 0的线程一边跨节点读数据一边可能跟Node 1的线程争抢同一块内存区域。数据引用模式彻底乱了。很多商业线程池和第三方调度库都实现了NUMA感知的工作窃取做法通常是先按NUMA节点划分工作线程组组内正常偷窃组间只在负载严重不均衡时才允许跨节点偷取并且偷取的比例和频率受到严格限制。这个思路很实用我后来在自己实现压力器时也加了相似逻辑本地队列空转超过某个阈值次数后才尝试跨节点偷。然而必须说明的是C标准库自带的并行算法实现并没有完整暴露NUMA感知的定制接口。标准库的线程池究竟在多大程度上做了节点感知完全取决于标准库版本和平台实现。你拿std::execution::par跑在Linux上和跑在另一个操作系统上底层的负载分配策略可能是完全不一样的。因此凡是追求可复现性能的场景我都建议不要依赖具体标准库实现的运气而是明确地控制任务划分方式和线程绑定。3. 实操一个NUMA感知的std::ranges并行负载分配实验3.1 实验环境与基础工具为了说清楚问题我做了一个完整的对照实验。硬件是双路服务器每路16个物理核心开启超线程后共64个逻辑CPU内存总计256GB每个节点128GB。操作系统是常见的Linux发行版内核版本较新天然支持NUMA。编译器选用最新版本启用C20和C23模式开启-O3 -marchnative。实验任务跟上文那段示例类似对一个1亿元素的double数组执行一个计算密集的函数函数内部包含多次乘法和三角函数调用确保CPU强度大于访存强度。这样设计有一个好处计算过程不容易被内存带宽卡死能更清晰暴露调度策略引发的差异。工具方面我用到了三个必须的东西。第一是numactl --hardware用来查看节点编号、CPU范围和内存范围第二是perf stat用来观察cache-miss和跨节点访存相关的指标第三是likwid-perfctr如果你熟悉调优用它看内存带宽非常舒服。不要求所有人装likwid但至少numactl是必须的它是整个实验的基石。实验中最重要的一个前置操作是把进程绑定到整个机器而不是某一个节点。很多人用numactl时习惯绑定单节点这在单节点测试里没错但做跨节点实验时就失去意义了。我统一用numactl --interleaveall的方式让物理页尽量均匀分摊到两个节点再通过代码里自带的CPU亲和性设置明确线程走哪个节点。3.2 基础版并行范围代码与线程池默认行为第一步我直接写了最朴素的std::ranges并行代码就是本篇开头那段。它的核心是构造一个iota索引视图交给std::for_each并行执行。标准库线程池会分配线程各线程处理一部分索引。此时完全没有线程绑定系统默认允许线程自由迁移。调度器很可能把线程派到不同节点也可能集中在同一节点全看操作系统的心情和负载情况。运行结果比预期更戏剧性。第一组跑下来耗时约420ms第二组重新运行却是280ms第三组又变成370ms。不是算法不稳定而是线程调度和页放置位置在某些运行中形成了灾难性组合。节点间负载不均时某个节点的内存带宽被耗尽另一个节点却闲置着。这种随机性对于生产环境是不可接受的所以我立刻意识到必须在代码层面干预。干预的第一步是固定线程亲和性。使用sched_setaffinity把每条第线程绑定到一个CPU然后根据CPU编号反查NUMA节点再把数据分段初始化在对应CPU的节点上。这部分代码不复杂但需要小心设置CPU集合掩码时不能把不同线程设到同一个核心上。超线程环境下尤其要留意同一个物理核心的两个逻辑CPU共享计算资源绑定到一起会直接造成资源争抢。#include pthread.h #include sched.h #include numa.h #include vector #include cstdint void pin_thread_to_cpu(std::size_t cpu_id) { cpu_set_t mask; CPU_ZERO(mask); CPU_SET(cpu_id, mask); pthread_setaffinity_np(pthread_self(), sizeof(mask), mask); } std::size_t node_of_cpu(std::size_t cpu_id) { // 通过/lib或者numa库查询cpu对应的node return numa_node_of_cpu(cpu_id); }这里有个重要的理念需要说明并行算法本身不管理线程那你就要在进入算法之前把线程环境准备好。一种做法是自己创建线程再在线程内调用std::for_each另一种做法是通过自定义执行策略把任务分块再在每块内部手工绑定CPU。两者相比前一种更直观但会失去标准库线程池复用的优势后一种可控性最好也是我最终采用的方案。3.3 自定义范围切分按NUMA节点分批这里遇到一个很现实的问题std::ranges的iota视图是纯粹的逻辑索引序列它并不直接暴露“按节点分组”的能力。要把任务分到两个NUMA节点愿意在范围层通过std::views::chunk把索引切成大块每个大块分配给一个节点而每个大块内部的元素再均匀摊给该节点上的多个线程。namespace views std::views; auto all_indices views::iota(0uz, N); auto node_chunks all_indices | views::chunk(N / 2); for (auto node_id : {0u, 1u}) { auto chunk node_chunks[node_id]; std::for_each(std::execution::par, chunk.begin(), chunk.end(), [](std::size_t i) { // 线程已经被绑定到node_id上的cpu output[i] heavy_compute(input[i]); }); }这段代码绵藏一个隐藏问题std::execution::par会用线程池里的全部线程执行每个大块而每个大块的执行线程可能横跨两个节点。也就是说即使数据已经按节点切好执行线程依然可能跑在对端节点。你需要在每个大块的执行体里临时调整线程的CPU亲和性。这个调整不能在线程池线程内部随意做因为那会干扰线程池对其他任务的调度。更稳妥的实践是绕过默认线程池直接创建两组线程每组线程绑定一个节点组内再并行处理属于自己节点的范围。虽然这种方法从字面上不再是“直接调用std::execution::par”但它的内核仍然大量使用std::ranges视图和算法只是执行载体由我控制。void run_node_parallel(std::size_t n, std::size_t threads_per_node) { std::vectorstd::thread threads; threads.reserve(2 * threads_per_node); for (std::size_t node 0; node 2; node) { for (std::size_t t 0; t threads_per_node; t) { threads.emplace_back([, node, t] { pin_to_cpu(cores_of_node[node][t]); auto begin node * (n / 2); auto end begin (n / 2); for (std::size_t i begin; i end; i) { output[i] heavy_compute(input[i]); } }); } } for (auto tx : threads) tx.join(); }这已经是最直接的NUMA感知实现。实际测试中这种“两节点分别并行、各自处理本地数据”的版本在1亿元素计算任务上的耗时稳定在95ms左右相比默认线程池跑出的280ms到420ms提升了三到四倍。而且运行五次的结果都落在95到98ms之间可复现性极佳。3.4 工作窃取实测无感知窃取导致的性能雪崩做完这个稳定版本之后我计划进一步提高动态负载均衡能力于是实现一个简单的可窃取任务队列。设计是每个节点维护一个任务队列线程先处理本地队列本地队列空了就去别的节点队列偷任务每次偷取一个中等大小的块比如32个元素。实测效果是灾难性的。跨节点偷取任务后Node 0的线程去处理Node 1数据延迟立刻升高同时两个节点的内存控制器都出现负载波动。整体耗时从95ms退化到约180ms还不如不做窃取。原因一是任务本身的粒度太小32个元素的计算量远不足以抵消一次跨节点内存访问的代价原因二是偷取过程破坏了原有的数据局部性缓存命中率大幅下降。从这次失败中我提炼出一个规律工作窃取要想在NUMA环境下生效必须把任务块粒度调大至少要到几万元素让窃取动作发生的频率降低一个数量级。同时要设置窃取阈值本地队列连续空转超过100次才允许触发一次跨节点窃取。经过参数调整后运行时间从95ms维持在稳定的100ms左右说明跨节点偷带来的额外收益极其有限而它带来的性能损失倒是实实在在的。3.5 实验数据汇总负载分配策略的真实代价把几组实验结果列为表格可以直观看到策略差异方案线程绑定数据初始化窃取策略平均耗时波动范围默认线程池par无串行初始化无明确策略360ms280~420ms默认线程池par无每节点初始化无明确策略210ms180~240ms自定义节点线程按节点绑定对应节点初始化无窃取96ms94~98ms自定义节点线程按节点绑定对应节点初始化小粒度窃取180ms160~210ms自定义节点线程按节点绑定对应节点初始化大粒度低频窃取99ms96~102ms表格里的差异不是一星半点而是成倍的变化。默认线程池无感知的分块方式在当前场景下基本是不可用的。而“数据本地化节点线程复制”的组合才是NUMA架构性能的钥匙。需要强调的是这个实验针对的是内存扫描型算法。若换成纯CPU计算型比如对一组完全存在寄存器或L1缓存里的数据做循环NUMA的影响会小很多负载分配策略的重心就应该转向计算负载均衡。因而真实项目中负载策略的选型一定基于算法访存模型来判断不能一概而论。4. 常见问题排查与一线避坑实录4.1 现象并行效率还不如单线程CPU占用却很高处理项目时最常见的问题之一就是把std::for_each(std::execution::par)用于一个大数组扫描结果发现性能跟单线程差不多甚至更差但CPU使用率却接近100%。这种矛盾感通常意味着线程们都在等内存内存带宽或者跨节点访存已经成为了瓶颈。排查时第一件事是用perf stat看两个关键指标。第一个是cache-misses如果每千条指令缓存缺失达到几十甚至几百那说明数据基本都在远处。第二个是任务执行期间的内存带宽使用likwid-perfctr -g MEM实测。如果内存带宽已经接近节点硬件极限那就说明问题不再是调度而是算法本身没有发挥出计算强度优势。处理手段也不复杂要么把任务分块到L2缓存能装下的规模同时利用ranges视图的流水线式处理让数据流在缓存里循环而不是反复从内存拉取要么就完全重新考虑数据分布把初始化改成NUMA感知的分节点初始化。凡是第一步就跨节点大量搬运数据所有后续优化都是在修补根本性漏洞。4.2 现象运行时间抖动剧烈同一份输入多次结果不一样如果你发现并行代码每次运行时间都不同幅度还很大第一反应不要责怪操作系统负载。先检查数据页放置。因为first-touch是动态的如果初始化代码里存在并发写不同运行里线程执行顺序可能不同页放置也随之改变。解决方案是显式确定每一块内存哪个线程先接触并把初始化和计算阶段绑定到同一组节点上。另外要留心超线程带来的干扰。超线程会共享执行端口和缓存层线程调度器如果把两个任务塞到同一个物理核心的两个逻辑CPU上计算资源互相挤占也会造成不稳定。用sched_setaffinity固定线程到不同物理核心位置最好配合numactl把每个逻辑CPU的编号梳理清楚排除超线程带来的度量误差。4.3 现象工作窃取启动了性能反而下降工作窃取在NUMA环境里并非天然有益。它本质上是内存漂移和负载均衡之间的一个换手。如果你的任务执行时间平均且可预测静态绑定可以做到比动态窃取更好。原因在于窃取本身有代价任务迁移后的缓存预热也需要时间。一条非常实用的经验只有当你观察到负载不均衡已经超过20%时才值得付出跨节点窃取的成本否则宁可让部分线程空转也不要让数据到处乱飞。“空转”听起来浪费但在NUMA环境下空转线程不消耗内存带宽也不污染缓存反而是件好事。我在项目里曾经做过一个混合策略每个节点内部做动态调度节点之间做静态轮转只有在极端情况下也就是单节点队列空转超过5000次才允许跨节点取任务。这样的收益在99%的常规负载下达到最优而那1%的超不平衡负载也有兜底方案。4.4 几个容易被忽略但必须注意的细节首先是std::ranges::views::iota的类型宽度。默认情况下iota(0uz, N)会生成size_t类型的索引宽度是64位这个没问题。但如果你写的是views::iota(0, N)中间的0是int在N超过20亿时会溢出。索引溢出后访问的数组下标完全错乱这是我在代码评审里经常抓到队友的问题。其次是std::execution::par_unseq和par的区别。par_unseq允许向量化也让编译器有更大的表达空间。但在需要精确控制线程和访问顺序时par_unseq可能存在难以追踪的行为差异所以我建议在NUMA实验中统一使用par不要随意升级到par_unseq除非你确认核心计算可以被安全地向量化并行执行。最后是NUMA库与线程库的初始化顺序。使用libnuma做内存分配和CPU绑定的时候要在创建线程之前调用numa_on_available检查支持情况。如果调用了set_mempolicy之类设置后又用标准线程库创建线程部分实现可能会重置内存策略。这个坑曾经让我排查了整整一个下午早期初始化是永远正确的做法。4.5 从标准库到自研调度什么时候离开默认执行策略默认执行策略的便利性是无价之宝它能用很少的代码写出可移植的并行程序。但在NUMA场景下这种便利性也意味着你放弃了细粒度控制。我认为关键在于分清使用场景而不是一棍子打死。如果你的目标是写明确、可复用、性能可预测的库代码且运行环境确认为多路服务器那就多花一点时间自己管理线程绑定和分块逻辑。标准库的ranges视图可以用算法也可以用但默认线程池的“盲”分块策略最好替换掉。如果你的目标是快速完成一个不过分要求性能的工具脚本那么std::execution::par配合简洁的ranges流水线仍然非常高效前提是你能接受它带来的性能波动。这篇文章的核心建议用一句话总结就是在NUMA架构上并行算法性能的表象是CPU利用率里子是数据位置和任务位置的匹配度。谁掌握了这一点谁就能在同等硬件上轻松跑出数倍优势。最后一个小经验也是我每次在项目交接文档里都会写的做任何NUMA优化之前先用最简单的方式量一量当前进程的内存带宽与cache-miss指标再决定要不要引入复杂的调度策略。数据不会骗人指标说得清楚的东西最终优化方向也就能定下来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑