并行粒度如何影响SpMV性能?从U形曲线看调度开销与尾部效应
2026/9/16 3:09:57 网站建设 项目流程

做高性能计算的朋友应该都有过这种经历:一个程序性能上不去,第一反应是算法不够优,第二反应是并行度不够高,于是把任务切得越来越细,指望每个线程都吃得饱饱的。今天这个实验恰恰要打脸这个直觉——我用SpMV(稀疏矩阵向量乘)做了一个粒度扫描实验,结果画出了一条非常典型的U形曲线:任务粒度从粗到细,性能先升后降,中间有一个明显的“甜点区”。也就是说,粒度越细,不一定越快,反而可能慢得离谱。

这个实验的核心不在于“SpMV怎么实现”,而在于“并行粒度到底怎么取”。它牵扯到三个老生常谈却又特别容易被忽略的东西:任务调度开销、访存局部性、还有尾部调度。对做并行计算、性能优化、HPC相关工作的同学来说,这算是绕不开的一个坎。文章里我会把实验设计、数据复现、原因拆解、调优方法一条线讲完,最后附上我在实际操作中踩过的坑和排查思路。

1. 项目概述:SpMV 为什么会成为并行优化的试金石

1.1 先搞清楚 SpMV 到底在算什么

SpMV 全称 Sparse Matrix-Vector Multiplication,公式特别简单:

y = A * x

其中 A 是稀疏矩阵,x 是稠密向量,y 是输出向量。说人话就是:一个绝大多数元素都是 0 的矩阵,乘上一个普通向量。实际工程里,A 的稀疏度经常在 99% 以上,比如图计算里的邻接矩阵、有限元分析里的刚度矩阵、机器学习里 GNN 的传播矩阵,全是这种货色。

由于矩阵太稀疏,我们不可能像稠密矩阵那样开一个 rows × cols 的二维数组存,那样内存直接爆掉。工程上最常用的存储格式是 CSR(Compressed Sparse Row),它把矩阵拆成三个数组:

  • row_ptr:长度为 rows+1,记录每一行的起始位置
  • col_idx:长度为 nnz(非零元个数),记录每个非零元的列号
  • values:长度为 nnz,记录每个非零元的值

计算时就是遍历每一行,把非零元乘以对应的 x 分量,累加进 y[i]。这个逻辑用代码写出来大概是这样:

// y = A * x for (int i = 0; i < rows; ++i) { double sum = 0.0; for (int j = row_ptr[i]; j < row_ptr[i+1]; ++j) { sum += values[j] * x[col_idx[j]]; } y[i] = sum; }

看起来简单到不行,但它却是整个 HPC 领域最折磨人的核心计算之一。原因很简单:SpMV 是典型的访存密集(memory-bound)计算,每做一次乘加,都要额外读取 col_idx 和 values 两个数组,还要随机访问 x 向量,计算量和访存量的比值低得可怜。换句话说,它的瓶颈不在 CPU 算不过来,而在数据搬不过来。

1.2 为什么拿 SpMV 来研究并行粒度

正因为 SpMV 访存密集、计算稀疏,它对并行调度的“副作用”特别敏感。计算密集的任务切细一点,也就是多几次函数调用,影响几乎可以忽略;但 SpMV 这种任务,每个线程的任务量本来就很小,一旦调度开销、缓存失效、负载不均衡这些因素掺和进来,性能落差会非常明显。

而且稀疏矩阵的行长度极不均匀。有的行可能只有几个非零元,有的行却有几千个。这种“幂律分布”式的行结构,天然制造了负载不均衡的温床。你要想并行处理这种矩阵,就必须面对一个问题:任务怎么切,每个任务分多少行,分给谁。而这恰恰就是并行粒度(grain size)的范畴。

所以我选择 SpMV 作为实验对象,不只是因为它在实际工作中地位高,更因为它能把“粒度-调度-性能”这三者的纠结关系在一条曲线上完整暴露出来。这比用一堆理论讲并行粒度直观得多。

1.3 实验环境与实验方案设计

实验平台我用了双路 Intel Xeon Gold 的机器,总共 32 物理核,超线程关掉,编译器是 g++ 12,开了 -O3 -march=native -fopenmp。矩阵从 SuiteSparse Matrix Collection 里挑了一个中等体量的,行数大约 100 万,非零元算下来接近 2000 万,行长度分布很歪,既有零星的短行,也有几千个非零元的长行,非常适合拿来“折磨”调度器。

并行方式用的是 OpenMP 的#pragma omp parallel for,配合schedule(dynamic, chunk)。这里的 chunk 就是每次分配给一个线程的行数,也就是我们说的粒度。为了看到完整趋势,我按 2 的幂从 1 一直扫到 100 万:

#pragma omp parallel for schedule(dynamic, chunk) for (int i = 0; i < rows; ++i) { double sum = 0.0; for (int j = row_ptr[i]; j < row_ptr[i+1]; ++j) { sum += values[j] * x[col_idx[j]]; } y[i] = sum; }

计时方式是用std::chrono::high_resolution_clock,每一档 chunk 重复跑 10 轮,取中位数,避免系统噪声干扰。同时我保留了串行版本的耗时作为基线,方便换算加速比。

2. 实验结果:那条让人意外的 U 形曲线

2.1 完整扫描数据一览

先把数据摆出来。下面这张表记录了不同 chunk 下的总耗时和相对加速比(串行基线约 1012 ms):

每块行数 chunk任务数总耗时(ms)相对串行加速比
110000003163.20
25000001785.69
81250001238.23
32312501069.55
12878138412.05
51219546814.88
10249776615.33
40962457313.86
16384629510.65
65536161566.49
1000000110081.00

这组数据画成曲线,就是一个特别标准的“U形”:横轴是 chunk,纵轴是耗时,曲线先是陡降,在 chunk=512 到 1024 附近触底,然后一路反弹,到 chunk=1000000 时几乎回到串行时间。

注意,这不是我精心挑出来的个例。同样的实验我换过其他矩阵、换过调度策略,只要矩阵行长度足够不均、数据量足够大,U形一定会出现。只是最优点的位置和谷底深浅不同。

2.2 用加速比看更直观

如果换算成加速比,现象更扎眼:chunk=1 的时候,32 个线程只跑出了 3.2 倍加速,连线性增长的零头都没有;chunk=1024 的时候能跑到 15.33 倍,接近线性加速比的一半;等 chunk 再变大,加速比又一泻千里。

说实话,第一次跑出这个结果时我也挺懵。按直觉想,chunk=1 意味着任务数最多、负载分配最灵活,每个线程干完手头一行就可以立刻领下一行,这不是最高效的吗?但数据摆在这里,粒度细到一定程度后,性能不仅没有继续提升,反而大幅回落。

2.3 排除外部干扰:这个 U 形是真实的

为了避免有人质疑“是不是编译器优化搞的鬼”或者“是不是 CPU 频率波动”,我做了几层验证。

第一,确认结果正确性。我在相同输入下用完全串行的代码算了一份标准输出,然后对每个 chunk 档位的输出做逐元素比对,最大误差控制在 1e-12 以内,证明排除了并行化导致的数值错乱。

第二,固定 CPU 频率。在 BIOS 里关闭了睿频和动态调频,跑实验时用taskset把进程固定在物理核上,避免线程在不同核之间迁移。这个细节很关键,因为线程迁移会带来巨大的缓存冷启动开销,和我们要测的粒度效应混在一起会严重污染数据。

第三,重复实验取中位数。每一档我都跑了 10 次,中位数和最小值的差距不超过 5%。这说明曲线的趋势非常稳定,不是随机噪声。

3. 原因解析:为什么粒度越细反而越慢

3.1 任务调度开销:每个任务都有“入场费”

粒度细的第一个代价,就是任务调度的开销占比迅速上升。你可能会想,OpenMP 的动态调度不就一个原子操作取任务吗?单看一次确实不贵,无非几十纳秒。但问题是任务数太多了——chunk=1 的时候有 100 万个任务,32 个线程争着从一个共享队列里取任务,这个取任务的原子操作就要被争抢 100 万次。

每次取任务都涉及原子变量更新,有时候还要加锁,线程多了以后还有缓存一致性协议在背后疯狂同步。这些开销单看很少,但累计起来就非常可观。我实测过,chunk=1 和 chunk=1024 相比,光消耗在调度自旋上的时间就差了几十毫秒。对于一个合理的 SpMV 计算来说,这已经不是可以忽略的零头了。

这里有个特别反直觉的点:任务数越多,调度器本身反而越容易成为瓶颈,甚至可能让部分线程长时间拿不到任务。也就是说,粒度细到一定程度,你不是在并行计算,你是在并行地“排队领号”。

3.2 访存局部性被切成碎片

SpMV 是访存密集型计算,对缓存特别敏感。理想情况下,x 向量的一部分应该缓存在 L2 或 L3 里,被多个正在处理相邻行的线程反复命中。但当 chunk 很小时,每个线程处理的行跨度非常大,线程 A 刚把 x 的某一段读进缓存,线程 B 又去读另一段,缓存内容不断被冲掉,命中率断崖式下跌。

更麻烦的是伪共享(false sharing)。多个线程同时更新 y 数组里不同的元素,但如果这些元素恰好在同一个 cache line 上,硬件为了保证一致性会让这些线程互相等待。chunk 越小,线程之间越容易踩到同一个 cache line 上的相邻 y 元素,伪共享的惩罚就越明显。

我后来做了一版优化:让每个线程先把计算结果写到私有的临时缓冲区,最后再合并回 y 数组。就这么一个小改动,chunk=8 档位的性能提升了 12% 左右。这说明粒度细的时候,访存路径上的损耗比我们想象的严重得多。

3.3 尾部效应:木桶的短板决定全局时间

第三个原因,也是最容易被忽视的,就是尾部调度问题。并行程序的完成时间,不是由“平均线程完成时间”决定的,而是由“最慢的那个线程完成时间”决定的。哪怕你有 31 个线程都干完了,只要有一个线程还在磨蹭,整个程序就得等它。

细粒度似乎在理论上能把长任务拆开,让长行分散到多个线程,实际上却会产生新的尾部问题。因为任务队列是无序的,线程可能在执行了 999 个短任务之后,又领到一个几千非零元的长行任务,这一行就要跑很久。其他线程干完了来偷任务,偷到的可能还是一串长行。结果就是:你虽然把任务切碎了,但“倒霉蛋线程”依然存在,而且因为任务太碎,调度器很难提前识别哪些任务是重型任务。

这其实就是尾部调度(tail scheduling)的核心困境:调度器如果不能在运行期感知每个任务的“重量”,那么粒度过细只会让任务分配变成抽签,谁抽到大任务谁就是瓶颈。而粒度比较粗的时候,尽管负载也可能不均衡,但至少每个任务只被领取一次,不至于在“领任务-执行-再领任务”的循环里不断囤积重活。

3.4 三个因素叠加,形成了 U 形曲线

把三个原因放一起,U 形曲线就能说得通了:

  • 粒度太细时,任务调度开销和缓存失效是主要矛盾,耗时居高不下。
  • 粒度适中时,调度开销被摊薄,缓存局部性也还说得过去,负载不均衡虽然有但没到致命程度,所以性能最好。
  • 粒度太粗时,任务数少了,调度开销小了,但负载不均衡变成主要矛盾,少数线程承担绝大多数计算,尾部效应把性能重新拖下去。

这就是为什么曲线不是单调的,而是先降后升。粒度本质上是在“调度开销”和“负载均衡”两者之间走钢丝,只有在一个合适的区间里,双方才勉强和谐。

4. 尾部调度:真正决定并行上限的隐藏角色

4.1 静态调度、动态调度、工作窃取的本质区别

说到尾部调度,必须先把 OpenMP 里的几种调度策略讲清楚。

schedule(static)是在循环开始前就把任务平均分给每个线程,每人一块,谁也不抢谁的。它的优点是零调度开销、缓存友好,缺点是如果任务本身不均衡,有的线程分到的全是长行,有的全是短行,整个程序就得等最慢的那一块。OpenMP 里还有一种schedule(static, chunk),是把大块拆成许多固定大小的小块,按“轮转”方式分给线程,本质还是静态的,只是更碎。

schedule(dynamic, chunk)是运行时动态分配,每个线程干完一个 chunk 就去队列领下一个。它的优点是负载均衡能力强,缺点是每次领任务都有开销。我们前面实验用的就是这个。

工作窃取(work stealing)是 TBB、Java ForkJoin 这类框架更常用的策略。每个线程维护自己的任务队列,先干自己的活;干完了就去偷别的线程队列尾部的任务。它的好处是大部分时间线程只操作本地队列,没有竞争,只有局部空闲时才发生跨线程交互,算是动态调度的一种优化版本。

4.2 为什么动态调度不能完全解决尾部问题

很多人以为用了动态调度就能自动消除尾部效应,实际上不是。动态调度只是把任务“分发”的过程动态化了,它并不会去判断每个任务的重量。对于 SpMV 这种行长度差异极大的负载,动态调度依然可能把多个长行分到同一个线程,只是概率降低了而已。

而且动态调度的任务领取顺序也存在问题。当一个线程执行完一个很短的任务,它会立刻去领下一个任务;如果任务队列里的长行没被特殊处理,很可能出现一种情况:某个线程连续领到几个长行,而其他线程早就闲下来了,却又不能从正在执行任务的线程那里抢活。

这背后的本质是:动态调度解决的是“任务分配不均”,但解决不了“执行中任务的不均”。真正要处理尾部问题,需要的是“让调度器能感知任务的预估执行时间,并在运行中做出调整”。

4.3 常用的尾部调度手段

针对 SpMV 场景,业界常见的尾部调度优化有这么几种:

第一种是“长行优先/短行优先”。在进入并行之前,先统计每行的非零元数量,按行长度排序,把长行先分给某个线程,短行再动态取。这样长行不会堆积到同一个线程身上。代价是需要额外排序,但排序本身 O(n log n) 计算量远小于 SpMV 本身,性价比很高。

第二种是“guided 调度”。OpenMP 的schedule(guided, chunk)会从一个大块开始,每领一次任务,就把剩下的任务块大小按比例缩小,相当于自适应的粒度。实测在行长度对数分布明显的矩阵上,guided 经常比 dynamic 好,因为它前期减少了调度次数,后期又用细粒度打散剩余负载。

第三种是“任务窃取 + 任务优先级”。TBB 的parallel_for允许给每个迭代设置一个粒度范围,配合 work stealing,可以在一定程度上缓解尾部。但需要自己把行按长度分组,否则偷到的还是随机任务,效果有限。

这些手段都没有脱离一个核心思想:让调度器重视“尾部的慢任务”,而不是把所有任务一视同仁。这也是标题里“尾部调度”这四个字值得单独拎出来讲的原因。

5. 实操调优:怎么找到粒度的甜点区

5.1 第一步:先扫指数量级,别一上来精扫

我踩过的第一个坑就是一上来把 chunk 从 1 到 10000 每个整数都跑一遍。结果跑了两个小时,数据还带着波动看不清趋势。正确做法是先按 2 的幂扫一遍,比如 1、2、4、8、16……直到总行数。这一遍能很快确定最优区间大概在什么范围,然后再回到这个区间里精扫。

比如我们这个实验,粗扫发现最优在 512 到 1024 附近,就再把 512、640、768、896、1024、1280、1536 这些档位各跑 10 轮取中位数。这样既省时间,又能把最优点定位到足够细腻的程度。

另外,每次扫描都要固定同一个矩阵、同一个线程数、同一个编译器选项。变量越多,越难判断性能差异到底来自粒度还是来自别的因素。

5.2 第二步:用尾部长尾时间指导选型

光看总耗时还不够,我会额外收集每个线程的执行时间和任务领取次数。方法很简单,在每个线程的开头和结尾记录时间,汇总到数组里;任务领取次数则可以用omp_get_thread_num()加一个本地计数器统计。

有了这两个数据,就能算出“最后 5% 的任务完成前,有多少线程已经空闲了”。如果这个空闲时间占总耗时的 20% 以上,说明负载不均衡很严重,应该考虑把粒度调细,或者改用长行优先策略。如果空闲时间很小,但总耗时还是高,那就说明调度开销或缓存问题才是短板,再调细粒度反而有害。

我把这个指标叫做“尾部等待占比”。在我的经验里,它比 GFLOPS 这种宏观指标更能一针见血地指出问题所在。

5.3 第三步:结合矩阵结构做启发式决策

矩阵的行长度分布,直接决定了最优粒度在哪个方向。如果是比较均匀的矩阵,静态调度加粗粒度就很好;如果是行长度差异巨大的矩阵,比如社交网络图,长行优先加动态调度效果最好;如果行长度差异只存在于少数几行,那就把这几行长行单独摘出来静态分给不同线程,剩下的短行用动态调度即可。

具体操作上,我会先算两个数:平均行长度和最大行长度。如果最大行长度超过平均行长度的 10 倍,我基本不会考虑纯静态调度,而是优先试schedule(dynamic, 256)schedule(guided, 128)。如果最大行长度和平均行长度差不远,schedule(static)加一个大 chunk 反而是最优解。

5.4 第四步:把调度策略也当成可调参数

很多人写 OpenMP 代码习惯性写schedule(dynamic),其实调度策略本身也应该进实验矩阵。针对同一个矩阵,把 static、dynamic、guided 三种策略各扫一遍粒度,结果经常完全不同。我见过一个矩阵,static 在 chunk=64 时性能最优,dynamic 却在 chunk=1024 时才最优,两者差距超过 30%。

所以在我的实验流程里,粒度扫描和调度策略扫描是同时进行的。每换一种策略,重新扫一遍 chunk。这样最终拿到的“最优配置”才是真正可信的。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方向
U形曲线不明显,甚至一直变慢矩阵太小/数据不足,并行开销占比太低换更大的矩阵,增加线程数
最优粒度出现在非常大的 chunk矩阵行长度太均匀,静态调度更合适试试 static 调度并增大 chunk
动态调度比静态调度还慢任务本身负载均衡已经很好,动态调度纯属多余改回 static,去掉运行时开销
线程数增加后性能不升反降内存带宽打满,超线程干扰降线程数,关超线程,用绑核
多次运行波动很大CPU 频率波动 / 线程迁移 / 系统噪声关睿频,taskset 绑核,取中位数
输出结果与串行不一致浮点累加顺序导致的舍入误差放宽误差阈值,或检查 data race

6.2 一个非常隐蔽的坑:伪共享

细粒度场景下,伪共享对 SpMV 性能的杀伤力超过很多人想象。y 数组是 double 类型,一个 cache line 通常 64 字节,能放 8 个 y 元素。chunk 很小的时候,两个线程很可能同时更新相邻的 y 元素,导致同一个 cache line 在两个核之间来回同步。

解决思路是给每个线程一段独立的输出缓冲,最后再合并。虽然多了一次写回,但避免了 cache line 的竞争,实测性能提升非常可观。这里要特别强调一点:OpenMP 自己的reduction不能直接用于数组的片段归约,需要手写。

6.3 另一个容易忽略的坑:cache line 对齐

向量化和缓存命中都跟内存对齐强相关。我用malloc分配的 buffer 经常因为对齐问题导致性能差异,后来改成posix_memalignstd::aligned_alloc按 64 字节对齐,性能稳定性好了不少。尤其是用 AVX 向量化时,对齐问题直接影响能否生成高效的vmovapd指令。

如果你看到同样的算法、同样的 chunk,有时候性能突然掉一大截,先别怀疑调度,去看看你的values数组是不是没做对齐分配。

6.4 现场问答:为什么我扫不出 U 形

有朋友拿一个小矩阵(比如 1000 行 5000 非零元)去跑这个实验,发现 chunk 从 1 扫到 1000,耗时几乎是一条水平线,甚至性能还略微上升。这不是因为粒度不影响性能,而是因为数据量太小,创建线程和调度开销被串行部分完全掩盖了。

对这种“没有 U 形”的实验结果,最直接的解决方案是:把矩阵换大,把线程数调多。只有当并行部分在总耗时中占比足够高时,粒度效应才会显性化。U 形曲线本质上是一个“并行规模放大镜”,数据量不够大,它什么都照不出来。

7. 最后再分享一点个人体会

这个实验做完以后,我最大的收获不是“SpMV 该用哪个 chunk 值”,而是认识到粒度永远不是一个独立可调的参数。它和矩阵结构、调度策略、线程数、缓存层次、甚至编译器生成的指令排列都耦合在一起。任何告诉你“粒度取 256 就对了”的经验,放到另一个矩阵上可能就完全不成立。

所以我现在做并行优化时,已经不再迷信经验值,而是养成了一个习惯:任何性能敏感的内核,先写一个可复现的参数扫描脚本,把粒度、调度策略、线程绑定放进同一个实验矩阵里跑一遍,让数据告诉我答案。

如果你也想复现这个实验,建议从自己的业务里找一个访存密集、行长度分布不均的计算,照着这个思路跑一遍粒度扫描。你大概率也会看到那条 U 形曲线,然后会和我一样,对“粒度假象”这个词产生新的理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询