0.8%串行代码拖住99.2%并行:并行计算性能上限与优化路径
简介《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)相对上一行的增幅11.0—43.92.91614.310.46442.628.325684.241.61024111.527.3趋于无穷125.013.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 侧的架构与编程模型再到亲和性、批处理调度和并行文件操作十七个章节一层层铺下来每一章后面还配了练习。真要把并行计算这件事弄明白按顺序读一遍比零散地搜帖子要省太多弯路。希望帮到你。本文还有配套的精品资源点击获取