
如果你写过多线程代码大概率会遇到这种情况逻辑上完全正确的并行程序跑起来却比单线程还慢甚至多核扩展效率低到让人怀疑机器出了问题。很多人在这一层卡了很久最后发现问题不在算法本身而在两个容易被忽略的地方——内存访问冲突和线程调度。算法并行化这个话题里内存访问冲突与调度优化算是“第四类”被单列出来的技术组合前面通常要解决负载均衡、同步原语、通信方式但真正决定并行程序能跑多快的往往是这一篇要说的内容内存怎么访问、任务怎么调度。这篇文章适合正在写 OpenMP、CUDA、MPI 或者任何多线程应用的同学也适合那些面试时被问“为什么并行程序没有线性加速比”却一时答不上来的人。我尽量把原理讲明白把代码给全把坑点列清楚。你看完可以直接拿去对照自己的项目排查。1. 并行优化到底在优化什么先看内存与调度的关系很多人一想到并行优化第一反应是“减少计算时间”于是拼命压缩算法本身的复杂度。但等你真正把算法拆到多个线程或设备上跑就会发现瓶颈往往不在 CPU 计算而在内存系统和任务分发机制。可以这么说并行优化优化的是“数据流动”和“任务落地”这两件事而不是单纯压缩指令条数。1.1 内存访问冲突的五种典型形态内存访问冲突听起来像是一个术语但在实际工程里它有非常具体的表现形式我列一下最常见的五类第一是数据竞争。两个线程同时读写同一个变量至少一个是写操作没有同步保护这种是“非法冲突”会产生不确定结果。这类冲突最好查但有时候藏得很深尤其是发生在复杂的数据结构里。第二是伪共享。多个线程本来操作的是不同变量但这些变量凑巧落在同一个缓存行里。当其中任意一个线程写自己那个变量时缓存一致性协议会让整个缓存行失效其他线程的变量也跟着遭殃。看起来是各改各的实际上互相拖后腿性能可以陡降好几个数量级。第三是锁竞争。当多个线程频繁争抢同一把锁时锁的获取和释放会成为串行化瓶颈。它本质上不算“内存错误”但是在性能分析里经常表现为大量时间卡在等待锁上其实也是内存子系统中的原子指令在打架。第四是缓存抖动。进程或线程在运行过程中频繁切换 CPU 核心导致每个核心上的缓存都不断失效重载。这个问题在操作系统默认调度下非常常见尤其当线程数超过物理核心数时上下文切换会让缓存热数据全部丢失。第五是 NUMA 远端访问。在多路服务器上每个 CPU 有自己的内存控制器访问本地内存快、访问远端内存慢差距可能达到两三倍。如果线程被调度到离数据很远的核上再快的算法也白搭。1.2 调度优化为什么和内存冲突绑在一起调度优化表面上是“决定谁在什么时间跑到哪个核上”但实际上它直接决定内存访问模式。一个线程如果被调度到新的核心它之前驻留在旧核心缓存里的数据就全部失效下一次访问就得重新从内存甚至远端内存加载。调度器每犯一次这样的“错”都要付出几十到几百纳秒的代价。在高频访问的数据结构中这个代价会被无限放大。这也是为什么很多老手在优化并行程序时会先做一件事绑定线程到固定核心。把线程固定在某个核心上缓存局部性才能保持住。到了多路服务器上还要进一步考虑 NUMA 拓扑保证线程所访问的数据在自己所在 CPU 的内存上。从这个角度看内存访问冲突和调度优化是同一枚硬币的正反面。只优化调度而不解决内存访问问题任务分配得再均匀也没用只优化内存访问而不关注调度数据局部性依然会被系统随机调度破坏。两者必须一起考虑。2. 内存访问冲突的根因与检测方法这一节我会把内存访问冲突的底层机制讲清楚并且给出一个可以自己复现的实验。理解缓存一致性协议和内存模型之后你再去看那些“诡异”的性能问题基本都能猜到一半根源。2.1 缓存一致性协议MESI带来的伪共享现代 CPU 的缓存一致性大多基于 MESI 协议或其变体。每个缓存行有四种状态Modified、Exclusive、Shared、Invalid。当一个核心写入自己缓存行里的数据时如果该缓存行处于 Shared 状态它必须发送一条“失效”消息给其他核心让它们把同一缓存行标记为 Invalid。下次其他核心读取时只能重新从内存或者其他核心的缓存里要数据。这里的关键是缓存一致性维护的粒度是缓存行不是单独的变量。x86 架构下常见缓存行大小是 64 字节CUDA GPU 上更复杂。哪怕你只写了一个字节整个缓存行也会被标记为失效。于是两个线程明明操作的是互不相干的 32 位整数只要它们在同一个 64 字节块内就会因为互相写操作而不断触发缓存行失效、重载。这就是伪共享的来龙去脉。它叫“伪”共享是因为从内存共享的角度看两个变量没有共享关系从缓存一致性角度看它们又确确实实被“捆绑”在了一起。要想消除它一般用字节对齐或填充padding把不同线程要用的变量分到不同缓存行。这在 C17 里有std::hardware_destructive_interference_size可以帮忙但更直接的是alignas(64)。2.2 用一段代码实际感受伪共享理论讲再多不如跑一次。下面这段代码会创建两个原子变量两个线程分别对它们累加一百万次。第一种情况两个变量紧紧挨着第二种情况给变量加上 64 字节对齐。#include atomic #include thread #include chrono #include iostream constexpr int N 10000000; struct Aligned { alignas(64) std::atomicint a; alignas(64) std::atomicint b; }; struct Packed { std::atomicint a; std::atomicint b; }; template typename T void run(const char* name) { T data; data.a.store(0); data.b.store(0); auto start std::chrono::high_resolution_clock::now(); std::thread t1([] { for (int i 0; i N; i) data.a.fetch_add(1, std::memory_order_relaxed); }); std::thread t2([] { for (int i 0; i N; i) data.b.fetch_add(1, std::memory_order_relaxed); }); t1.join(); t2.join(); auto end std::chrono::high_resolution_clock::now(); double ms std::chrono::durationdouble, std::milli(end - start).count(); std::cout name : ms ms\n; } int main() { runPacked(packed no padding); runAligned(aligned 64); return 0; }在普通 x86 机器上你会看到“packed no padding”明显慢很多差距可能从几倍到几十倍不等。原因就是两个变量位于同一个缓存行每次fetch_add都会导致两个线程之间互相等待缓存行同步。虽然用了memory_order_relaxed避免了不必要的顺序约束但缓存一致性协议的开销依然躲不掉。这也给你一个判断伪共享的简单方法构造一个只有两个原子变量累加的微基准对比对齐前后的耗时。如果差距巨大说明问题多半是伪共享。在生产代码里不一定要把所有变量都单独对齐到 64 字节那会浪费内存但要对“频繁写的、由不同线程访问的变量”做隔离。2.3 数据竞争、原子变量与内存序数据竞争和伪共享最大的区别在于伪共享是性能问题数据竞争是正确性问题。数据竞争意味着至少一个线程在写别的线程在同一时刻可能读且没有任何同步机制。它最可怕的地方是“一会儿对一会儿不对”因为编译器、CPU 都可能重排指令导致每次运行结果不一样。处理数据竞争的正规方式是用互斥量或原子变量。但原子变量也不是随便用的内存序选错同样会出问题。C 提供了四种内存序relaxed、consume、acquire、release以及acq_rel和seq_cst。很多人图省事一律用默认的seq_cst这在功能上没问题但性能上会付出额外代价因为顺序一致性要求在所有线程之间建立一个全局顺序往往需要更强的内存屏障。我的建议是能用relaxed的地方不要用seq_cst比如计数器累加这种只需要原子性的场景memory_order_relaxed完全够用。如果是一写一读的发布-获取模式用release写、acquire读。只有当你在实现复杂的无锁数据结构、需要完整的顺序一致性语义时才用seq_cst。这不是在教你偷懒而是让你明白内存序本身就是缓存一致性协议上的一个“调度信号”越强的信号意味着越多的等待。3. 调度优化实操从静态绑定到动态负载均衡内存访问冲突解决了大半之后接下来就是调度。调度优化要考虑的核心问题有两个怎么减少线程切换导致的热数据丢失怎么在不牺牲局部性的前提下实现负载均衡。我先从最常见的 OpenMP 调度模型说起再讲到线程亲和性和工作窃取。3.1 OpenMP 调度策略与 chunk size 到底怎么选OpenMP 的schedule子句有四种策略static、dynamic、guided、auto。每种策略都会影响循环迭代怎么分配给线程。static是最简单的方式编译时就把迭代均匀切成连续块每个线程固定拿一块。它的好处是零调度开销而且局部性非常好因为每个线程处理的迭代在内存地址上通常是连续的。缺点是如果每次迭代的计算量不均匀就会出现某些线程早做完、某些线程还在忙的情况整体吃不满 CPU。dynamic是运行时动态领取每个线程做完一块再去拿下一块。它天然适合计算量不均匀的场景但代价是每次领取任务都要访问共享的调度队列存在锁竞争和原子操作开销。如果块切得太小调度开销甚至可能超过计算收益切得太大负载均衡效果又被削弱。guided是 dynamic 的一个变种开始的时候给线程较大的块后续块逐渐减小默认最小块大小是 1。这种策略既减少了调度次数又能逐步平衡负载在很多场景下是兼顾两者的好选择。chunk size 的选择我一般遵循几个原则计算量均匀、数据量大的循环优先用static让块大小大于缓存行能覆盖的连续数据范围计算量不均匀、线程数多优先用guided只有当static和guided都表现不佳时才试dynamic。块大小不要太小至少保证每个线程拿到的块能覆盖几十个连续元素否则内存访问会变得很碎。我见过一个案例有个图像处理算法每个像素的滤波计算量取决于边缘判断非常不均匀。最初用schedule(static)四核加速比只有 2.1换成schedule(guided, 16)之后加速比直接到了 3.4。只改一行性能差这么多就是因为静态分块让两个线程几乎空等。3.2 线程亲和性与 NUMA 感知调度OpenMP 的调度策略解决的是循环迭代分配问题但线程到底跑在哪个 CPU 核心上还得看操作系统。默认情况下系统会尽量把线程调度到“当前空闲”的核心上这个策略对普通多任务还行对并行程序却是灾难。线程频繁迁移缓存反复失效性能掉得厉害。解决方法是设置 CPU 亲和性。最简单的做法是用taskset命令启动程序taskset -c 0-7 ./my_app这会把进程绑定到 0 到 7 号核心上。但进程亲和性只到进程级线程级还需要在代码里进一步绑定。OpenMP 可以用OMP_PROC_BINDtrue环境变量或者在代码中调用sched_setaffinity。为了细粒度控制我习惯在程序启动时用如下方式绑定当前线程到指定核心#include sched.h #include iostream void pin_to_core(int core_id) { cpu_set_t set; CPU_ZERO(set); CPU_SET(core_id, set); if (sched_setaffinity(0, sizeof(set), set) -1) { std::cerr pin failed\n; } }在 NUMA 架构下光绑定核心还不够还要考虑内存分配位置。numactl工具可以控制内存分配策略比如numactl --interleaveall ./my_app表示内存页在所有 NUMA 节点间交替分配适合多线程均匀访问全局数据的场景。但更精细的做法是在代码里用libnuma的numa_alloc_onnode把数据分配到线程所在节点的内存上这需要仔细分析每个线程主要访问的数据区域。我的经验是先跑一遍lstopo看拓扑再按照“核心、内存、数据”三者的距离关系绑线程。通常一个线程要访问的数据应该优先放在离它最近的内存节点。如果不这么做程序性能可能连本地访问的一半都不到尤其在多路服务器上。3.3 工作窃取兼顾负载和局部性的调度思路OpenMP 的调度策略适合循环并行但很多算法不是简单循环而是树形搜索、递归分治、任务依赖图这类动态任务。这时候需要一个更灵活的调度器工作窃取是常见选择。工作窃取调度器本质是一个共享任务队列的分布式版本每个工作线程维护自己的一个双端队列deque把新任务放到队首自己执行时也从队首取任务。如果某个线程的队列空了它会从其他线程的队尾“偷”任务。为什么从队尾偷因为队首的任务是最新生成的大概率还在该线程的缓存里自己执行局部性最好队尾的任务是最早入队的可能已经很久没人碰被偷走对其他线程缓存影响最小。这个设计把“负载均衡”和“缓存局部性”做了非常巧妙的折中。实现工作窃取时队首只允许本线程操作队尾可能被其他线程并发访问因此队尾操作需要加锁或用无锁队列。C 里可以用std::dequeTask加mutex简单场景没问题追求性能可以看 Intel TBB 的task_arena和task_group或者自己实现 Chase-Lev 无锁双端队列。一个常见的取舍是别让任务切得太碎。任务粒度小了窃取和同步次数多开销占比大任务粒度大了负载均衡差。我自己的经验是单个任务的最小执行时间尽量控制在 10 到 100 微秒级别这样一个线程在等待另一个线程完成时不会因为任务太碎而频繁窃取。4. GPU 与多机场景调度优化要换个维度CPU 上的缓存行为和线程调度模型到了 GPU 和多机环境下又不一样。GPU 有上千个线程同时运行内存访问模式对性能的影响比 CPU 更直接多机环境下网络通信又成了新的瓶颈。所以优化的重心要跟着硬件换一换。4.1 CUDA 共享内存与 bank conflict在 CUDA 里每个线程块有一块共享内存速度比全局内存快很多。但共享内存不是无限带宽的它被分成 32 个 bank每个 bank 每个时钟周期可以服务一次访问。当多个线程同时访问同一个 bank 但不同地址时硬件会把它们串行化这就是 bank conflict。举个例子如果一个 warp 里的 32 个线程访问共享内存时地址恰好是threadIdx.x * 2那么奇偶线程会落到不同的 bank 上一半的 bank 被跳过另一半 bank 被多次请求产生一次 2-way conflict性能减半。更糟的是如果所有线程访问同一个地址那叫做 broadcast硬件有优化不算冲突但如果访问的是同一 bank 的不同地址那就只能串行服务。解决 bank conflict 常见方法是在数组索引上加上一个适当的偏移比如把shared[N * 32]改成shared[N * 33]让相邻行错开 bank。这样虽然浪费了一点点共享内存但能避免同一列的元素落在同一 bank 里。这个过程叫 padding原理和 CPU 缓存行填充很像只是粒度从 64 字节变成了 4 字节的 bank。写 CUDA 核函数时我会先检查内存访问有没有 “stride” 模式。如果访存索引是threadIdx.x * k且 k 是偶数就有很大概率产生 bank conflict。把 k 改成奇数往往立竿见影。4.2 访存合并与数据布局的取舍GPU 的全局内存带宽很高但它最喜欢的访问模式是“合并访问”一个 warp 里的 32 个线程访问的地址尽量落在连续的内存段里。如果每个线程访问的地址间隔很大比如按列访问一个二维数组那么一次内存事务只用到了一小部分数据带宽利用率会低得吓人。这就是为什么 GPU 编程里经常强调用结构体数组Array of StructuresAoS还是数组结构体Structure of ArraysSoA。如果你的数据是三维坐标(x, y, z)AoS 布局是连续存放x0,y0,z0,x1,y1,z1...每个线程要处理一个点那访问x分量时地址间隔是 3 个 float无法合并。改成 SoA也就是先放所有x再放所有y最后放所有z线程访问x时就是一个连续的 float 数组合并访问效率极高。在 CPU 上 AoS 可能更好因为缓存行可以装下一个完整点的数据但在 GPU 上SoA 往往是首选。这再次说明调度优化和内存访问是分不开的。数据布局不对后面所有调度策略都白搭。4.3 多机并行中计算与通信重叠多机并行MPI 或分布式框架里的调度优化核心是“通信与计算重叠”。数据在节点间传输时CPU 和网络都空闲着没有计算这就是一种浪费。常见思路是用异步通信接口比如MPI_Isend/MPI_Irecv发送数据之后立刻继续计算等到需要接收数据时再MPI_Wait。但这不只是 API 用法问题还需要你在设计阶段就安排好计算顺序。比如在分块矩阵乘法里先把当前块发送给邻居然后立即计算不依赖邻居数据的部分最后再处理接收来的数据。这个调度逻辑可以用一个流水线表达通信、计算、再通信如此循环。多机场景下还有一个容易被忽视的点数据传输的粒度。如果把一个很大的数组拆成成千上万个小消息发送网络协议开销会让你痛不欲生。最好是提前把数据打包成大缓冲区用一次大消息传递代替多次小消息传递。这和 OpenMP 里 chunk size 的选择逻辑一模一样本质都是减少同步开销、增大有效计算比例。5. 常见问题速查与实战排查技巧无论你用什么框架总会碰到一些“症状明显但原因藏在深处”的问题。我把这些年遇到的问题整理成了一个速查表方便你按图索骥。5.1 症状到原因的快速映射表症状可能原因首选排查手段多线程比单线程慢锁竞争、伪共享、线程切换用 perf 看 cache miss 和锁等待时间加速比上不去且波动大线程绑定丢失、OS 随机调度检查进程亲和性尝试 taskset / OMP_PROC_BIND程序结果偶发不一致数据竞争用 ThreadSanitizer 或 helgrind 定位CPU 利用率高但吞吐低内存带宽饱和或访存未合并检查内存访问模式、数据布局多路服务器性能不升反降NUMA 远端访问用 numastat 检查内存分布numactl 绑定GPU 核函数慢但占用率高bank conflict 或访存不合并用 CUDA profiler 查看访存指标这张表不算完整但它覆盖了我遇到的大部分情况。真排查的时候不要直接凭直觉改代码先用工具定位。5.2 我自己常用的排查流程和工具第一步用系统工具确认是不是 CPU 层面问题。perf stat可以看任务切换次数、缓存未命中率、锁等待。如果context-switches数量很高说明线程调度太频繁如果cache-misses率非常高说明内存访问模式出了问题。第二步如果是 C 多线程程序我会用 ThreadSanitizer 快速抓数据竞争。编译时加上-fsanitizethread运行后它会精确报告哪两个线程访问了哪个变量、在哪行代码。这个工具对定位“偶发 bug”特别有效但要注意它会使程序变慢很多所以只用于 debug。第三步如果怀疑锁竞争用perf lock或者直接看程序热点。如果热点集中在pthread_mutex_lock或原子操作指令上那就要考虑减小锁粒度或者换成无锁结构。第四步如果是 GPU 程序用nvprof或后来推出的Nsight Compute看shared memory的 bank conflict 次数和全局内存的合并程度。它会给出一个量化指标比如实际请求周期数你可以据此判断瓶颈。排查的过程有时候很枯燥但一定要记录每次修改前后的性能数据。没有基线你后面判断改动是否有效全靠猜。5.3 几个反直觉的坑与应对第一个坑加锁后程序反而变快了。很多人不理解为什么加了同步反而更快其实原因很简单不加锁时数据竞争导致大量无效的缓存失效和回写加了锁反而把这些失效集中到一起减少了冲突开销。这说明你原来的问题不是“缺锁”而是伪共享或内存布局太差。第二个坑增大线程数后性能下降。这通常是所有线程都在争抢同一片内存带宽或者锁竞争进一步加剧。我曾经遇到一个矩阵乘法线程从 8 增加到 16性能反而差了 15%后来发现是数据没有分块访存全部穿透到主内存CPU 加得再多也没用。第三个坑schedule(dynamic)并不是“动态平衡”的万能药。线程数多、任务数少、chunk size 又小的时候光调度开销就能吃掉所有收益。甚至在某些场景下dynamic比static慢几倍都是正常的。所以不要一上来就选动态调度先静态跑一遍拿数据再说。第四个坑在 NUMA 机器上numactl --interleaveall适合某些场景但不适合所有场景。如果你的程序每个线程只访问自己的私有数据interleave 反而会把数据均匀打散到所有节点让每次访问都可能是远端访问。这时候应该让每个线程的数据在本地分配而不是全局交错。我个人的习惯是遇到并行性能问题第一件事不是改代码而是先画一张数据和作业的分布图标出哪些线程要读写哪些数据再检查它们被放到哪个核心上最后再决定改调度方式还是改内存布局。顺序错了可能会在错误的方向上浪费一整天。实际调优过程中我还会用一个小脚本周期性输出当前线程运行在哪个核心上结合watch -n 0.1查看迁移情况。线程如果经常在不同核心之间跳来跳去那说白了就是调度器在跟你的优化作对。这时候用亲和性绑定是最快的解法别犹豫。说到底算法并行化这件事越到后面越不是“把任务分给多个人”那么天真。内存访问冲突和调度优化是一体的缓存行、内存序、线程绑定、任务队列、数据布局这些东西共同决定了一个并行程序是真快还是假快。如果这篇文章能让你少踩几个我已经踩过的坑那就不算白折腾。