简介:《并行算法的设计与分析》课件围绕异步并行模型下的排序与选择问题展开,适合计算机专业学生、研究者以及需要掌握并行计算基础知识的开发者学习。课件对应经典教材“Parallel Algorithms”第5章,重点讲解MIMD-CREW模型上的异步枚举排序算法、MIMD-TC模型上的异步快速排序算法和分布式k-选择算法,涵盖异步算法基本框架、排序二叉树的构造思路、算法伪代码、示例演示与时间分析,并对进程同步、数据依赖、通信开销等关键设计点做了说明。其中异步枚举排序通过为每个元素创建独立进程实现无通信并行,异步快速排序则将串行快排迁移到共享内存并行环境,分布式k-选择算法面向数据分散场景查找第k小元素。压缩包为单个pptx演示文稿,约236KB,按章节组织便于课堂演示或自学复习。目前已有45人学习浏览,对希望理解异步并行算法设计思路与经典模型应用的读者具有一定参考价值。
1. 并行算法的设计与分析:这份.pptx讲稿背后的工程链路
“并行算法的设计与分析”这六个字,搭配 .pptx 后缀,乍看是一门课的题目,实际上对应着一条贯穿多核 CPU、GPU 和集群开发的工程链路。碰到并行优化任务,没受过训练的做法是直接开线程、开进程,跑慢了两眼一黑;而按这套方法论走的人,会先从任务分解和粒度选择入手,再决定数据怎么映射,最后用加速比、效率和扩展性三个指标闭环。这套链路能解决三类典型诉求:让单机多核计算真正提速、把算法改造成能在集群上运行的结构、把串行程序整理成可流式处理的流水线。适合两类读者:正写着并行程序却说不清瓶颈在哪的从业者,以及准备把并行计算做成内部培训课、需要大量可复现案例支撑的讲师。接下来我按能直接照做的顺序拆开讲:先讲任务怎么拆,再讲性能怎么算,然后给三个实用设计模板,最后排雷和立验收习惯。
2. 并行算法设计的第一公里:任务分解的三种姿势与粒度边界
很多人写并行程序失败,不是因为不会用并行库,而是任务分解这一步就没做对。这一章把“怎么拆任务”讲透,后面所有性能分析才有意义。
2.1 粒度先于并行度:拆太细与拆太粗的两个极端
粒度指的是一次派给单个线程或进程的独立工作量。常犯的错误是只关注“开了多少个线程”,却忽略每个线程实际干了多少活。一个典型反例:处理一张 4096 乘 4096 的灰度图,有人按像素生成一千多万个任务,每个任务只算几个像素,结果任务创建和调度的开销比计算本身还贵,整体比单线程慢一大截。这就是粒度拆得太细。
拆得太粗则是另一个极端。把整个数据集切成四块,四个核各拿一块,听起来很合理,但一旦各块计算量差异明显,比如图像分割里有的区域边缘复杂、有的区域几乎是空白,快的核十几秒干完就开始空转,慢的核拖到最后一刻才交活。整体耗时等于最慢那块的时间,加速比很难看。
我一般会用一条经验线做初判:单个任务的最小执行时间,至少应该是任务派发成本的 50 到 100 倍。任务派发成本包括创建、入队、同步这些开销,在主流线程库下通常几微秒。换句话说,如果你预估每次任务的计算量只有几微秒,先把任务合并,让单次计算量提到几百微秒甚至毫秒级。粗估时可以先按“核数乘以 4 到 8 倍任务数”来切,跑完看负载均衡情况再调。负载看什么?看每个线程的实际耗时分布,而不是看 CPU 占用率。
2.2 域分解、功能分解与流水线:三种拆法的适用边界
任务分解大体有三条路:域分解、功能分解、流水线。域分解是最常用的一种,把数据切成块,每个处理单元负责一块,块与块之间只在边界处交换必要的数据。网格迭代、图像滤波、矩阵乘法都适合这种拆法,因为数据局部性好,通信模式固定,分析起来也容易。
功能分解则是按“处理阶段”来切,而不是按数据来切。典型场景是音视频处理里的采集、变换、编码、写入四个阶段,每个阶段由不同处理单元负责,数据从上一个阶段流向下一个阶段。它的优点是各阶段可以独立开发和优化,缺点是阶段之间有依赖,整体吞吐受最慢阶段限制。流水线可以看作是功能分解的时序化形态,把各阶段重叠起来执行,就像工厂生产线。流水线的好处是天然支持流式数据,坏处是每条流水线的吞吐上限由最慢环节决定,而且阶段数越多,调度和缓冲的复杂度越高。
怎么选?先问数据能不能切。能切,优先域分解,这是最简单、最容易分析性能的路径。数据切不了,再看处理过程能不能按阶段分开。阶段之间有明确的单向数据流,就考虑功能分解或流水线。下表是三者的对照:
| 分解策略 | 切分对象 | 通信特点 | 典型场景 |
|---|---|---|---|
| 域分解 | 数据块 | 块边界少量邻居通信 | 网格迭代、图像处理、矩阵运算 |
| 功能分解 | 处理阶段 | 阶段间单向全量传递 | 音视频处理、ETL 流程 |
| 流水线 | 阶段 + 时序 | 顺流传递,可重叠执行 | 流式计算、并行编译 |
2.3 数据映射:共享内存和分布式内存的分配策略差异
任务拆完之后,数据放在哪、由谁访问,这一步决定通信开销。共享内存模型下,所有线程看到同一份地址空间,数据映射的核心不是“能不能访问”,而是“访存是否局部”。常见问题是 NUMA 架构下线程访问了远端内存,延迟比本地内存高一个数量级。常见做法是让每个线程优先处理自己内存附近的数据,也就是绑核之后再做数据分区,而不是让线程随机访问整个大数组。
分布式内存模型下,数据映射的核心变成“数据在哪一侧,就要付出多少通信代价”。映射策略主要三种:复制、分区、按需拉取。复制适合只读数据,每个节点一份,完全消除读通信;分区适合可写数据,每份数据只有一个归属节点;按需拉取适合访问模式稀疏的场景,但要控制拉取频率,否则容易把网络打满。
我在动手前通常会做四个小判断:这份数据谁在写、谁在读、写读比例大概多少、跨节点访问频率高不高。写多读少的数据必须分区,读多写少的数据优先复制,读写都频繁的数据就要重新考虑任务划分是否合理。一句话建立心智模型:共享内存下地址决定热区,分布式下通信决定边界。数据映射方案在写代码之前定下来,比写完再改省力得多。
3. 性能分析不能只算加速比:把指标吃透才能定位瓶颈
设计做完,下一步是回答“它到底快了多少、值不值得”。只看单次耗时是远远不够的,加速比、效率、扩展性三个指标配合起来,才能定位瓶颈在哪一层。
3.1 加速比与效率:固定问题规模才算得准
加速比的定义是 S(p)=T(1)/T(p),其中 T(1) 是单处理单元的最优串行耗时,T(p) 是 p 个处理单元并行后的耗时。效率是 E=S(p)/p,它衡量的是每个处理单元的利用率。四核跑出三倍加速比,效率是 75%;十六核跑出十二倍,效率是 75%?不对,是 75% 的加速比?算一下:十二除以十六等于百分之七十五,效率仍然不错,但十六核只跑出十二倍说明每增加一个核带来的收益在递减。
这里有个非常关键的细节:T(1) 必须用可比较的最优串行实现,而不是随便写一版循环就当基线。有些人为了显得并行效果好,故意用一个没开编译优化的串行版本当对照,这属于自欺欺人。我一般会先把串行版本用编译器高优化等级跑一遍,确认没有明显可优化的垃圾代码,再把它作为 T(1)。
实际测量时还会遇到一个常见现象:加速比并不是随着核数单调上升,而是先升后降。这是因为核数增加后,通信和同步开销的增长超过了计算收益。以固定问题规模为例,常见趋势是:2 核接近 1.9 倍,4 核约 3.7 倍,8 核约 6.3 倍,16 核可能只有 7.5 倍左右。后面的效率跌得很快,说明瓶颈已经从“算不过来”变成“沟通不过来”。
3.2 Amdahl 与 Gustafson:两套公式各管一个场景
性能分析绕不开 Amdahl 定律和 Gustafson 定律,但很多人把两套公式用错了场景。Amdahl 定律说的是:如果程序里有 s 比例的部分无法并行,那么无论加多少核,加速比上限是 1/s。公式是 S=1/(s+(1-s)/p)。举个例子,串行部分占 5%,16 核理论的加速比上限约 9.1 倍,64 核也到不了 14 倍。这个公式的适用前提是问题规模固定不变,所以我们想要的是“同样的问题跑得更快”。
Gustafson 定律则适用于另一个场景:时间预算固定,问题规模可以随核数扩大。它的表达式是 S≈s+p(1-s),在这个假设下,串行占比 5% 时,16 核可以得到约 15.25 倍的加速比,看起来比 Amdahl 乐观得多。这不是矛盾,而是前提不同:一个是固定工作量,一个是固定时间、允许“加核的同时加规模”。
判断你的场景该用哪套,就问一个问题:业务允许结果规模变化吗?如果算法输出必须是指定尺寸、指定精度的结果,按 Amdahl 定律做预算,先集中精力压缩串行段;如果业务是“单位时间内产出越多越好”,比如数值模拟里网格分辨率可以提高、搜索场景里候选集可以扩大,那就参照 Gustafson 定律来设计扩展路径。
| 定律 | 适用前提 | 核心变量 | 加速比上限 |
|---|---|---|---|
| Amdahl | 问题规模固定 | 串行占比 s | 极限 1/s |
| Gustafson | 时间固定、规模随核数扩展 | 并行占比 | 接近线性增长 |
3.3 强扩展与弱扩展:用四条曲线判断性能上限
扩展性分析可以看作是把上述两套定律落到实测里。强扩展测试固定问题规模,只增加处理单元数,观察加速比是否接近线性;弱扩展测试固定每个处理单元的工作量,总规模随处理单元数增大,观察效率是否维持稳定。这两条曲线是判断算法设计是否健康的核心依据。
具体操作分三步。第一步,选定一个中等规模数据集,能把单线程跑完但时间不要太短,比如 20 到 60 秒,太短了测量噪声影响太大。第二步,分别用 1、2、4、8、16 个处理单元跑同一份数据,每个配置至少跑 5 次取中位数,避免偶尔的系统抖动污染结果。第三步,换弱扩展:1 个核处理一定量数据,2 个核就处理两倍,4 个核处理四倍,同样记录耗时和效率。
for p in 1 2 4 8 16; do ./bench --threads $p --size 4096 --repeats 5 done上面的脚本只是一个最小示例。size 固定时,得到的是强扩展数据;如果想让 size 随线程数线性增长,就把 size 改成 p 的倍数,比如--size $((p * 1024)),这就是弱扩展数据。repeats 取中位数而不是平均值,是因为并行程序对系统噪声很敏感,偶尔一次调度抖动会把平均值拉高,中位数更能代表典型表现。
读曲线也有经验可循。强扩展的加速比曲线从接近线性开始逐渐掉头,掉头最明显的位置往往就是通信或同步开销开始压过计算收益的临界点,这个临界点对应的核数就是你当前实现的实际适用范围。弱扩展的效率曲线如果保持平稳,说明数据划分方式经得住规模增长;如果效率随核数明显下滑,优先怀疑跨节点通信或者共享数据的争用。
4. 三个高频可复现的设计模板:分治、规约、前缀和
设计方法不能只停留在概念上,需要落到具体模板。这一章给出三个高频使用的并行模式,前两个在工程里几乎天天遇到,第三个是检验“设计与分析”基本功的最佳案例。
4.1 分治模板:递归划分的可用与滥用边界
分治模板适合“子问题相互独立”的任务,数组求和、快速排序、分块矩阵乘都是典型场景。核心思路是把大问题递归拆成小问题,各处理单元并行解决小问题,再合并结果。它有一个很明确的适用边界:子问题不能有强依赖。如果后一阶段必须等前一阶段全部完成才能开始,就不要硬套分治,改走规约或流水线。
下面是一个简化的分治求和伪码,展示了设计思路:
def parallel_sum(data, cutoff): if len(data) <= cutoff: return sum(data) # 小段走串行,减少调度开销 mid = len(data) // 2 left = task_pool.submit(parallel_sum, data[:mid], cutoff) right = task_pool.submit(parallel_sum, data[mid:], cutoff) return left.get() + right.get()cutoff 是串行截止值,默认可以取 512 到 4096 个元素。取值太小,任务数量会膨胀,调度与同步开销覆盖掉并行收益;取值太大,并行度不足,多核闲着。一般先从 4096 开始,逐步减半对比耗时,找到性能拐点。任务总数控制在核数的 4 到 8 倍通常比较合适,任务太多调度器反而成了瓶颈。另一个要注意的点是:如果各子任务的计算量差异很大,静态划分会严重负载不均,这时要改用动态调度,比如工作窃取策略。实现上可以用线程池配合带有窃取能力的任务队列,而不是自己写递归线程。
4.2 树形规约:把串行瓶颈换成 log 深度
规约问题是指把一组数据通过某个满足结合律的运算合并成一个结果,比如求和、求最大值、内积。串行实现很直接,循环累加即可,但并行化之后你会发现,最后一轮的合并结果只有一个处理单元能拿到,其他单元都在空等。树形规约就是为了解决这个“扇形收敛”的问题。
以分布式内存为例,每个进程先算本地部分和,然后按步长成对合并,每轮通信之后活跃进程数减半。伪码如下:
value = local_partial_sum stride = 1 while stride < size: if rank % (2 * stride) == 0: received = recv(rank + stride) value += received elif rank % (2 * stride) == stride: send(rank - stride, value) break # 发送完即退出本轮 stride *= 2这里的 stride 是合并步长,每轮翻倍,所以通信轮次是 log2(p) 而不是 p。参数要注意两点:前半段进程在接收,后半段进程在发送后直接退出,活跃进程数逐轮减半,这本身是一种负载不均衡,只是它换来的是通信次数的大幅减少;如果做的是全局规约,也就是所有进程都要拿到最终结果,那么还需要一轮广播,或者改用蝶形算法。实际工程里,能用现成库的 Allreduce 就用现成的,大多数成熟实现会按消息大小自动选择树形还是蝶形,比自己手写的版本更稳。树形规约的意义在于让你理解为什么通信次数可以从线性降到对数级。
4.3 并行前缀和:检验设计与分析是否上手的试金石
前缀和,也叫扫描,要求对输入数组的每个位置输出它之前所有元素的和。看起来天然串行,因为第 i 个输出依赖前 i-1 个输出,但并行算法可以用两阶段思路绕过这个依赖链。常见做法是块内扫描加全局偏移修正:
# 阶段 1:每个处理单元对本地块做排他前缀和 local_prefix = exclusive_scan(local_chunk) # 阶段 2:计算每个块的总和,对块和再做一次排他前缀和 block_sum = sum(local_chunk) block_offset = exclusive_scan(all_block_sum) # 阶段 3:本地前缀和加上全局偏移,得到正确答案 result = local_prefix + block_offset这个过程能跑通,依赖的是前缀和运算的结合律。第一阶段是纯并行,每块独立计算;第二阶段把块和汇总,数据量已经缩小到块数级别;第三阶段再次纯并行,把偏移量广播回各块并相加。总时间大约是 O(n/p + log p),n 是数据总量,p 是处理单元数。前半部分是本地计算开销,后半部分是全局通信开销。
为什么说这是试金石?因为它同时训练了“设计”和“分析”两件事:设计体现在三个阶段的划分和映射上,分析体现在你能否准确地说出时间都花在哪一段。如果你能给同事讲明白这个案例,说明你对并行算法的任务划分、通信模式和复杂度分析都有感觉了。从做讲稿的角度看,这也是最容易让听众建立信心的案例,因为它从“不可能并行”到“三段式解决”的转折足够清晰。
5. 并行算法踩坑排查:五个高频问题的现象、原因与对策
并行程序跑不出预期性能,一般不是算法思想错了,而是落在几个固定坑里。这一章我把高频问题按“现象到对策”整理出来,可以直接当排查手册用。
5.1 负载不均衡:核全在忙,总时间却纹丝不动
现象:任务管理器里所有核心的占用率都不低,甚至接近 100%,但加速比就是上不去,有时 8 核跑出来只有 1.5 倍。原因:任务被静态等量划分,但各任务的实际计算量差异很大,整体耗时被最慢的那个任务拖死。比如网格计算里某块区域的迭代次数远高于其他区域,或者图像处理中不同分块的边缘复杂程度差别很大。
解决:把静态划分改成动态调度。共享内存场景下用带任务窃取的线程池,谁先干完谁去拿新任务;分布式场景下可以把任务切得更小,让工作节点按需领取,而不是一次性把大块分完。另外,日志里为每个任务记录开始和结束时间,做完一轮后看各任务耗时的分布,如果最大和最小差了 3 倍以上,负载不均衡基本可以坐实。
5.2 缓存伪共享:结果正确,速度却比单线程还慢
现象:多线程版本的输出完全正确,但耗时比单线程还长,而且性能工具显示缓存一致性相关的等待很高。原因:多个线程频繁写入不同的变量,但这些变量恰好落在同一条缓存行上,比如 64 字节的缓存行里有两个相邻数组元素,分别被两个线程更新。每次写入都会让整条缓存行在其他核心失效,于是两个线程互相拖累。
解决:把不相干的数据用填充的方式隔开,让它们落在不同的缓存行上。也可以让每个线程先写自己的私有缓冲区,全部算完之后再一次性合并到共享数组。这个坑最容易出现在“按索引并行处理数组”的场景里,排查时优先检查同一数组相邻下标是否被不同线程并发更新。
5.3 锁争用与死锁:共享数据结构让你“部分快、整体崩”
现象:加锁之后并行效率断崖式下跌,系统态 CPU 占用明显升高;更严重的情况是程序挂起,几秒后无响应。原因:前者通常是全局锁把关键路径串行化了,比如所有线程都要更新同一个计数器;后者是死锁,两个线程互相持有对方需要的锁,谁都不肯释放。
解决:先分析共享数据能否分区。计数器可以改成每个线程一个本地计数,最后汇总;链表可以用细粒度锁或者无锁队列。死锁的处理我习惯按四个条件倒查:互斥、持有并等待、不可抢占、循环等待。代码里只要破坏其中任意一个条件就不会死锁,最常用的办法是规定全局一致的加锁顺序,所有线程都按相同顺序获取多个锁。排查死锁时,先看线程栈里每个线程在等哪把锁,再画依赖环,环的起点就是问题所在。
5.4 通信放大:频繁小消息比数据量大更致命
现象:计算部分很快,但整个程序运行时间依然很长,统计下来通信占了八成以上。原因:消息次数太多,每次消息体又很小。比如分布式场景里,每个迭代向其他节点发送一个几十字节的状态信息,一万次迭代就是上万次网络往返,延迟完全吃掉了计算收益。通信成本由两个因素决定:消息次数和消息体积,而在多数网络环境下,次数比体积更致命。
解决:把频繁的小消息合并成批量缓冲区,攒够一批再发送。另一种思路是改变计算结构,让数据尽量在本地处理,只在边界处同步。判断通信是否健康,可以在日志里统计两个数:每秒消息次数和平均消息字节数。如果次数在十万级而平均字节数只有几十,优先做合并。
5.5 超线性加速比:是惊喜,但先别高兴
现象:16 核跑出了 18 倍的加速比,理论上不可能,但它确实发生了。原因:并行版本把大数组拆成了小块,每个核处理的小块恰好完全放进了处理器缓存,而串行版本的数据会不断在各级缓存和内存之间搬运,相当于并行版本白赚了一笔“内存带宽红利”。算法本身并没有变得更快,只是数据局部性变好了。
解决:先不要把这个结果写进汇报,回去做两个验证。第一,确认串行基线版本是不是同一份代码在相同编译优化等级下的最优实现;第二,把问题规模扩大一倍再测,如果超线性消失,说明确实是缓存效果而非算法突破。这个坑看似是好事,但它会误导后续的优化方向。
下表汇总了这五个问题的快速对照:
| 高频问题 | 典型现象 | 根本原因 | 优先检查手段 |
|---|---|---|---|
| 负载不均衡 | 核占用高但加速比低 | 任务计算量差异大 | 统计各任务耗时分布 |
| 缓存伪共享 | 正确但慢,单线程反而快 | 多线程争用同一条缓存行 | 检查共享数组相邻下标访问 |
| 锁争用与死锁 | 加锁后效率骤降或挂起 | 全局锁串行化、锁依赖环 | 查看线程栈等待关系 |
| 通信放大 | 计算快但总耗时长 | 消息次数过多、消息体过小 | 统计消息次数与平均字节数 |
| 超线性加速 | 加速比大于核数 | 缓存与内存带宽红利 | 扩大规模复测确认 |
6. 把分析能力变成验收习惯:三个每次迭代必做的自查
到这一步,设计与分析的框架已经完整,最后要解决的是“怎么保证自己每次改动都在进步”的问题。我的做法是固化三个自查动作,每个迭代都过一遍。
第一个自查:先有串行基线,再谈并行加速。固定数据集、固定机器、固定编译优化等级,把最优串行耗时记录成基准。并行实现没跑过这个基准,就是失败,不要用“理论上应该有加速”安慰自己。这里要小心的是,编译器开启优化后,某些串行代码会被自动向量化,这时候并行版本的优势会被缩小,但这才是真实的对比基线。
第二个自查:一次只改一个变量。线程数、任务分块大小、调度方式、数据布局,这些参数每次只动一个,然后记录三个数字:总耗时、同步或通信耗时占比、各线程利用率。改完两个参数再跑,如果性能变了,你无法判断是哪个起了作用,等于白跑。记录下来的数据按日期和参数存好,后续排查问题时能快速回溯。
第三个自查:强扩展和弱扩展两条曲线都留档。固定问题规模跑强扩展,固定每核负载跑弱扩展,每条曲线至少五个数据点。每次代码改动后重跑一轮,把曲线图和原始耗时数据存成文件。不要靠记忆,也不要只留最终数字,否则换了测试机器、换了编译器之后,你会发现之前的所有判断都没法复现。我在给内部一个图像处理 Demo 做并行化时,早期只拍了 4 核和 8 核两组耗时,加速比看着还行,换到 32 核机器上直接翻车,返工点恰好就在数据划分策略上。那之后我才立了这三条自查。做并行优化前,也先算一笔投入产出账:优化本身的开发时间加上反复调试的时间,值不值得做。只要业务是计算密集、数据可分区、需要反复验证的场景,这套前期投入很快就能回本。希望帮到你。
本文还有配套的精品资源,点击获取