先讲一个我自己真实踩过的坑。有次做一个并行流体程序,理论算出来16个核应该能跑到14倍加速比,结果实测卡在6倍上不去。我一开始以为是算法并行度不够,后来把每个阶段的耗时打出来才发现,真正吃掉收益的不是计算,而是大量小消息的通信开销。从那之后我就养成了一个习惯:写并行算法之前,先老老实实把通信代价建模算一遍,再做任务划分。本文是并行算法与任务划分技术专题的第四篇,重点讲清楚通信代价建模和任务划分优化这两件事的底层逻辑、实操方式和典型坑。适合正在做并行计算、分布式训练、高性能计算优化的工程师,也适合准备系统学习并行算法的研究生,这篇文章可以帮你少走很多弯路。
1. 为什么先建模,再谈任务划分
1.1 并行性能的真正瓶颈:通信不是"边角料"
很多人刚开始接触并行算法时,注意力都放在"怎么把任务拆开"上,觉得只要每个处理器都有活干,性能自然就上来了。但等你真正把程序跑起来就会发现,任务拆得越碎,处理器之间需要交换的信息越多,通信成本反而可能高过计算本身。
通信代价不是一个"传了多少字节"那么简单的问题。一次通信在底层要经历这么几件事:调用通信库、打包数据、进入网络、等待对端接收、确认完成。其中有的开销跟数据量无关,是固定成本,比如发起一次消息传递的启动延迟;有的开销跟数据量线性相关,比如每一字节的传输时间。所以通信代价通常写成这样一个形式:
T_comm = α + β × L
其中 α 是消息启动延迟,β 是单位数据传输时间,L 是消息长度。并行程序跑得快的秘密,说白了就是想办法让 T_comm 在整个运行时间里的占比尽可能小。任务划分优化的本质,就是调整每个处理器负责的数据块大小和形状,让计算量尽量大、通信量尽量小、两者之间的重叠度尽量高。
我之前遇到过很多同学问:为什么我用了MPI_Send/MPI_Recv,性能还是不如串行程序?答案往往就藏在这个 α 和 β 里。每一次点对点通信都有启动成本,消息越多,α 被乘的次数越多。哪怕单条消息只要几微秒,消息数量级到了百万级别,累积起来就是好几秒的开销。
1.2 两个基础代价模型:线性模型与LogP模型
通信代价建模并不是一个固定公式,而是要针对不同并行粒度选不同模型。最常用的是线性模型,也就是我上面给出的 T_comm = α + β × L。这个模型的优点是好算、直观,适合做第一轮粗估,比如快速判断一个数据块划分方案是"看起来合理"还是"一看就不可行"。
但线性模型只考虑了"单次传输的成本",没有考虑处理器之间的并发、网络拥塞和消息在接收端的处理开销。如果并行规模大了,比如几百个节点同时通信,网络拓扑上的拥塞、路由器排队、进程调度的抖动都会进来,线性模型就明显不准。
另一类经典模型是 LogP 模型,它把一次消息传递拆成四个参数:
- L:网络中数据传播的延迟;
- o:通信库的发送或接收开销,也就是处理器进行通信操作时不能算计算的时间;
- g:通信间隔,即处理器连续发送或接收两条消息之间的最小时间间隔;
- P:处理器数目。
LogP 模型能够比较好地反映细粒度并行算法中"计算与通信重叠"的情况。比如你用了非阻塞通信,计算可以先跑起来,通信在后面慢慢进行,这时候如果拿线性模型估,会高估通信时间;用 LogP 模型,反而能把 overlap 的效果量化出来。
还有一个经常被提到的模型是 BSP 模型,也就是"整体同步并行"模型。BSP 把并行计算划分成若干"超步",每个超步里处理器先算各自的局部数据,然后做一次全局同步和通信,再进入下一个超步。BSP 模型的好处是让设计者必须明确考虑同步屏障的开销,因为同步意味着所有处理器都要等到最慢的那一个到达,处理器之间的负载不均衡会被直接放大。
这三种模型之间不是互相替代关系。我做并行优化时的习惯是:先用线性模型估算量级,定位瓶颈;再用 LogP 或者 BSP 模型细化某一个关键通信阶段的成本;最后结合实测数据反过来校正模型参数。
1.3 建模到底要解决什么问题
技术专题前几篇讲过并行算法设计、通信原语实现、负载均衡入门,到了这一篇,核心视角从"怎么发消息"变成了"每条消息到底值多少钱、怎么安排才最省钱"。
任务划分需要回答这样几个具体问题:数据块是切大片好还是切小片好?一维划分、二维划分还是三维划分更划算?处理器之间的数据交换是邻居之间做近距离通信,还是每次都要做全局通信?是固定划分还是运行时动态调整?通信代价建模的作用,就是把这些问题从"凭感觉判断"变成"代公式比较"。
举个例子。同样一份内存里的二维矩阵,按行切成 P 份,每个处理器手里是 N/P 行,通信只发生在相邻行块之间的边界;如果按块状切成一个 P 行乘 P 列的网格,每个处理器只有一块 N/√P 乘 N/√P 的子矩阵,通信要发生在四周邻居之间。两种切法计算量一样,但通信
