ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

并行粒度如何影响SpMV性能?从U形曲线看调度开销与尾部效应

2026/9/16 3:10:05 拓冰建站 浏览量
并行粒度如何影响SpMV性能?从U形曲线看调度开销与尾部效应 做高性能计算的朋友应该都有过这种经历一个程序性能上不去第一反应是算法不够优第二反应是并行度不够高于是把任务切得越来越细指望每个线程都吃得饱饱的。今天这个实验恰恰要打脸这个直觉——我用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 的二维数组存那样内存直接爆掉。工程上最常用的存储格式是 CSRCompressed Sparse Row它把矩阵拆成三个数组row_ptr长度为 rows1记录每一行的起始位置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[i1]; 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 -marchnative -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[i1]; 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.2025000001785.6981250001238.2332312501069.5512878138412.0551219546814.8810249776615.3340962457313.8616384629510.6565536161566.491000000110081.00这组数据画成曲线就是一个特别标准的“U形”横轴是 chunk纵轴是耗时曲线先是陡降在 chunk512 到 1024 附近触底然后一路反弹到 chunk1000000 时几乎回到串行时间。注意这不是我精心挑出来的个例。同样的实验我换过其他矩阵、换过调度策略只要矩阵行长度足够不均、数据量足够大U形一定会出现。只是最优点的位置和谷底深浅不同。2.2 用加速比看更直观如果换算成加速比现象更扎眼chunk1 的时候32 个线程只跑出了 3.2 倍加速连线性增长的零头都没有chunk1024 的时候能跑到 15.33 倍接近线性加速比的一半等 chunk 再变大加速比又一泻千里。说实话第一次跑出这个结果时我也挺懵。按直觉想chunk1 意味着任务数最多、负载分配最灵活每个线程干完手头一行就可以立刻领下一行这不是最高效的吗但数据摆在这里粒度细到一定程度后性能不仅没有继续提升反而大幅回落。2.3 排除外部干扰这个 U 形是真实的为了避免有人质疑“是不是编译器优化搞的鬼”或者“是不是 CPU 频率波动”我做了几层验证。第一确认结果正确性。我在相同输入下用完全串行的代码算了一份标准输出然后对每个 chunk 档位的输出做逐元素比对最大误差控制在 1e-12 以内证明排除了并行化导致的数值错乱。第二固定 CPU 频率。在 BIOS 里关闭了睿频和动态调频跑实验时用taskset把进程固定在物理核上避免线程在不同核之间迁移。这个细节很关键因为线程迁移会带来巨大的缓存冷启动开销和我们要测的粒度效应混在一起会严重污染数据。第三重复实验取中位数。每一档我都跑了 10 次中位数和最小值的差距不超过 5%。这说明曲线的趋势非常稳定不是随机噪声。3. 原因解析为什么粒度越细反而越慢3.1 任务调度开销每个任务都有“入场费”粒度细的第一个代价就是任务调度的开销占比迅速上升。你可能会想OpenMP 的动态调度不就一个原子操作取任务吗单看一次确实不贵无非几十纳秒。但问题是任务数太多了——chunk1 的时候有 100 万个任务32 个线程争着从一个共享队列里取任务这个取任务的原子操作就要被争抢 100 万次。每次取任务都涉及原子变量更新有时候还要加锁线程多了以后还有缓存一致性协议在背后疯狂同步。这些开销单看很少但累计起来就非常可观。我实测过chunk1 和 chunk1024 相比光消耗在调度自旋上的时间就差了几十毫秒。对于一个合理的 SpMV 计算来说这已经不是可以忽略的零头了。这里有个特别反直觉的点任务数越多调度器本身反而越容易成为瓶颈甚至可能让部分线程长时间拿不到任务。也就是说粒度细到一定程度你不是在并行计算你是在并行地“排队领号”。3.2 访存局部性被切成碎片SpMV 是访存密集型计算对缓存特别敏感。理想情况下x 向量的一部分应该缓存在 L2 或 L3 里被多个正在处理相邻行的线程反复命中。但当 chunk 很小时每个线程处理的行跨度非常大线程 A 刚把 x 的某一段读进缓存线程 B 又去读另一段缓存内容不断被冲掉命中率断崖式下跌。更麻烦的是伪共享false sharing。多个线程同时更新 y 数组里不同的元素但如果这些元素恰好在同一个 cache line 上硬件为了保证一致性会让这些线程互相等待。chunk 越小线程之间越容易踩到同一个 cache line 上的相邻 y 元素伪共享的惩罚就越明显。我后来做了一版优化让每个线程先把计算结果写到私有的临时缓冲区最后再合并回 y 数组。就这么一个小改动chunk8 档位的性能提升了 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 在 chunk64 时性能最优dynamic 却在 chunk1024 时才最优两者差距超过 30%。所以在我的实验流程里粒度扫描和调度策略扫描是同时进行的。每换一种策略重新扫一遍 chunk。这样最终拿到的“最优配置”才是真正可信的。6. 常见问题与排查技巧实录6.1 问题速查表现象可能原因排查方向U形曲线不明显甚至一直变慢矩阵太小/数据不足并行开销占比太低换更大的矩阵增加线程数最优粒度出现在非常大的 chunk矩阵行长度太均匀静态调度更合适试试 static 调度并增大 chunk动态调度比静态调度还慢任务本身负载均衡已经很好动态调度纯属多余改回 static去掉运行时开销线程数增加后性能不升反降内存带宽打满超线程干扰降线程数关超线程用绑核多次运行波动很大CPU 频率波动 / 线程迁移 / 系统噪声关睿频taskset 绑核取中位数输出结果与串行不一致浮点累加顺序导致的舍入误差放宽误差阈值或检查 data race6.2 一个非常隐蔽的坑伪共享细粒度场景下伪共享对 SpMV 性能的杀伤力超过很多人想象。y 数组是 double 类型一个 cache line 通常 64 字节能放 8 个 y 元素。chunk 很小的时候两个线程很可能同时更新相邻的 y 元素导致同一个 cache line 在两个核之间来回同步。解决思路是给每个线程一段独立的输出缓冲最后再合并。虽然多了一次写回但避免了 cache line 的竞争实测性能提升非常可观。这里要特别强调一点OpenMP 自己的reduction不能直接用于数组的片段归约需要手写。6.3 另一个容易忽略的坑cache line 对齐向量化和缓存命中都跟内存对齐强相关。我用malloc分配的 buffer 经常因为对齐问题导致性能差异后来改成posix_memalign或std::aligned_alloc按 64 字节对齐性能稳定性好了不少。尤其是用 AVX 向量化时对齐问题直接影响能否生成高效的vmovapd指令。如果你看到同样的算法、同样的 chunk有时候性能突然掉一大截先别怀疑调度去看看你的values数组是不是没做对齐分配。6.4 现场问答为什么我扫不出 U 形有朋友拿一个小矩阵比如 1000 行 5000 非零元去跑这个实验发现 chunk 从 1 扫到 1000耗时几乎是一条水平线甚至性能还略微上升。这不是因为粒度不影响性能而是因为数据量太小创建线程和调度开销被串行部分完全掩盖了。对这种“没有 U 形”的实验结果最直接的解决方案是把矩阵换大把线程数调多。只有当并行部分在总耗时中占比足够高时粒度效应才会显性化。U 形曲线本质上是一个“并行规模放大镜”数据量不够大它什么都照不出来。7. 最后再分享一点个人体会这个实验做完以后我最大的收获不是“SpMV 该用哪个 chunk 值”而是认识到粒度永远不是一个独立可调的参数。它和矩阵结构、调度策略、线程数、缓存层次、甚至编译器生成的指令排列都耦合在一起。任何告诉你“粒度取 256 就对了”的经验放到另一个矩阵上可能就完全不成立。所以我现在做并行优化时已经不再迷信经验值而是养成了一个习惯任何性能敏感的内核先写一个可复现的参数扫描脚本把粒度、调度策略、线程绑定放进同一个实验矩阵里跑一遍让数据告诉我答案。如果你也想复现这个实验建议从自己的业务里找一个访存密集、行长度分布不均的计算照着这个思路跑一遍粒度扫描。你大概率也会看到那条 U 形曲线然后会和我一样对“粒度假象”这个词产生新的理解。