简介:《Parallel and High Performance Computing》是 Manning 出版社 2021 年推出的英文技术书籍,作者 Robert Robey 与 Yuliana Zamora 具有多年高性能计算工程经验。全书围绕“并行化”主线,从多核 CPU 到 GPU 加速,系统讲解并行程序的设计与分析,适合科学计算、数据分析、后端性能优化等场景的开发者与研究人员阅读。资源为 1 个 PDF 文件,大小约 35.83MB,属于完整电子版,内容清晰、可全文检索,方便对照目录章节按需学习;目前已有 691 人学习下载,是并行计算入门与进阶的常用参考。书中第一部分介绍并行计算引入、并行化规划、性能限制与剖析、数据设计及性能模型、并行算法与模式;第二部分深入 CPU 并行核心,重点讲解向量化、OpenMP 与 MPI 的实践方法;整体覆盖从单机向量化、共享内存并行到分布式消息传递的完整路径,可帮助读者掌握性能瓶颈识别、并行方案选型与调优手段,并在 GPU 场景中建立扎实的并行思维。
1. 从一份 2021 年的 Manning 并行计算书稿说起:并行到底在解决什么问题
2021 年 5 月的 Manning 书稿《Parallel and High Performance Computing》,翻完最大的感受是:并行计算不是一门语言,是一笔账。我在某实验室维护过一个模拟项目X,单核要跑 9 小时,加了 OpenMP 以为能快 4 倍,实际只有 1.7 倍,瓶颈在内存带宽而不是 CPU。这类翻车案例在这份书稿里拆得很细:先算硬件账,再决定要不要多线程,最后才改代码。
它适合已经写过大量循环和数值代码、机器有 8 核以上却只用一个核的开发者,也适合接手并行代码后被竞态和调度折磨过的人。下面按我落地的顺序,把硬件选型、OpenMP、MPI 和性能分析这条路拆开讲。每个环节我都会给出能直接跑的命令和参数,并说明踩过的坑在哪里。
2. 先看硬件再谈并行:线程、核心、缓存与内存带宽的账
2.1 为什么「更多核心」不等于「更快」:四个性能瓶颈
并行化之前,先要承认一个事实:多核并不线性放大性能。最常见的原因是四个瓶颈,我按出现频率排一下。
第一个是内存带宽。所有物理核心共享同一条内存总线,当每个线程都在大量读数组时,内存带宽会先于 CPU 算力到顶。表现是线程数翻倍,运行时间几乎不动。判断方法很简单:把数组规模扩大到内存压力的量级,再测不同线程数的耗时。
第二个是缓存一致性带来的伪共享。多个线程写同一个 cache line 里相邻的变量时,硬件会让整条缓存行在核之间反复失效。表现是线程越多越慢,或者加速比卡在一个很低的水平。
第三个是同步开销。锁、临界区、屏障都要时间,当每个线程的计算量太小时,同步成本会吞掉并行收益。表现是并行区耗时小于同步耗时,加速比反而低于 1。
第四个是串行尾。Amdahl 定律算的是硬上限:一个程序如果有 10% 的串行部分,32 核的理论加速比也只有约 8.5 倍;串行部分到 30%,32 核上限只剩 3 倍多。这四个瓶颈对应不同的排查工具,我习惯先列一张表再动手。
| 瓶颈 | 典型现象 | 常见场景 | 快速定位手段 |
|---|---|---|---|
| 内存带宽饱和 | 线程翻倍,时间不变 | 大数组求和、矩阵运算 | llcbench、perf stat 看带宽 |
| 伪共享 | 线程越多越慢 | 多线程写相邻计数器 | perf record 看 cache-misses |
| 同步开销大 | 并行区越切越碎 | 锁保护了整个循环 | 把临界区拆小再测 |
| 串行尾拖累 | 加速比早早封顶 | 初始化、IO、归并 | 打点统计串行占比 |
2.2 用 lscpu 和 /proc/cpuinfo 摸清机器的家底
拿到一台机器,我第一步永远是看它的拓扑结构,而不是直接写代码。下面的命令在 Linux 上通用,能回答三个问题:有几个物理核、有没有超线程、内存是不是分 NUMA 节点。
lscpu echo "---- 逻辑核与物理核映射 ----" cat /proc/cpuinfo | grep -E "processor|physical id|core id|siblings|cpu cores" | head -20lscpu 输出的关键字段是 Socket(s)、Core(s) per socket、Thread(s) per core 和 CPU(s)。举例:CPU(s)=16、Thread(s)=2、Core(s)=8、Socket(s)=1,说明这是 1 颗 8 核 16 线程的 CPU,逻辑上能并行的 16 个线程其实共享 8 个物理核。超线程能不能用要实测:我一般会在任务队列里分别跑 8 线程和 16 线程,如果 16 线程相对 8 线程的加速不足 1.2 倍,后续就只按物理核数设线程。
/proc/cpuinfo 里的 physical id 和 core id 组合起来可以判断两个逻辑核是否属于同一个物理核。做性能对比时,不要让两个线程抢同一个物理核,除非你在故意测试超线程收益。NUMA 节点信息也在 lscpu 里,跨 NUMA 节点访问远端内存会比本地慢一大截。如果程序的数据集超过单节点内存,要么用 numactl --interleave 均衡分配,要么干脆上 MPI。
2.3 从 Amdahl 定律估算理论上限:一个可复用的 Python 脚本
看清楚硬件后,先算理论收益再决定值不值得并行。这段脚本很短,但能帮你避免在串行占比高的情况下白写一堆并行代码。
# amdahl.py:输入串行占比与核心数,估算加速比上限 p = 0.10 # 串行部分占比,0.10 表示 10% for n in [1, 2, 4, 8, 16, 32]: speedup = 1.0 / ((1 - p) + p / n) print(f"cores={n:2d} speedup_max={speedup:.2f}")Amdahl 定律的公式是加速比 = 1 / ((1 - p) + p / n),p 是串行部分占整体运行时间的比例。这里的难点是 p 怎么测:我常见做法是在程序的关键阶段前后打点,把初始化、IO、单线程归并这类无法并行的耗时加起来除以总时间,得到 p。也可以用 perf stat 的 time 指标估算一个区间,但打点更直观。
这个脚本的价值不是精确预测,而是定方向。如果你测出 p=0.3,32 核上限只有 3 倍多,那应该优先优化串行部分,而不是继续加线程。反过来,p 小于 0.05 时,并行化收益会非常可观,值得投入。
3. 第一个能跑的并行程序:OpenMP 从编译到加速比
3.1 OpenMP 最小可运行例子:pragma 与编译参数
OpenMP 是共享内存并行模型,适合单机多核。最小例子不用造工程,一个 C 文件和一行编译命令就够。下面的程序对 1 亿个 double 求和,规模约 800MB,能撑起足够的并行负载。
#include <stdio.h> #include <stdlib.h> #include <omp.h> #define N 100000000 int main(void) { double *a = malloc(N * sizeof(double)); double sum = 0.0; for (int i = 0; i < N; i++) a[i] = i * 1.0; double t0 = omp_get_wtime(); #pragma omp parallel for reduction(+:sum) for (int i = 0; i < N; i++) { sum += a[i]; } double t1 = omp_get_wtime(); printf("sum=%f time=%f\n", sum, t1 - t0); free(a); return 0; }编译和运行分别用 gcc 和 OMP_NUM_THREADS 控制线程数:
gcc -O2 -fopenmp -o sum_openmp sum_openmp.c OMP_NUM_THREADS=4 ./sum_openmp#pragma omp parallel for reduction(+:sum) 拆成三件事:parallel 启动一个线程组;for 把循环迭代拆分给各线程;reduction(+:sum) 让每个线程持有 sum 的私有副本,结束后按加法归约。如果不写 reduction,sum 会被多个线程同时写,结果就是竞态,数值每次都不同。
-O2 是最低建议优化级别,-fopenmp 让编译器识别 pragma 并链接 OpenMP 运行时。omp_get_wtime() 是 OpenMP 自带的计时接口,比手动 clock() 更准,返回的是墙上时钟,能直接看到并行效果。
3.2 三个必调参数:OMP_NUM_THREADS、schedule、chunk
很多人在 OpenMP 上翻车,问题不在 pragma,而在环境变量。我每次调并行程序,必看三个参数。
export OMP_NUM_THREADS=8 export OMP_PROC_BIND=close export OMP_PLACES=cores ./sum_openmpOMP_NUM_THREADS 是线程数。不是越大越好,我从物理核数开始测,再往上加超线程,记录加速比曲线后选定。OMP_PROC_BIND=close 把线程绑定在相邻核心上,避免操作系统把线程在不同核之间来回迁移,迁移会带来缓存失效和调度延迟。OMP_PLACES=cores 表示绑定对象是物理核心而不是逻辑线程。
循环调度方式由 schedule 控制,直接影响负载均衡。下面是三种常见写法:
#pragma omp parallel for schedule(static, 4) #pragma omp parallel for schedule(dynamic, 16) #pragma omp parallel for schedule(guided)static, 4 表示编译期按每次 4 个迭代切块,适合每次迭代计算量均匀的循环,开销最小,也是默认行为。dynamic, 16 表示运行时按 16 个迭代一块领取,适合迭代耗时有波动的场景,能防止某个线程拿到一大块重活导致整段并行区等它。guided 是动态调整块大小,块从大变小,兼顾均衡和调度次数。
我的经验是:先跑 static 看负载均衡,如果加速比异常低,再用 dynamic 对比。chunk 值不是越小越好,块越小调度越频繁,调度开销反而上升。chunk 一般取迭代总数除以线程数的 1 到 4 倍之间。
提示:schedule(static) 在计算量均匀时最安全,不要一上来就 dynamic。dynamic 解决的是「个别迭代特别慢」的问题,不是提速灵药。
3.3 用加速比和效率判断并行收益
跑出几组时间后,要用指标说话。加速比 = 串行时间 / 并行时间,效率 = 加速比 / 线程数。我习惯写一段小脚本处理多次运行结果。
# 分别测 1、2、4、8 线程 for t in 1 2 4 8; do OMP_NUM_THREADS=$t ./sum_openmp done把上面的 time 输出整理后,用这个小脚本算指标:
# speedup.py times = {1: 0.80, 2: 0.42, 4: 0.22, 8: 0.18} # 来自上面运行输出 base = times[1] for t, sec in times.items(): speedup = base / sec eff = speedup / t * 100 print(f"threads={t} speedup={speedup:.2f} efficiency={eff:.0f}%")效率高于 80% 时,并行扩展性不错,值得继续加规模。效率跌到 50% 以下,先怀疑内存带宽饱和、锁竞争和伪共享,而不是急着加线程。单次运行结果有波动,正确的做法是每组线程数跑 5 遍取中位数,后面第 6 章我会说具体命令。
4. 跨节点的并行:MPI 设计与通信代价
4.1 进程模型与消息传递:什么时候该上 MPI
OpenMP 锁死在单机共享内存里,数据集超过单机内存或者要跨多台机器,就得换 MPI。MPI 的进程模型和 OpenMP 完全不同:每个进程拥有独立地址空间,没有共享变量,数据交换必须显式调用消息传递接口。代价是代码变复杂,收益是能扩展到集群。
| 维度 | OpenMP | MPI |
|---|---|---|
| 内存模型 | 共享内存,多线程 | 分布式内存,多进程 |
| 数据交换 | 直接读写共享变量 | 消息传递 |
| 适用规模 | 单机多核 | 多机、大数据集 |
| 排错难度 | 竞态隐蔽 | 死锁常见 |
| 改造成本 | 加 pragma 即可 | 要重新组织数据流 |
我的选型经验是:单机且只是想缩短计算时间,先上 OpenMP;数据单机装不下,或者要跑几十上百核的集群,直接 MPI。还有一种过渡做法是 OpenMP + MPI 混合,但新手不建议一上来就混合,通信和线程叠加在一起,排错难度翻倍。
4.2 一个最小 MPI 程序:从 mpicc 到运行
MPI 的最小程序不是 hello world 打印,而是「0 号进程把一条消息发给所有其他进程」。这能验证通信环境是否正常,也能暴露最基础的收发顺序问题。
#include <mpi.h> #include <stdio.h> #include <string.h> int main(int argc, char **argv) { MPI_Init(&argc, &argv); // 初始化 MPI 环境 int rank, size; MPI_Comm_rank(MPI_COMM_WORLD, &rank); // 当前进程编号 MPI_Comm_size(MPI_COMM_WORLD, &size); // 总进程数 char msg[32]; if (rank == 0) { strcpy(msg, "hello from root"); // 0 号进程把消息发给其它每一个进程 for (int i = 1; i < size; i++) MPI_Send(msg, 32, MPI_CHAR, i, 0, MPI_COMM_WORLD); } else { MPI_Recv(msg, 32, MPI_CHAR, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("rank %d got: %s\n", rank, msg); } MPI_Finalize(); // 清理 MPI 环境 return 0; }编译和运行:
mpicc -O2 -o mpi_hello mpi_hello.c mpirun -np 4 ./mpi_helloMPI_Init 之后每个进程有独立的 rank,0 号通常作为协调者。MPI_Send 的参数依次是缓冲区、消息长度、数据类型、目标进程、消息标签、通信域。MPI_Recv 对应接收,最后的 MPI_STATUS_IGNORE 表示不关心消息状态信息。注意这里的 32 是字节数,因为类型是 MPI_CHAR;换成 double 数组时,长度要写成数组元素个数而不是字节数,这是个常见翻车点。
运行输出顺序可能不固定,因为各进程独立打印,这是正常现象。mpirun -np 的进程数一般不超过物理核心数,超过后多出来的进程会争抢 CPU,时间反而变差。
4.3 估算通信耗时:带宽、延迟与消息大小的关系
MPI 程序写完后,性能瓶颈往往不在计算而在通信。消息传输时间的近似模型是总时间 = 延迟 + 消息大小 / 带宽。小消息的成本几乎全是延迟,大消息才受带宽影响。
# comm_cost.py latency = 1e-5 # 10 微秒,局域网级 bandwidth = 1e9 # 1 GB/s 估算 for size in [1, 1024, 1024*1024, 16*1024*1024]: t = latency + size / bandwidth print(f"size={size:>10} bytes est_time={t*1e6:.2f} us")运行结果能看到一个关键趋势:1 字节的消息耗时约 10 微秒,而 1MB 消息约 1010 微秒,前者完全被延迟支配。如果程序里每次迭代只交换几个整数,循环 10 万次,通信时间会远超计算时间。
更精确的做法是写一个 ping-pong 小基准:0 号进程向 1 号发一条消息,1 号立刻回一条,循环 1000 次,用 MPI_Wtime() 记录总耗时,再除以 2 倍次数得到单程延迟。我一般先用上面的估算模型判断通信是否可能是瓶颈,再决定要不要花时间写基准。如果估算结果显示通信占比很小,那性能问题大概率在计算侧,不用折腾消息合并。
5. 并行程序避坑指南:竞态、伪共享与性能分析
5.1 竞态条件:现象、原因与解决
现象:同一份输入,程序有时输出正确,有时错误,而且线程数越多越容易出错。这是竞态条件最典型的信号。
原因:多个线程同时读写同一个共享变量,读写操作交错,后写覆盖先写。OpenMP 里最常见的就是循环累加共享变量。
解决方式有三种,性能差别很大。下面这段代码展示了从错误到正确再到推荐的演进:
// 错误:没有 reduction,也没有锁,sum 被多线程同时写 #pragma omp parallel for for (int i = 0; i < N; i++) sum += a[i]; // 修改 1:加 atomic,保证单条语句原子性 #pragma omp parallel for for (int i = 0; i < N; i++) { #pragma omp atomic sum += a[i]; } // 修改 2(推荐):reduction 让每个线程先累加私有副本 #pragma omp parallel for reduction(+:sum) for (int i = 0; i < N; i++) sum += a[i];atomic 的缺点是每次累加都有同步开销,线程多时反而慢。reduction 是编译器为每个线程生成私有变量,线程结束时归约,既正确又高效。我的习惯是优先 reduction,只有在 reduction 无法表达的操作才考虑 critical 区。
5.2 伪共享:现象、原因与解决
现象:线程数增加,性能不升反降,或者加速比卡在 1.5 以内上不去。这类问题没有报错,只能靠性能数据定位。
原因:不同的线程在写数组里相邻的元素,这些元素恰好落在同一个 64 字节 cache line 里。硬件缓存一致性协议会让这条缓存行在各核之间反复失效,表现是大量 cache-misses。
解决方法是让每个线程独立占满一个 cache line。常见做法是结构体填充:
// 每个元素 64 字节,占满一条 cache line struct padded_counter { int value; char padding[60]; }; struct padded_counter counters[8]; #pragma omp parallel for for (int t = 0; t < 8; t++) { counters[t].value += 1; // 每个线程只写自己的元素 }int 占 4 字节,加上 60 字节填充正好 64 字节。也可以用__attribute__((aligned(64)))让变量对齐到 cache line 边界,但填充写法更直观。cache line 大小用getconf LEVEL1_DCACHE_LINESIZE查,常见值是 64,不确定就按 64 填。伪共享是玄学里最折磨人的一种,因为它不报错,唯一的观测手段就是看 cache-misses 计数器的增幅。
5.3 性能分析器怎么读:热点函数与调度开销
现象:加了很多并行区,整体加速比不到 1.5。原因通常不在并行循环本身,而在串行区、内存拷贝或锁等待。我拿到这类问题,第一件事是跑性能分析,别急着改代码。
perf stat -e task-clock,context-switches,cache-misses,cache-references ./sum_openmp perf record -g ./sum_openmp perf reportperf stat 里的上下文切换次数高,说明线程被频繁调度,可能是线程数超过了空闲核数,或者锁等待导致线程睡眠唤醒。cache-misses / cache-references 的比例高,说明缓存局部性差,优先查伪共享和数据布局。perf record 带 -g 参数采集调用栈,perf report 会给出热点函数占比;如果某个函数占比超过 60%,先优化它而不是继续调并行参数。
注意:perf 在容器或无权限环境可能看不到硬件计数器,这时候退回到 gprof 或者干脆在代码里手动打点计时。
5.4 三条踩坑记录
坑一:线程数越大越好。某图像处理 Demo 把线程数从 8 提到 16,耗时从 1.2 秒涨到 1.8 秒。原因是超线程的两个逻辑核共享同一个物理核的浮点单元,加上内存带宽早已饱和。解决方式是回到物理核数,并设置 OMP_PROC_BIND=close 把线程绑在核心上,别让系统乱调度。
坑二:临界区保护范围太大。某跨平台系统在并行区里加了一把锁,运行时间和串行几乎一样。原因是锁把循环外部的整理和计算也包了进去,只剩下一个线程在真正干活。解决方式是尽量缩小临界区,把不冲突的计算移到锁外面,最终合并时再同步。
坑三:MPI 收发顺序不匹配。某模拟项目X在单机上跑得好,上集群用 64 进程后偶发卡死。原因是 0 号进程按顺序发给 1 到 63,但接收方各有各的接收顺序,MPI_Send 在大消息时等待接收方就绪,两边互相等待形成死锁。解决方式是统一消息标签和收发顺序,或者改用 MPI_Bcast、MPI_Sendrecv 这类对称接口。
6. 进阶:从「跑得快」到「跑得稳」——扩展性曲线与可复现实验
6.1 用多组规模/线程数画一张扩展性曲线
并行性能只看一两个点没意义,要画扩展性曲线。固定问题规模、增加线程数,叫 strong scaling,观察效率下降速度;线程数和问题规模同步增长,叫 weak scaling,效率接近 100% 才说明扩展性良好。我常用的处理脚本:
import sys base_time = float(sys.argv[1]) threads = int(sys.argv[2]) run_time = float(sys.argv[3]) speedup = base_time / run_time eff = speedup / threads * 100 print(f"threads={threads} speedup={speedup:.2f} efficiency={eff:.0f}%")配一组循环就能出表格。strong scaling 下效率掉得快,先怀疑通信和带宽;weak scaling 下效率也上不去,先怀疑算法本身的扩展性。
6.2 两个验证手段:结果一致性校验与运行时间区间估计
性能数字稳定之前,任何结论都不算数。我会做两个验证:第一,同一份输入分别用 1 线程和 N 线程跑,对比输出结果的哈希值,不一致就说明有竞态,或者浮点归约顺序差异过大(浮点差异应在可接受量级内)。第二,每组参数跑 5 遍取中位数,不用最小值。运行时间波动大说明机器上有干扰,用任务绑定还能缓解:
for i in 1 2 3 4 5; do ./sum_openmp; done | grep time | awk '{print $2}' | sort -n | awk '{a[NR]=$1} END{print "median=" a[int((NR+1)/2)]}'6.3 我的习惯:把性能数据当实验记录留档
最后说一个我自己的教训。某次优化改了一行代码快了 30%,一周后想复现,发现自己没留编译选项、线程数和机器配置,等于白干。现在我的习惯是每次调参都把脚本、编译器选项、线程数、数据规模、运行时间存成一个文本记录,脚本本身要能从命令行重跑。性能调优是实验科学,记录不全就是瞎调。
希望帮到你。从这份 2021 年 Manning 书稿出发,把硬件账算清楚、把 OpenMP 和 MPI 的边界分清、把性能分析器用起来,并行程序的收益是可以算出来也能复现的。
本文还有配套的精品资源,点击获取