简介:《Parallel and High Performance Computing》是Manning于2021年出版的并行计算与高性能计算英文原版书,面向具备一定编程基础、希望系统掌握CPU多核与GPU加速开发的工程师和研究生。全书围绕为何并行、并行化规划、性能瓶颈与剖析、数据设计与性能模型展开,并分章节讲解向量化、OpenMP、MPI等CPU并行技术以及GPU编程实践,兼顾并行算法模式与真实性能优化思路。压缩包仅含1个PDF文件,大小约35.83MB,单文件即完整电子书,目录结构清晰,便于在电脑或平板上检索阅读。已有692人学习下载,适合用作课程参考、项目性能调优手册与面试前知识梳理。读者可借此理解多核处理器、异构计算与GPU加速之间的协作关系,掌握性能度量、扩展性分析与调优取舍,并将书中模式迁移到科学计算、数据分析或工程仿真等场景。
1. 0.8%的串行代码,凭什么拖住 99.2% 的并行
买电脑时,销售人员说这颗芯片有十六个核,比隔壁八个核快一倍。搬回家跑自己那段循环,时间却几乎没变。这种落差,每个刚碰并行计算的人都撞上过。道理不复杂:把活儿拆给十六个人干,前提是这活儿真能拆,且拆完之后大家不用排着队等某一个人签个字。2021 年那本讲并行与高性能计算的著作里,放了一个很扎眼的数字——串行部分 0.8%,并行部分 99.2%。乍看几乎全是可并行的活,可正是那不到百分之一的排队环节,悄悄决定了整条流水线的最终上限。搞懂并行计算,说到底是搞懂两件事:哪些活能同时干、哪些活必须等着;以及 CPU 和 GPU 这两种完全不同的干法,各自适合什么场景。
2. 天花板从哪来:阿姆达尔定律、机器平衡与性能分析
动手改代码之前,先要承认一件事:加速比有上限,而且这个上限可以先算出来,不用等跑完才知道。很多人并行化的第一步是打开一个线程库,第二步是看到程序没变快,第三步是加更多线程,第四步是发现更慢。把顺序倒过来,先算账再动手,能省掉后面三步。这一章讲的就是这本账怎么算:一条定律告诉你理论上限,一条定律告诉你如何绕开上限,一个模型告诉你瓶颈在算力还是在搬运,最后是一套测量顺序。
2.1 阿姆达尔定律:把上限先算给别人看
阿姆达尔定律的形式极其朴素:一个程序如果可并行部分占比为 p,串行部分占比为 1-p,用 n 个执行单元去跑,加速比 S(n) = 1 / ((1-p) + p/n)。这个式子没有任何微积分,初中代数就能看懂,但它的结论足够扎心——n 趋向无穷大时,p/n 这一项趋近于零,整个式子收敛到 1/(1-p)。也就是说,串行部分占多少,加速比的上限就是它的倒数,加多少核都突破不了。
拿开头那组数字代入:p = 0.992,串行部分 0.008,上限是 1/0.008 = 125 倍。看起来还不错,可这是核数无穷多时的理论值。现实里核数是有限的,达到这个上限的过程相当缓慢:
| 执行单元数 n | 理论加速比 S(n) | 相对上一行的增幅 |
|---|---|---|
| 1 | 1.0 | — |
| 4 | 3.9 | +2.9 |
| 16 | 14.3 | +10.4 |
| 64 | 42.6 | +28.3 |
| 256 | 84.2 | +41.6 |
| 1024 | 111.5 | +27.3 |
| 趋于无穷 | 125.0 | +13.5 |
这张表最值得盯的是最后两行的对比:从 256 个执行单元加到 1024 个,投入翻了四倍,加速比只从 84 涨到 111,收益不到三分之一。再往上加,投入产出比会更难看。这就是为什么在真实的高性能计算项目里,扩到一定规模之后,工程师关心的不再是「还能不能再加机器」,而是「那 0.8% 到底藏在哪一行」。
顺着这个思路还能推出一条实用结论:优化串行部分,收益往往比优化并行部分更大。把串行占比从 0.8% 降到 0.4%,上限就从 125 倍抬到 250 倍,等于凭空多出一倍的理论空间,而你一行并行代码都没多写。反过来说,把并行部分再优化 10%,在 256 核的规模下可能连 1% 的实际提升都换不来。这个反直觉的判断,是很多性能优化工作里最容易被忽略的一条血泪经验。
2.2 古斯塔夫森-巴里斯定律:把问题做大,天花板跟着抬高
阿姆达尔定律有个隐含前提:问题规模是固定的,你只是想把同一块活分给更多人干。可现实中的科学计算、仿真、渲染,往往是「机器变多了,那我就把网格划得更细、把时间步取得更小、把模型做得更真」。这时候适用的是另一条定律:古斯塔夫森-巴里斯定律,S = (1-p) + p·N,其中 N 是执行单元数。
同样取 p = 0.992、N = 256,代入得 S = 0.008 + 0.992×256 ≈ 254 倍。同一个程序、同一个串行比例,阿姆达尔给出 84 倍,古斯塔夫森给出 254 倍,差了整整三倍。矛盾吗?不矛盾,因为两者的假设不同:前者是「工作量不变,看你能多快干完」,后者是「时间预算不变,看你能干多少活」。
提示:讨论加速比之前,先问清楚是「固定问题规模」还是「固定时间预算」。这两个前提下的结论不能互相引用,很多技术讨论吵到最后,其实是两拨人在算两种不同的题。
这条定律的实际意义在于,它把「扩规模」从一个工程问题变成了一个算法问题。如果一个应用天然具备「规模越大、单位计算越划算」的性质,比如三维网格里计算量与边界通信量的比值会随问题变大而上升,那么它在大机器上反而更容易跑出漂亮的扩展曲线。反过来,一个规模很小、边界开销占大头的问题,扔到几千个核上只会变成通信的狂欢。
判断自己的问题属于哪一类,有个粗糙但好用的办法:把问题规模扩大一倍,看看串行部分占比是变大还是变小。变小,说明有扩展空间,往大机器上搬是合理的;基本不变,说明串行部分是结构性的,先回去优化它;变大,那说明你碰上了通信或同步开销,加机器只会让情况恶化。
2.3 机器平衡:算得快,还是喂得饱
搞清楚了理论上限,下一个问题是:程序在单机上到底卡在哪。现代处理器有个尴尬的事实——算术运算的速度早就超过了从内存取数据的速度。处理器每做一个浮点运算的时间,够它从内存里取好几个数。于是就有了「机器平衡」这个概念,它的定义是峰值浮点算力除以内存带宽,单位是「每字节能换来多少次浮点运算」,英文常写作 flops per byte。
把应用的需求也换算成同一个单位,叫算术强度:完成这段计算总共做了多少次浮点运算,除以总共搬运了多少字节。两个数一比,就能判断瓶颈在哪。
举个例子,最经典的流式三元组循环a[i] = b[i] + s * c[i]。每个元素需要读 b 和 c 各一个双精度数,共 16 字节,写回 a 一个双精度数,8 字节,合计 24 字节;运算量是一次乘法加一次加法,2 次浮点运算。算术强度 = 2/24 ≈ 0.083 flops/byte。而一颗典型的服务器级处理器,机器平衡常常在 5 flops/byte 上下。0.083 远小于 5,意味着这个循环根本用不满处理器的计算能力,它被内存带宽死死按住,加再多的计算单元也没用。
这就是为什么性能优化圈子里有句话叫「先看带宽,再看算力」。把这两个量画在一张图上,横轴是算术强度,纵轴是可达性能,就得到所谓的屋顶线模型:左半段是一条随算术强度上升的斜线,代表带宽受限区;右半段是一条水平线,代表算力受限区;两者交点对应的算术强度,正好等于机器平衡。任何一个循环、任何一个核函数,都能在这张图上找到一个位置,位置决定了你该往哪个方向优化——在斜线区优化计算逻辑是白费力气,在水平线区优化访存同样是白费力气。
2.4 先测量再动手:性能分析的正确顺序
有了定律和模型,还需要一套执行顺序,否则很容易陷入「我觉得这里慢」的玄学。这本 2021 年的著作给出的顺序相当克制:先算理论值,再测实际值,最后才 profile 应用。
第一步是算硬件的理论峰值。浮点峰值 = 核心数 × 每核心每周期的乘加次数 × 2 × 主频。乘 2 是因为一次乘加算两次浮点运算。内存带宽的理论值 = 内存频率 × 位宽 / 8 × 通道数。这两个数厂商都会标,但标称值和实测值之间通常有 20% 到 40% 的差距。
第二步是实测。用流式三元组测实际能跑到多少带宽,用大规模矩阵乘测实际能跑到多少浮点性能。这两个基准程序的价值在于,它们分别逼近了带宽和算力的实际天花板,能告诉你「这台机器真正能给你多少」。理论值打七折是常态,拿理论值当基准去评估应用,只会得到一堆虚假的「效率低下」。
第三步才是 profile 应用本身,找出热点函数、热点循环,算它的算术强度,看它落在屋顶线的哪个区域。顺带还要看缓存的行为——缓存缺失通常分三类: compulsory miss 是第一次访问某块数据时必然发生的,躲不掉;capacity miss 是工作集太大、缓存装不下导致的;conflict miss 是数据映射到同一组缓存行、互相挤占导致的。三类缺失的应对手段完全不同:第一类只能靠改算法减少总访问量,第二类要靠分块让工作集塞进缓存,第三类靠填充数组或调整步长打散映射。不区分类型就瞎调,多半是在给编译器添乱。
顺序对了,优化才有方向。顺序反了,就会变成「改一版、跑一次、快了点、不知道为什么、下次又不灵」的循环,这在性能优化里是最消耗人的一种状态。
3. CPU 的接力赛:向量化、OpenMP 与 MPI 三层并进
CPU 上的并行不是一个开关,而是三层叠起来的接力。最底层是单核内部的向量化,一条指令同时处理多个数;中间层是单机多核的线程并行,把循环拆给几个核;最上层是多机多进程的消息传递,把一个大问题切给整台集群。三层各管一段,混起来用才是常态。理解这三层的分工,比记住任何一条编译选项都重要。
3.1 向量化:一条指令处理四个数
现代 CPU 的每个核心里都有一套 SIMD 单元,全称是单指令多数据。普通指令一次处理一个数,SIMD 指令一次处理一组数——四个单精度、两个双精度、或者八个整数,具体看寄存器宽度。理论上这是「免费的加速」:不用改算法,不用加线程,只要让编译器愿意生成向量指令,同一段循环就能快上几倍。
麻烦在于编译器很保守。它必须确认循环的每一次迭代互不干扰,才敢把四次迭代打包成一条向量指令。只要有一点点不确定,比如两个指针可能指向同一块内存,它就退回标量版本,性能原地踏步。
// 连续访存、无跨迭代依赖的循环,最容易被自动向量化 void add_arrays(const double * restrict a, const double * restrict b, double * restrict c, int n) { #pragma omp simd // 明确声明:各次迭代彼此独立,可以放心打包 for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; // 每次迭代只碰自己的下标,没有前后依赖 } }这段代码里有两处关键。restrict是给编译器的承诺:a、b、c 三块内存互不重叠,编译器不必担心写 c 会污染 a。没有这个关键字,编译器只能假设最坏情况,向量化直接放弃。#pragma omp simd则是更明确的指令,它属于 OpenMP 标准的一部分,主流编译器都认,比各家私有的编译指示可移植得多。参数方面,n是元素个数,函数本身不检查边界,调用方要保证三块数组都至少有 n 个元素。
向量化还有更激进的路子:手写 intrinsics,也就是直接调用对应 SIMD 指令集的内部函数;再激进就是内联汇编。这两条路的性能上限更高,但代码会绑死在某一种指令集上,换台机器就得重写。绝大多数场景下,调整好数据布局和循环结构,让编译器自动向量化,性价比最高。
真正影响向量化成功率的,往往是编程风格而不是编译选项。几条经验:内层循环尽量连续访问内存,别做跨步跳跃;循环体内避免 if 分支,分支会打断打包;数据结构尽量按「同类聚在一起」组织。这些看起来是代码风格问题,实际上是硬件偏好的问题。
3.2 OpenMP:贴在循环上的一张便签
单核的向量化榨干了,接下来是把多个核用起来。OpenMP 的思路极其轻量:在串行代码上加几行编译指示,编译器自动生成线程创建、任务分配和汇合的代码。原来的程序不加指示照样能编译运行,只是跑在单线程上。这种「渐进式改造」的特性,是它被广泛接受的主要原因。
最常用的是循环级并行:在一个 for 循环前加一行#pragma omp parallel for,循环的迭代就被分给多个线程。但这里有个经典翻车点——变量作用域。
double total = 0.0; #pragma omp parallel for reduction(+:total) // 每个线程持有私有副本,结束时归并 for (int i = 0; i < n; i++) { total += f(x[i]); // 少了 reduction 子句,多个线程同时写 total 就是数据竞争 }reduction(+:total)的含义是:每个线程先各自维护一份 total 的私有副本,循环结束后按加法把所有副本合并成一份。没有这个子句,所有线程会同时往同一个内存地址上累加,结果随机出错,而且往往在小数据量下测不出来,一上规模就开始飘。这类错误就是并行调试里的黑匣子——程序不崩、不报错,只是答案不对。
除了 reduction,还有几个高频使用的子句:private(x)声明每个线程各有一份 x 的副本,循环变量默认就是私有的;shared(x)声明所有线程共用一份;schedule(static)适合每次迭代耗时均匀的情况,schedule(dynamic)适合耗时差异大的情况,前者开销小,后者负载均衡好。
还有一个隐蔽的性能杀手叫伪共享:两个线程各自修改变量 a 和 b,这两个变量恰好落在同一条缓存行里。硬件层面这条缓存行会在两个核之间来回弹,谁都写不痛快。解决办法是把它们用填充字节隔开,或者干脆让每个线程操作独立的数组段,最后再合并。
OpenMP 还有更高层的用法,比如把整个函数并行化、用 task 构造处理不规则任务、用 target 构造把计算卸载到加速器上。层级越往上,能榨出的性能越多,需要操心的细节也越多。
3.3 MPI:进程之间那条必须自己铺的路
OpenMP 管的是同一台机器、共享内存的多核。跨机器就完全是另一回事了:每台机器有独立的内存空间,进程之间看不见对方的数据,想交换信息必须显式地发消息。MPI 就是这套消息传递的标准接口。
最小可用的 MPI 程序只需要四个调用:初始化、拿编号、拿总数、结束。
MPI_Init(&argc, &argv); // 启动 MPI 环境 int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); // 我是第几号进程 MPI_Comm_size(MPI_COMM_WORLD, &size); // 一共多少个进程 int local_n = n / size; // 每个进程分到的数据量 int start = rank * local_n; // 我这个进程负责的起始下标 double local_sum = 0.0; for (int i = start; i < start + local_n; i++) { local_sum += x[i]; // 各算各的那一段 } double global_sum = 0.0; MPI_Allreduce(&local_sum, &global_sum, 1, MPI_DOUBLE, MPI_SUM, MPI_COMM_WORLD); // 所有进程的 local_sum 相加,结果广播回每一个进程MPI_Comm_rank返回的 rank 是进程的唯一编号,从 0 开始,程序用它来决定「我该负责哪一块数据」——这就是数据分解的核心。MPI_Allreduce属于集合通信,参数依次是:本地输入、接收结果、数据个数、数据类型、归约操作、通信域。它做的事可以类比成开会:每个人报出自己的数,主持人加总,然后把总数告诉在场所有人。
除了 Allreduce,集合通信还有广播(一份数据发给所有人)、散播(把不同数据分给不同人)、收集(把所有人的数据按顺序收上来)、屏障(所有人到齐才继续)。点对点通信则是 Send 和 Recv 的配对,适合相邻进程交换边界数据。
说到边界数据,就不得不提幽灵单元格交换:二维网格划分给多个进程后,每个进程计算自己那块区域时,需要邻居进程边界外一圈的数据。于是每个时间步都要和上下左右四个邻居交换一圈数据。这个模式在流体、气象、结构仿真里反复出现,通信量随网格周长增长,而计算量随面积增长,所以网格越大,通信占比越低——这正是古斯塔夫森定律在实际应用中的体现。
3.4 混合编程:为什么两个都要
既然 MPI 能跨机器,OpenMP 能用多核,为什么不干脆全用 MPI,每个核起一个进程?可以,但代价不小。进程数一多,每个进程都要维护独立的内存副本,边界数据要复制更多份,通信的消息数量呈平方级增长;而且进程间切换的开销比线程切换大得多。
混合模式的分工是:机器之间用 MPI,机器内部用 OpenMP 线程。好处很直接——进程数从「核数」降到「节点数」,每个进程内部的多个线程共享同一份内存,边界数据的副本数量大幅减少,消息数量也随之下降。
代价是复杂度上升。线程安全级别要确认,MPI 调用在什么情况下可以被多个线程同时发起有明确的规定;线程和核的绑定关系要配置好,否则操作系统会把两个线程调度到同一个核上,白白浪费一个物理核。这些细节不影响正确性,只影响性能,所以最容易被跳过,然后在压测时被现实打脸。
4. GPU 为什么快:它不是更快的 CPU,是另一种生物
把 GPU 当成「核更多的 CPU」是最常见的一处认知偏差,而且这个偏差会一路带偏后面的所有决策。GPU 快的真正原因不是单个核心更强——恰恰相反,它的每个核心比 CPU 的核心弱得多,频率也更低。它快是因为它有几千个这样的弱核心,而且它们被组织成「同一时刻执行同一条指令」的队形。理解这个队形,比记住任何一款显卡的参数都重要。
4.1 SIMT:成千上万个「我」同时执行同一条指令
GPU 的执行模型叫 SIMT,单指令多线程。它的基本单位有明确的层级:最小的执行单位是一个工作项,对应一个逻辑上的线程;若干工作项组成一个工作组,工作组内的成员可以通过共享内存交换数据、通过屏障同步;再往上一层是网格,也就是整个核函数的一次启动。
真正决定性能的是工作组内部的分组:硬件会把工作项按固定大小打包成一批,这一批里的所有工作项在同一时刻执行同一条指令,这个小组在不同厂商的术语里叫 warp、wavefront 或 subgroup,大小通常是 32 或 64。
// 核函数示意:每个工作项算出自己的全局编号,只处理属于自己的元素 __global__ void scale(double *a, double factor) { int gid = blockIdx.x * blockDim.x + threadIdx.x; // 全局编号 = 所在工作组的编号 * 每组工作项数 + 组内编号 a[gid] = a[gid] * factor; // 同一指令,被整批工作项同时执行 }blockIdx.x是当前工作组的编号,blockDim.x是每个工作组里工作项的个数,threadIdx.x是工作项在组内的编号。三者一组合,就得到了全局唯一的编号,用来定位「我该处理哪个元素」。这种「先算我是谁,再干我的活」的写法,是 GPU 编程里最基础也最普遍的模式。
锁步执行带来一个必须正视的代价:分支发散。一批工作项本来齐步走,遇到 if 语句时,一部分走 then、一部分走 else,硬件只能先让一批走完 then,再让另一批走完 else,两批串行。分支越多、越散,浪费越严重。所以在 GPU 上,把分支改成算术运算、把条件判断提前到数据组织阶段,往往比优化计算本身更有效。
4.2 寄存器、占用率与显存墙
GPU 的片上资源是有限的,其中最紧张的是寄存器。每个工作项都要占若干个寄存器,一个工作组的所有工作项加起来,不能超过流多处理器上的寄存器总数。寄存器用得多,能同时驻留的工作组就少;驻留得少,硬件就没有足够的任务来掩盖访存延迟。
衡量这个状态的指标叫占用率,等于实际驻留的工作项数除以硬件支持的最大工作项数。占用率高不代表一定快,但占用率过低通常意味着性能上不去,因为一旦某个工作组在等内存,没有别的工作组能顶上来干活。
注意:占用率不是越高越好。有些计算密集型核函数,用大量寄存器换取更少的重复计算,占用率只有三成反而比占满更快。这是一个需要实测的取舍,不存在通用最优值。
另一堵墙是显存带宽。GPU 的显存带宽比 CPU 高一个数量级,但它的算力也高了一个数量级,所以屋顶线模型在这里同样适用,而且斜率更陡。很多看起来「计算量很大」的核函数,一分析算术强度,仍然落在带宽受限区。这是 GPU 优化里最常见的判断失误:以为瓶颈在算,其实在搬。
4.3 主机与设备之间那座窄桥
GPU 有自己的显存,和主机内存是物理隔离的两块空间。数据要在两边来回搬,通道是主板上的总线。这条通道的带宽,比显存带宽低一到两个数量级。
这个差距决定了一条铁律:数据搬运的代价必须被计算摊薄。如果一块数据搬过去只要 1 毫秒,而计算要 100 毫秒,那搬运开销可以忽略;如果计算只要 2 毫秒,那搬运就吃掉了三分之一的收益,甚至可能让整体比纯 CPU 还慢。
工程上的对策有三条。第一是把多次小搬运合并成一次大搬运,减少启动开销。第二是用异步队列,让数据拷贝和核函数计算在不同队列里重叠进行,硬件会在搬运的同时执行计算。第三是尽量把整个计算流程都留在显存里,中间结果不要来回搬——所谓「数据留在设备上」,说的就是这件事。
多卡系统还要考虑卡与卡之间的通信。走主板总线的卡间通信,带宽可怜;走专用高速互连的卡间通信,能快上一个数量级。所以在多卡扩展时,拓扑结构的影响可能比算法优化还大。
4.4 指令式还是原生语言:五条上手路径的取舍
把代码搬到 GPU 上,有好几条路,改动量和性能上限成反比。
| 路径 | 代码改动量 | 性能上限 | 可移植性 | 适合的场景 |
|---|---|---|---|---|
| 指令式(OpenACC / OpenMP target) | 小,加编译指示即可 | 中 | 好,多家编译器支持 | 已有 C/C++/Fortran 代码,想快速试水 |
| 原生 GPU 语言(CUDA / HIP) | 大,要重写热点核函数 | 高 | 弱,绑定特定生态 | 热点明确、愿意为性能付出维护成本 |
| 开放标准(OpenCL / SYCL) | 大 | 中高 | 好,跨厂商 | 需要跑在多种硬件上 |
| 性能可移植层(Kokkos / RAJA) | 中,要改数据结构和循环写法 | 中高 | 很好,一套代码多后端 | 长期维护的科学计算代码库 |
指令式编程的价值在于「先看看能快多少」。在已有的循环前加一行#pragma acc parallel loop或者#pragma omp target teams distribute parallel for,编译器就会尝试生成 GPU 代码。如果加速比已经可观,可能根本不需要往下走。如果不够,再针对热点核函数手写原生代码,把 80% 的时间花在 20% 的代码上。
原生语言的价值在于控制力。共享内存怎么用、寄存器怎么省、数据怎么组织、异步怎么排,全部由你决定,性能上限最高。代价是代码与某个生态绑定,换硬件平台可能要重写。HIP 这类方案的出现,某种程度上是在缓解这个问题:语法接近,工具链可以互相转换。
性能可移植层的思路更激进:用一套抽象描述并行模式,编译时选择后端。写一次,可以在 CPU、不同厂商的 GPU 上分别编译运行。代价是要接受它的数据结构约定,某些极限优化做不了。
选哪条路,取决于代码的生命周期。一次性的分析脚本,指令式最划算;要维护十年的仿真代码,可移植层更稳妥;性能就是产品竞争力的场景,原生语言别无选择。
5. 五个最容易搞混的概念:别在起跑线上翻车
并行计算领域的术语密度很高,而且很多词日常用法和技术含义不一样。这些混淆不会让程序报错,只会让判断一路跑偏——选错工具、定错目标、优化错地方。下面五组是初学者最常踩的坑,每一组都值得停下来想清楚。
5.1 并发不是并行
易混点:两个词在日常语境里几乎同义,中文翻译也经常互换。
为什么会混:都描述「多个任务同时推进」,字面上看不出差别。
怎么区分:并发是结构性质,指程序能被拆成多个可以交错执行的逻辑流;并行是执行性质,指这些逻辑流真的在同一时刻被不同的物理单元执行。一个单核机器上的多线程程序是并发的,但不是并行的——操作系统在多个线程之间快速切换,宏观上像同时进行,微观上任何时刻只有一个线程在跑。并发解决的是「怎么组织代码结构」,并行解决的是「怎么用满硬件」。判断标准很直接:把程序放到单核上跑,性能不掉,说明它只是并发;性能掉到 1/N,说明它真的并行了。
5.2 核多不等于跑得快:加速比与效率
易混点:加速比和并行效率经常被当成同一件事,或者只用加速比来评价优化是否成功。
为什么会混:两个指标都随核数变化,而且都往「越大越好」的方向理解。
怎么区分:加速比是「用 N 个单元比用 1 个单元快多少倍」,效率是「加速比除以 N」,衡量每个单元的利用率。16 核跑到 14 倍加速,效率是 87.5%,相当不错;1024 核跑到 111 倍加速,效率只有 10.9%,听起来很惨,但如果问题规模也随之扩大了,这个效率数字的意义完全不同。更要命的是,加速比的基线选错会得出完全相反的结论:拿一份没优化过的串行代码当基线,任何并行版本都显得很快;拿一份高度优化过的串行代码当基线,并行版本可能根本打不过。这也是为什么同一段代码,不同人测出来的加速比能差好几倍。
5.3 GPU 不是万能加速卡
易混点:看到「GPU 有几千个核心」,就认为任何计算搬上去都能快几十倍。
为什么会混:核心数量的对比太直观了,几千对几十,很难不产生错觉。
怎么区分:GPU 的优势建立在三个前提上——有海量独立的工作项、每个工作项的计算相对简单、数据能成批搬运。三者缺一,加速比就会大打折扣。一个强依赖前后迭代的循环(比如递推、串行的累积求和),在 GPU 上几乎得不到好处,因为锁步执行要求同一批工作项走同一条路径,有依赖就只能排队。一个小规模的问题,搬数据的开销比计算还大,跑完一测反而更慢。一个有大量分支的发散型代码,锁步执行会让分支两侧轮流空转。判断方法很朴素:先算算术强度,再看工作项数量是否远大于核心数,两个条件都满足再考虑移植。
5.4 向量化和多线程是两码事
易混点:都叫「并行」,都被当成提升性能的手段,经常被混在同一句话里讨论。
为什么会混:两者确实都是让多个运算同时发生,只是发生在完全不同的层级。
怎么区分:向量化发生在单个核心内部,一条指令处理一组数据,靠的是 SIMD 单元,不需要创建线程,也没有线程间通信;多线程发生在核心之间,多个线程各跑一段代码,需要同步、需要考虑数据共享。两者的优化手段完全不通用:向量化关心的是循环体内有没有依赖、内存访问连不连续、有没有分支;多线程关心的是任务怎么切分、负载是否均衡、同步开销多大。而且它们会互相影响——一个循环向量化之后,每次迭代的计算量变大,多线程切分的粒度也要跟着调整。实践中经常出现「向量化做好之后,多线程反而扩展性变差」的情况,原因就在这里。
5.5 AoS 和 SoA 看着只是写法不同
易混点:结构体数组和数组结构体,只是数据摆放顺序的差异,很多人认为对性能影响不大。
为什么会混:在串行代码里,两种写法确实都能跑,逻辑完全一样,编译器也不会报错。
怎么区分:差别在内存访问模式上。结构体数组把同一个对象的所有字段放在一起,适合「一次要用到某个对象的全部字段」的场景;数组结构体把不同对象的所有同名字段放在一起,适合「一次只用到所有对象的某一个字段」的场景。后者在向量化和 GPU 上优势巨大,因为一批工作项要读取的是同一个字段,连续存放才能合并成一次宽访存。用错了,访存次数会翻几倍,而且这种损失在 profile 里往往表现为「带宽利用率低」,很难直接定位到数据布局上。还有一个折中方案叫「结构体数组的数组」,把数据按小块分组,兼顾两种访问模式,代价是索引计算变复杂。
6. 什么时候该并行、什么时候别碰:一份边界判断
前面几章讲的是「怎么做」,这一章讲「该不该做」。这个问题比技术细节更重要,因为一次错误的并行化,可能把一个跑 10 分钟的程序变成跑 25 分钟,还附赠一堆难以复现的随机错误。
判断顺序建议是这样的:先看这个程序跑一次要多久。如果只要几秒钟,并行化带来的收益抵不上你调试它的时间,直接放弃。如果几分钟到几小时,再往下看。接着看热点集中度:拿性能分析工具跑一遍,如果 90% 的时间集中在少数几个循环里,那就有明确的优化目标;如果时间均匀散落在上百个函数里,那说明瓶颈是结构性的,加线程只会把开销摊到更多地方。
然后看并行度。把热点循环的迭代次数算出来,如果只有几十次,而你有几十个核,每个核分到的活太少,调度开销会吃掉全部收益。经验阈值是迭代次数至少要比执行单元数大一个数量级,才有稳定的收益。
最后看瓶颈类型。用屋顶线模型算一下热点的算术强度,落在带宽受限区的,优先想办法减少数据搬运、改善数据布局,加核没用;落在算力受限区的,才值得考虑上 GPU 或者加线程。
| 场景 | 建议 | 原因 |
|---|---|---|
| 程序总时长几秒,热点分散 | 不并行 | 收益低于调试成本 |
| 迭代次数只有几十次的多层循环 | 优先改算法 | 粒度太细,调度开销吃掉收益 |
| 热点算术强度远低于机器平衡 | 先改数据布局 | 瓶颈在带宽,加核无效 |
| 热点集中于少数连续访存的循环 | 向量化 + 多线程 | 典型的可并行结构 |
| 强前后依赖的递推、累积 | 换算法或分段处理 | 锁步执行下无法并行 |
| 数据规模远超单卡显存 | 多卡 + 分块 | 单卡放不下,只能分布式 |
我自己有过一次很典型的翻车:一个两小时跑完的仿真,热点循环里有个累加求和,我图省事直接加了并行指示,忘了处理变量的作用域。小规模测试跑了十几遍都对,上了正式算例之后,结果每次都不一样。查了两天才定位到那行代码,因为错误不是崩溃,而是数值缓慢偏移,看起来特别像物理模型本身的问题。从那以后,我每次动并行代码之前,都强制走一遍「先算上限、再测带宽、再定位热点、最后才改代码」的流程,一步都不跳。慢是慢了点,但比事后查随机错误快得多。
这套完整资料里,从为什么并行、怎么规划、性能模型怎么建,到 CPU 侧的向量化与消息传递、GPU 侧的架构与编程模型,再到亲和性、批处理调度和并行文件操作,十七个章节一层层铺下来,每一章后面还配了练习。真要把并行计算这件事弄明白,按顺序读一遍,比零散地搜帖子要省太多弯路。希望帮到你。
本文还有配套的精品资源,点击获取