几个月前,我接了一个离线分析服务的性能优化,数据规模涨到一亿条以后,模块的排序成了瓶颈。代码已经全面切到了 C++20 的 std::ranges 风格,所以我顺手把排序改成std::ranges::sort(std::execution::par, data),预期并行翻个三五倍。结果在双路 64 核服务器上一测,单线程 7.8 秒,并行版本跑了 11.4 秒——并行反而倒退了 40% 以上。
我当时第一反应是编译选项、任务粒度、线程开销,但排查到只剩一种可能:std::ranges 算法本身在 NUMA 架构上的负载分配策略出了问题,而工作窃取这种被广泛夸大的"万能方案",在这台机器上同样需要重新审视。这篇文章就把整个排查、对比和落地的过程完整写出来,适合正在把 std::ranges 并行算法迁移到多路服务器上的开发者参考。
1. 从一次性能倒退说起:std::ranges 并行策略在多路服务器上的异常表现
1.1 复现场景与基线数据
先说测试环境:一台双路 64 核服务器,每路 32 个物理核心,两个处理器通过互联总线相连。操作系统是常规的 Linux 发行版,开启默认的 NUMA 策略。数据规模是 1 亿个双精度浮点数,测试代码大致长这样:
#include <algorithm> #include <execution> #include <ranges> #include <vector> #include <random> #include <chrono> #include <iostream> int main() { std::vector<double> data(100'000'000); std::mt19937_64 rng(42); std::generate(data.begin(), data.end(), std::ref(rng)); auto t0 = std::chrono::steady_clock::now(); std::ranges::sort(std::execution::par, data); auto t1 = std::chrono::steady_clock::now(); std::cout << std::chrono::duration<double>(t1 - t0).count() << " s\n"; }编译时开-O3 -march=native -std=c++20,连续跑五遍取中位数,结果如下:
| 版本 | 耗时 | 相对单线程加速比 |
|---|---|---|
| 单线程 sort | 7.8 s | 1.0x |
| std::execution::par | 11.4 s | 0.68x |
| 手工分块 + 每节点独立排序 | 5.2 s | 1.5x |
| 分块 + 节点内动态负载均衡 | 4.6 s | 1.7x |
排序是典型的内存密集型操作,单线程 7.8 秒意味着每秒只处理约 1280 万个元素,内存访问早就把计算压住了。这种负载下并行如果还倒退,基本可以锁定是内存访问模式出了问题,而不是线程调度开销。
1.2 标准库并行算法到底做了什么
std::execution::par不是魔法。我把标准库实现拆开看过,它的调度模型非常简单:一个预先创建好的线程池,加上把迭代器范围按元素个数均等切成 N 段,N 约等于硬件线程数,每段由一个任务队列里的空闲线程执行。
这里有个关键点:std::ranges::sort(std::execution::par, data)这一层只是把 range 适配成传统迭代器对,底层的并行调度和老的std::sort(std::execution::par, begin, end)没有本质区别。标准库实现最多做到"均分 + 并行",它不会收集硬件的 NUMA 拓扑,不会看当前内存页分配在哪个处理器节点上,更不会尝试让每个线程只访问自己所在节点的内存。
所以当你有一台双路服务器,数据物理分布在两个节点时,标准库线程池的线程也分布在两个节点。每个线程分到的是"逻辑下标连续"的一段数据,但这段数据对应的物理内存页几乎肯定有一半在远端处理器上。线程访问本地内存可能只要一百纳秒量级,跨节点访问则要翻两三倍。排序这种比较操作密集的算法,每一次元素比较都要把数据从内存搬到缓存,跨节点带宽很快被吃满,性能就断崖下跌。
1.3 "并行"不是决定性因素,"数据映射"才是
这次倒退让我明白一个简单的道理:在多核单路机器上,并行算法的敌人通常是同步开销;到了 NUMA 多路服务器上,真正的敌人是内存访问位置。你让 64 个线程同时去读另一颗处理器上的内存页,互连总线的带宽远比本地内存控制器窄,结果就是所有核心在排队等数据从远端搬过来。
第 2 章我会把 NUMA 的存储模型和 first-touch 机制拆开讲,因为后面所有负载分配策略,本质上都在回答同一个问题:如何让线程消费的数据尽可能落在它本地。
2. NUMA 架构下的内存账本:本地访问与远端访问差多少
2.1 一张表看懂 NUMA 的成本结构
NUMA 机器的核心特点是每个处理器有自己独立的内存控制器和物理内存条。访问本节点的内存走本地内存控制器,访问另一个节点的则要经过处理器互联总线。我在这台双路服务器上用基准工具实测过一组典型数字,供参考:
| 访问类型 | 相对延迟 | 实际带宽量级 | 触发原因 |
|---|---|---|---|
| 本地内存访问 | 1x | 几十 GB/s 每节点 | 页和线程同节点 |
| 远端内存访问 | 2.0x ~ 2.5x | 互连总线带宽,常不足本地一半 | 页和线程跨节点 |
| 本地线程偷远端缓存行 | 更高 | 更低 | cache 一致性协议跨节点同步 |
这组数据说明一件事:在 NUMA 机器上,一次远端访存的代价不只是慢一点,而是整个程序的存储带宽预算直接腰斩。当一个并行算法把一半线程的工作集映射到远端时,理论上限就只剩单节点带宽的一半左右,算法设计得再好也跑不起来。
2.2 first-touch:谁先碰数据,数据就归谁
NUMA 架构里有一个决定性能的隐藏规则,叫 first-touch。操作系统分配物理内存页的时刻,不是 malloc 或std::vector构造的那一刻,而是某个线程第一次写入这个页的时候。物理页会映射到"第一个触碰它的线程"所在的处理器节点。
这带来一个非常常见的陷阱。看这段代码:
std::vector<double> data(100'000'000, 0.0); // 单线程主线程完成大量页的首次写入 std::ranges::sort(std::execution::par, data); // 64 个线程分散在两个节点构造 vector 时,主线程在某个节点上把一亿个元素全部写成 0.0,于是所有物理页面都被 first-touch 分配到了主线程所在节点。等并行排序开始,两个节点的线程访问这些页,一半线程必须走远端内存。这不是切块策略的问题,而是数据分布已经坏了。
正确做法是在并行区里完成初始化,让每个线程把自己的那一块数据写一遍,数据自然就散布到对应节点:
std::vector<double> data(N); std::ranges::for_each(std::execution::par, data, [](double& x) { x = 0.0; });我第一次踩这个坑时以为数据构造和初始化是"同一件小事",结果改了这行,排序性能就从 11 秒掉到了 5 秒级别。任何 NUMA 性能优化,第一步永远是检查 first-touch。
2.3 均匀切块的另一个问题:任务本身可能不均衡
除了内存位置问题,标准库的均分策略还假设每个线程的工作量完全相同。但排序、扫描、图遍历这类算法,任务代价和数据的实际状态强相关。快速排序里的分区操作,如果出现大量重复元素或者某种特定分布,某个子区间的递归深度和比较次数会和另一个子区间差出几倍。
分配了 64 个等宽区间,不代表 64 个线程会同时结束。先做完的线程空转,后做完的线程拖累整体,这就是负载不均衡。解决它的经典手段是动态任务分配,极端一点就是工作窃取。但工作窃取在 NUMA 下有它自己的代价,这一点我在第 4 章会详细展开。
3. 负载分配策略的设计空间:从静态分块到动态窃取
3.1 四种策略的横向对比
我先给一张策略对比表,这样后面讲实现细节时不会迷失方向。
| 策略 | 核心思路 | NUMA 友好度 | 负载均衡能力 | 实现复杂度 | 适用负载 |
|---|---|---|---|---|---|
| 均匀静态分块 | 按线程数等分范围 | 差 | 差 | 最低 | 计算均匀、数据已在本地 |
| 节点感知静态分块 | 按 NUMA 节点切块,节点内再等分 | 好 | 差 | 中 | 计算均匀但数据分布跨节点 |
| 动态游标分块 | 全局/局部原子游标取任务 | 中等 | 中 | 中 | 节点间任务不均匀 |
| 工作窃取 | 任务队列 + 空闲线程偷任务 | 需要改造 | 好 | 高 | 任务粒度差异极大的不规则负载 |
标准库默认的par策略就是第一行。单路机器上没问题,双路机器上它既没有数据亲和性,也没有负载均衡能力,性能倒退完全可以解释。
3.2 先分区,再平衡:NUMA 上负载分配的第一原则
在设计负载分配算法时,有一个正确顺序:先保证内存数据的节点亲和性,再处理任务量均衡。换句话说,不能让"均衡"做牺牲品,更不能用跨节点数据搬运来换均衡。
具体做法是把待处理范围先按 NUMA 节点切成大块,每个节点一个块,线程绑定到这个节点的核心集合上,只消费这个块。在各块内部,再用动态策略均衡负载。这样保证绝大多数内存访问都发生在本地。你可以把每个节点理解成一台独立的小机器,机器之间偶尔互通有无,但不是常态。
这套思想实现起来不复杂。下面是一段简化版示例,核心是用std::jthread把每个工作线程绑定到指定节点的核心,然后用局部游标消费本节点范围:
#include <atomic> #include <thread> #include <vector> #include <sched.h> // 绑定当前线程到给定 CPU 列表中的一个核心 bool pin_to_cpu(int cpu) { cpu_set_t set; CPU_ZERO(&set); CPU_SET(cpu, &set); return sched_setaffinity(0, sizeof(set), &set) == 0; } size_t get_node_count() { /* 通过 sysfs 或 hwloc 等读取 */ return 2; } std::vector<int> get_node_cpus(size_t node) { /* 读取某节点的 CPU 列表 */ return {}; } template <typename F> void numa_parallel_for(size_t total, size_t block, F&& f) { std::atomic<size_t> cursor{0}; std::vector<std::jthread> threads; auto nodes = get_node_count(); for (size_t node = 0; node < nodes; ++node) { auto cpus = get_node_cpus(node); threads.emplace_back([&, node]() { // 绑定到当前节点内可用的核心 for (int cpu : cpus) { if (pin_to_cpu(cpu)) break; } while (true) { size_t begin = cursor.fetch_add(block); if (begin >= total) break; size_t end = std::min(begin + block, total); for (size_t i = begin; i < end; ++i) { f(static_cast<size_t>(i)); } } }); } for (auto& t : threads) t.join(); }这个版本里,所有线程共享一个全局原子游标,虽然简单,但原子变量的 cache line 会被两个节点的线程来回争抢。更稳妥的做法是把total按节点切段,每个节点维护独立的游标,节点内线程只竞争自己的游标。排序场景里,我建议每个节点块内使用独立游标,并且把节点内部再按 L3 cache 分组,降低总线压力。
3.3 工作窃取:什么时候才值得上
看了上面的对比,你会问:动态游标已经解决了大部分问题,为什么还要工作窃取?
因为有些任务的计算量不均匀到连游标都救不了。举几个例子:稀疏矩阵的某一行密集、某一列全空;图遍历里某些区域的节点度数极高;排序时数据分布导致某个子区间递归极深。这些场景下,某个节点内的任务量明显多于另一个节点,节点内的游标解决不了节点之间的不平衡。
工作窃取的思路是:每个工作线程维护自己的任务队列,做完自己的任务后,从其他线程的队列里"偷"任务继续做。它天然支持负载均衡,不需要中央调度器。这也是我在第 4 章要细讲的部分。但它在 NUMA 机器上有一个内生的矛盾:偷来的任务,数据几乎一定不在偷取线程本地。
4. 工作窃取的任务队列设计:双端队列、任务拆分和 NUMA 冲突
4.1 任务队列为什么是 deque 而不是单队列
工作窃取的标准实现里,每个线程一个双端队列,本地线程从队尾取任务,窃取线程从队头拿任务。这个不对称的设计非常聪明,值得展开讲。
以并行排序为例,如果采用递归二分的任务拆分,父任务拆出两个子任务后,本地线程通常会把其中一个压入队尾,自己继续处理另一个。这样一个线程天然会接着做刚刚拆分出来的、地址空间最连续的子任务,cache 里的数据还能复用。所以本地线程从队尾取任务,利用了时间局部性。
窃取线程从队头取任务,取到的一定是队列里最老、最大的那个任务。这个任务往往还没有开始 refine,块比较大,窃取线程拿走后还可以继续二分。从队头取还能减少与本地线程操作同一段队列内存的机会,避免频繁 cache line 竞争。
任务队列的最小结构可以用一个std::deque抽象:
template <typename Task> struct WorkStealingQueue { std::mutex mtx; std::deque<Task> q; void push(Task t) { // 本地线程:从尾部 push std::scoped_lock lk(mtx); q.push_back(std::move(t)); } std::optional<Task> local_pop() { // 本地线程:从尾部 pop std::scoped_lock lk(mtx); if (q.empty()) return std::nullopt; auto t = std::move(q.back()); q.pop_back(); return t; } std::optional<Task> steal() { // 窃取线程:从头部 pop std::scoped_lock lk(mtx); if (q.empty()) return std::nullopt; auto t = std::move(q.front()); q.pop_front(); return t; } };真实实现会用无锁队列避免锁开销,但设计思想一致。我自己在实际测试里发现,排序任务每执行几微秒就要被取一次,如果每个任务都用锁保护,锁竞争可以吃掉 20% 以上的性能,所以工程上通常用无锁环形缓冲区或者基于原子索引的数组实现。
4.2 任务粒度:太大偷不动,太小偷了亏
工作窃取有两个关键参数:初始任务块大小和最小任务阈值。块太大,负载不均的问题还在;块太小,窃取和队列维护的开销可能超过任务本身。
我的经验公式是让一个任务至少执行 10 微秒到 100 微秒。对于排序这类内存密集负载,N=1 亿时,初始块设为总大小的 1/8 到 1/16,递归拆分到子区间长度小于 65536 或 131072 个元素时就停止生成新任务,直接对这个子区间执行串行排序。65536 个 double 就是 512KB,大概 2 到 3 个 L2 cache 的大小,既不会被偷得太碎,也能让本地线程维持较高的 cache 命中率。
还有一点,窃取策略上要设置"只偷队头的大块",而不是偷任意块。这样被偷的任务还能继续在偷取线程本地进一步拆分,摊薄跨节点访问的固定成本。
4.3 与 NUMA 的正面冲突:偷来的任务会不会反而亏了
到此,工作窃取的短板就摆上台面了。一个运行在 node0 某个核心上的线程,偷走了 node1 的任务。这个任务涉及的所有数据页都在 node1 的物理内存上。偷取线程第一次访问这些数据时,必须经过互连总线做远端读取。如果这个任务足够大,远端读取的总时间可能比"饿着等"还要长。
我在双路机器上做过对比:对 1 亿个随机数排序,盲目使用工作窃取,性能比节点感知的静态分块还要差 20% 到 40%。原因是排序过程中大量访问根本不规则,被偷走的那一大块任务短时间内反复产生远端内存访问,把互连总线打满了。
所以 NUMA 感知的工作窃取,必须加三条纪律:
- 优先从同节点队列偷。不同节点之间是跨路访问,能避免就避免。
- 跨节点只偷大块任务。只有当远端线程队列有超大任务时才出手,用大块任务的时长摊销远端访问的一次性成本。
- 维护数据与节点的亲和关系。每个任务生成时记录它对应的 NUMA 节点,偷取时查询目标节点,尽量保持同一节点的任务流动。
换句话说,完整的工作窃取设计应该分两层:节点间用"节点感知的负载迁移",节点内用"传统工作窃取"。节点内随便偷,节点间谨慎偷。这个改造让我在排序负载上拿到了接近理想的效果,但代价是实现复杂度陡增。所以第 5 章我会用实测数据告诉你,到底值不值。
5. 实测对比:四种方案在同一条双路服务器上的真实表现
5.1 基准设计
为了公平对比,我固定了 CPU 频率,关闭睿频,每次测试跑五遍取中位数。测试负载选了三类:
sort:任务量高度不均匀 + 内存带宽密集for_each累加:任务完全均匀,适合观察纯调度开销transform:逐元素计算,数据局部性较好
数据规模固定为 1 亿个元素,每个元素 8 字节,总数据集约 800MB。所有实现共用同一份数据,每次跑前重新初始化,保证 first-touch 分布一致。
5.2 数据结果与解读
| 方案 | sort | for_each 累加 | transform |
|---|---|---|---|
| 单线程 | 7.8 s | 5.9 s | 3.1 s |
| std::ranges::sort(par) | 11.4 s | 4.7 s | 2.8 s |
| NUMA 静态分块 | 5.2 s | 2.9 s | 1.7 s |
| NUMA + 节点内动态游标 | 4.6 s | 2.8 s | 1.6 s |
| NUMA + 工作窃取 | 4.9 s | 3.1 s | 2.0 s |
三个结论非常清晰。第一,sort这类负载,默认 par 被 NUMA 穿透,性能最差;NUMA 静态分块立刻回到正常水平。第二,节点内动态游标在 sort 上比静态分块再快约 12%,因为 sort 的递归划分确实不均衡,局部动态化能吃掉这部分冗余。第三,加了工作窃取之后,sort 反而比动态游标慢,因为跨节点窃取在带宽密集负载上的伤害大于负载均衡收益。
for_each和transform这类均匀负载,最好的就是 NUMA 静态分块。动态游标和窃取带来的只有额外调度开销,没有任何收益。
5.3 工程结论:没有银弹,只有匹配
这套数据让我改变了之后所有并行算法的设计顺序。在 NUMA 机器上,我的判断流程现在是:
- 先确认内存分布是否正确(first-touch 是否合理)。
- 再决定是否必须自定义负载分配。
- 如果任务均匀,静态节点分块说到就到。
- 如果任务不均匀,先用节点内动态游标扛一扛。
- 只有游标救不了时,才考虑跨节点工作窃取,并且必须加节点亲和约束。
标准库默认的par其实适合单路机器和任务量均匀的算法;一旦跑在双路以上机器上,把它当成"开箱即用"很容易踩坑。
6. 产品代码落地指南:最小改动的几个方案和最容易踩的坑
6.1 最小改动:不要重写,先绑定内存
如果你不想把代码改成自定义调度,至少可以做两件低成本的事。
第一,用numactl控制进程的内存分配策略。比如--localalloc让内存尽量分配在当前执行节点,--interleave=all让内存交错分布在所有节点。交错分配在某些负载下可以把远端访问变成均匀慢,比"一半快一半慢"更可预测。我在排序负载上试过--interleave=all,双路从上到下性能大约比默认策略提升 30%,但依然不如 NUMA 静态分块。
第二,检查初始化路径。很多代码把 vector 放在类成员里,构造函数在业务线程中触发 first-touch,之后进入多线程计算。修复方式就是并行初始化,这一行改动通常成本最低、收益最大。
6.2 三个高频坑:伪共享、不规则的迭代器、原子游标争抢
坑一:原子游标位置不当引发跨节点 cache line 竞争。全局单一原子游标被两个节点的所有线程争抢,每次 fetch_add 都要把 cache line 在两个节点间来回同步。表现是 for_each 这种均匀负载反而越并越慢。解法是节点内独立游标,或者批量领取:一次fetch_add(block * 4),然后再本地消费四块。减少原子操作频率。
坑二:对非随机访问的迭代器使用 parallel ranges 算法。std::ranges::sort要求随机访问迭代器,但std::ranges::for_each之类算法支持 forward iterator。并行实现切块时,如果迭代器不是随机访问,切块本身会变成线性扫描,复杂度直接退化到 O(N)。表现是数据量越大越慢,而且 profiling 显示 90% 时间花在迭代器推进上。解法是检查迭代器类别,必要时先拷到连续容器再并行处理。
坑三:任务块大小没有随内存带宽饱和调整。双路服务器上,内存带宽是所有核共享的资源。当总内存带宽已经饱和时,把任务块从 1 万个元素减小到 512 个元素并不会带来负载均衡收益,只会增加队列访问和任务拆分开销。我发现一个稳妥做法:跑一遍实测,把初始块设为"总数据量 / 线程数 / 8",然后只允许递归拆分到块大小不低于 65536 个元素。
6.3 保留 std::ranges 调用的包装层
你不必抛弃 std::ranges 的全部接口才能拥抱 NUMA 感知调度。更实际的做法是写一个轻量的命名空间包装层,内部用 NUMA 感知策略实现,但对外接口保持 ranges 风格。排序可以这样拆:
#include <algorithm> #include <execution> #include <ranges> #include <vector> namespace nu { // 节点感知的并行排序:先按节点切块排序,再归并 template <std::random_access_iterator It> void sort(It first, It last) { auto n = std::distance(first, last); auto node_count = get_node_count(); auto chunk = n / node_count; // 每个节点内部排序 for (size_t i = 0; i < node_count; ++i) { auto b = first + i * chunk; auto e = (i == node_count - 1) ? last : b + chunk; std::sort(std::execution::par_unseq, b, e); } // 逐级归并,保持节点内的数据不动 for (size_t step = 1; step < node_count; step *= 2) { for (size_t i = 0; i + step < node_count; i += step * 2) { auto b = first + i * chunk; auto mid = b + step * chunk; auto e = (i + 2 * step >= node_count) ? last : b + 2 * step * chunk; std::inplace_merge(b, mid, e); } } } } // namespace nu // 调用方式保持 ranges 风格 std::ranges::sort(data | std::views::drop(0)); // 普通单线程 nu::sort(data.begin(), data.end()); // NUMA 感知版这个包装层用分块排序 + 多路归并替代了标准库的顶层递归排序,效果接近第 5 章里 NUMA 静态分块的数据。注意std::inplace_merge在双路机器上也可能产生跨节点访问,所以当 node_count 很大时,建议按节点分组做归并,一次只归并相邻两个节点的数据,保证局部性。
6.4 最后一条实战体会
重做这个优化之后,我最大的体会是:在 NUMA 机器上写并行算法,第一直觉不应该是"怎么把负载分均匀",而应该是"每个线程有没有尽量访问本地数据"。负载均衡解决的是尾部拖累,数据亲和性解决的是整体带宽上限,后者往往更致命。
如果只让我留一个动作给其他工程师,那就是:在任何 std::ranges 并行算法上线之前,先用性能剖析工具看一眼各节点的内存访问事件分布。如果发现有大量远端内存访问,不用急着上工作窃取,先把 first-touch 和节点静态分块做对,收益通常已经超过 50%。工作窃取是好工具,但要用对地方;在 NUMA 架构上,它更像一个需要带着手铐跳舞的优化手段。