通过软件压榨硬件的性能:从 CPU 缓存到 GPU 的分层优化地图

写在前面

《每个程序员都该知道的延迟数字》回答的是"各种操作有多慢";这篇回答的是下一个问题——知道了谁快谁慢之后,软件怎么把硬件真正跑满

先给全文的总纲:现代硬件的峰值性能,和未经针对性优化的软件能拿到的性能之间,经常差着数倍;对特定热路径,差出一个数量级也不罕见。主流服务器 CPU 每周期能退休多条指令,但内存、分支或同步受限的代码,IPC(每周期指令数)可能长期低于硬件上限;一张 GPU 的低精度理论算力以数百至数千 TFLOPS 计,而 PaLM 论文披露的 540B 训练 MFU 为 46.2%。执行单元并非一直在做有效计算——它们会等数据、等依赖、等锁、等内核。所谓"压榨",就是缩小这段差距。

这篇按硬件分层展开一张优化地图:CPU(缓存、分支、SIMD)→ 内存(带宽、分配、大页、NUMA)→ 并发(锁、批处理、异步)→ I/O 与网络(io_uring、零拷贝、内核旁路)→ GPU 压轴(SIMT、tiling、FlashAttention、PagedAttention)→ 编译器与运行时 → 方法论。每一层的手段看着五花八门,归纳起来只有四条元策略,第一节先立起这个框架。

前置知识:读过《每个程序员都该知道的延迟数字》最好,没读过也能跟上(文中会复述关键量级)。示例以 C# 和少量 C/C++、CUDA 为主——机制是跨语言的,和你写什么栈无关。

一、差距从哪来:四条元策略

1.1 硬件留下的余量

把《延迟数字》那张表压缩成三行,量级关系一目了然:

层级典型量级相对寄存器
L1 / L2 / L3 缓存~1 ns / ~4 ns / ~10–30 ns快,但要"命中"
主存(DRAM)~100 ns慢约两个数量级
SSD / 网络 / 磁盘~100 µs 起步又慢三四个数量级

(《延迟数字》篇交代过:这些是建立直觉用的量级参考,不是某台机器的规格表。)

简单算术指令的执行延迟通常只有若干周期,但只要操作数落到主存,相关依赖链就可能等待上百纳秒(具体周期数随频率和平台而变)。硬件设计者当然知道,所以他们在 CPU 里堆了三样东西替你兜底:缓存(把热数据留在近处)、乱序执行(等待数据时先跑无依赖的指令——当代高性能核心的重排序缓冲可容纳数百条微操作,分配、分发或退休宽度可达 6–8 条;例如 Intel Golden Cove 为 6-wide allocation、8-wide retirement,AMD Zen 5 为 8-wide dispatch/rename/retire,具体术语与宽度随微架构而异)、预取与预测(猜测接下来要用的数据和控制流,提前准备)。这三样兜底机制的利用率,会显著影响你离峰值有多远:

  • 顺序扫一个数组,硬件预取器更容易猜中、缓存行利用率也更高,IPC 通常明显优于随机指针追逐;
  • 随机跳着访问链表、分支不可预测、数据跨缓存行伪共享,IPC 掉到 1 以下——大部分晶体管在空转

GPU 更强调吞吐:它用较轻量的执行单元和大量驻留线程,由硬件 warp 调度器在就绪 warp 之间切换来隐藏延迟;软件则负责暴露足够并行度、选择数据布局与分块方式(详见第六节)。因此,kernel 的映射与访存方式不同,性能可能相差数倍乃至一个数量级。

1.2 四条元策略

一切压榨手段,本质都是下面四条的组合。后面每一节都会标注当前手段属于哪条:

#元策略一句话代表技术
1局部性让快的层更忙缓存友好布局、预取、tiling
2并行度让所有执行单元同时转多核、SIMD、SIMT、流水线重叠
3批处理把固定开销摊到 N 次操作上网络批量、聚合写、io_uring 批提交
4绕过能少搬一层是一层零拷贝、内核旁路、kernel fusion

1.3 统一的透镜:Roofline

判断"还能榨多少、往哪个方向榨",有一个经典模型:Roofline(屋顶线,Williams/Waterman/Patterson 2009,见参考资料)。把每个工作负载画到一张图上:横轴是运算强度(原论文称 operational intensity,即每字节 DRAM 流量对应多少次运算),纵轴是可达到的浮点性能(FLOP/s)。屋顶由两条线构成:平台的峰值算力(水平的屋檐)和内存带宽(倾斜的屋脊)。强度低的工作负载撞上的是"带宽墙"——再快的算力也没用,瓶颈在搬运;强度高的才撞"算力墙"。

一个直接推论贯穿全文:当工作负载的运算强度低于平台的 ridge point 时,先撞上的是带宽墙。这类负载的优化主旋律通常不是让算术指令更快,而是让数据少动、成批动、就近动;运算强度足够高时,瓶颈才会转向计算屋顶。

二、CPU:把每个时钟周期喂饱

2.1 局部性:缓存友好的代码长什么样(元策略 1)

缓存以缓存行为单位搬运:主流 x86 服务器与许多 ARM 服务器核心使用 64 字节缓存行,具体大小仍以目标处理器文档或实测为准。以 64 字节缓存行为例,你读 1 个字节,硬件会把它所在的整行带入缓存;接着读相邻的第 2 个字节,通常已经命中。这就是为什么同样逻辑的代码,性能可以差一个数量级:

  • 数组 vs 链表:顺序访问数组,预取器提前搬下一行,接近内存带宽上限;链表节点散落堆中,每次跳转都可能一次缓存未命中(~100ns)。List<T> 碾压 LinkedList<T> 的根因不在算法复杂度,在缓存(《延迟数字》篇讲过这里由缓存决定实际快慢);
  • 布局即性能:把一起用的字段放一起(AoS),或者反过来——各字段分别成列、按列处理(SoA),让每次搬进来的 64 字节全是有效数据。游戏引擎和数据库列存的存储布局都在做这道题;
  • 别扰乱预取器:硬件预取依赖访问模式的规律性。倒序遍历数组几乎和正序一样快(预取器能学会简单逆序),但随机下标跳访就把预取废掉了。

.NET 侧对应一整套 API:连续内存用 T[] / Span<T>(深入用法见《深入 .NET Memory<T>》),栈上小缓冲用 stackalloc,避免把热数据装箱打散到堆各处。

边界:数据集小到整个装进 L1/L2 时,布局收益急剧缩小;对指针追逐型结构(如图、哈希表桶链)强行 SoA 化会增加间接层,得实测。

2.2 分支:和预测器对赌(元策略 1/2)

流水线 CPU 取指时并不知道分支往哪走,只能赌——SPEC 类基准上现代预测器被 Intel 的论文形容为"近乎完美"(near-perfect accuracy),预测命中通常没有流水线清空惩罚;预测失败则会冲刷流水线,典型代价 10–20 个周期,并可能让错误路径上的工作白费。而数据中心真实负载没有这么配合:MICRO 2024 的一篇实测在 Intel 服务器 CPU 上测得平均 9.2% 的执行周期浪费在分支预测失败上(3.6%–20%,随负载浮动)。分支密集且不可预测的代码(如对随机数据反复做 if (x > pivot) 之类的划分),会被预测失败吃掉大块吞吐。

应对两条路:

  • 让分支可预测:数据有序时同一分支连续大量同向。先排序再处理,分支命中率上升——这个技巧在特定基准里快过不排序,但排序本身有成本,只在重复扫描或分布有利时才划算(《延迟数字》篇也提醒过别当万能药);
  • 消除分支(branchless):用条件传送(x86 的 cmov)、位运算、查表替代数据依赖的跳转。Math.Max 这类简单比较,编译器常已自动生成条件传送;手写 branchless 逻辑(如用掩码归一化 sign 位)在关键路径上有效。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 数据依赖的分支:每个元素都要预测一次
static int SumIf(ReadOnlySpan<int> data, int threshold)
{
    int sum = 0;
    foreach (var x in data)
        if (x > threshold) sum += x;   // data 随机时预测器连败
    return sum;
}

// branchless 思路:把"条件累加"改写成"选择累加",没有可预测错的跳转。
// 三元选择形态在 RyuJIT 下可能编译为条件传送(cmov),收益以微基准为准。
static int SumMask(ReadOnlySpan<int> data, int threshold)
{
    int sum = 0;
    foreach (var x in data)
        sum += x > threshold ? x : 0;
    return sum;
}

边界:高度可预测的分支惩罚通常很低,别为消除而消除;branchless 引入的依赖链(每次计算都依赖上一次)有时反而限制指令级并行,结论以微基准为准。

2.3 SIMD:一条指令顶 N 条(元策略 2)

CPU 里最后一档"并行度"藏在你没写的代码里:SIMD(单指令多数据)。一条向量指令同时对一整排数据做同样的运算,寄存器宽度一路演进——SSE 128bit、AVX/AVX2 256bit、AVX-512 512bit。同频同核下,512bit 宽度的理论吞吐是标量处理的 16 倍(以 32 位浮点计)。

现实中这个红利的代表是 simdjson:用 SIMD 逐 64 字节块扫描 JSON 结构,论文标题就叫《Parsing Gigabytes of JSON per Second》——摘要的声明是"首个在单核商用处理器上每秒解析 GB 级数据、符合标准的 JSON 解析器",实测在 Skylake 3.4GHz 单核上解析 2–3 GB/s,比同为高性能实现的 RapidJSON 快约 4 倍(指令数不超过其 1/4)。

C# 侧不用手写 Intrinsics 也能吃到大部分收益:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Numerics;

// Vector<T> 自动映射到本机最宽的向量寄存器(AVX2→256bit,AVX-512→512bit)
static int SumVectors(ReadOnlySpan<int> data)
{
    if (!Vector.IsHardwareAccelerated)          // 无硬件加速的回退路径
    {
        int fallback = 0;
        foreach (var v in data) fallback += v;
        return fallback;
    }

    var acc = Vector<int>.Zero;
    int i = 0;
    for (; i <= data.Length - Vector<int>.Count; i += Vector<int>.Count)
        acc += new Vector<int>(data.Slice(i, Vector<int>.Count));

    int sum = 0;
    for (int j = 0; j < Vector<int>.Count; j++) // 把向量各 lane 折叠求和
        sum += acc[j];
    for (; i < data.Length; i++)                // 尾部不足一个向量的部分按标量收尾
        sum += data[i];
    return sum;
}

需要更细粒度控制时,System.Runtime.Intrinsics 提供 Vector128 / Vector256 / Vector512 与具体指令集(.NET 8 起支持 AVX-512)。

向量化的前提同样值得记住,因为它解释了为什么编译器不一定能自动完成:循环体通常要没有难以掩码化的分支,访问模式要便于合并,跨迭代依赖(除编译器能识别的归约等模式外)不能阻止并行执行。这些条件把许多业务代码挡在门外——所以 SIMD 往往需要在热路径上有意识地设计和验证。

2.4 伪共享:看不见的缓存行战争(元策略 1,并发篇预告)

两个逻辑上毫无关系的变量落在同一缓存行里,两个核各写各的,硬件一致性协议却按整行同步——缓存行在核间来回失效乒乓,每次往返约百纳秒量级。Stephen Toub 在《Performance Improvements in .NET 9》里给过实测:两个被错误共享的计数器,缓存未命中约 3 倍于隔离后。修法是填充对齐,把并发的热变量隔到不同缓存行。

这一条与《线程安全的本质》第七节同源:那边从正确性讲缓存一致性,这边从性能讲它。判断口诀:跨核写得越频繁的变量,越要彼此隔远

2.5 怎么知道自己没喂饱

CPU 层的体检指标就两个:

1
perf stat -e cycles,instructions,cache-misses,branch-misses ./your-program
  • IPCinstructions / cycles):长期偏低说明流水线没有持续退休足够多的指令,原因可能是缓存未命中、分支失败、数据依赖、前端供给不足或同步等待;它不是单一瓶颈的证明。还要先确认程序不在等待外部事件,否则低 IPC 可能只是说明它在睡觉;
  • cache-misses / branch-misses:定位到"等什么"。配合 perf record 火焰图(第八节)找到具体代码行。

三、内存:数据搬运的成本会计

3.1 带宽是稀缺资源(元策略 1/3)

延迟阶梯之外,内存还有第二个约束:带宽。桌面双通道 DDR5-5600 理论带宽约 90GB/s(DDR5-4800 约 77GB/s);服务器 12 通道 DDR5-4800(AMD EPYC Genoa 起)约 460GB/s。这些是"通道数 × 传输率 × 8 字节"的理论峰值,实测更低。单核顺序访问吃不满它,但几十个核同时跑内存密集代码,最先见顶的就是带宽——此时加核不再涨吞吐,这正是 Roofline 里"带宽墙"的具象。

对付带宽墙的手段回到元策略:少搬(压缩、更窄的数据类型——intshort 带宽直接减半)、就近搬(缓存命中不占内存带宽)、成批搬(让预取和 DRAM 行缓冲命中都受益)。

3.2 分配器:内存不是免费的(元策略 3)

通用分配器命中本地缓存的快路径可以很短,但共享元数据与 arena 仍可能产生争用——于是有了 tcmalloc 的线程本地缓存(现代实现也支持 per-CPU 缓存)与 jemalloc 的多 arena 设计:先从局部缓存或 arena 获取小对象,必要时再成批补充,把共享路径开销摊薄到多次分配(元策略 3 的教科书应用)。jemalloc 作者 2006 年的论文把降低多线程应用在多处理器系统上的锁争用列为设计目标之一。

.NET 的小对象分配快路径通常是线程分配上下文中的指针递增(bump allocation),但清零、写屏障以及之后的 GC 都是成本;它不是“免费”。.NET Core 3.0 还缩小了最小第 0 代分配预算,使其更贴近现代处理器的缓存大小与层级,从而降低默认内存占用,同时尽量不牺牲吞吐。所以 .NET 侧的“分配器优化”实际是减分配

  • 复用缓冲:ArrayPool<T>.Shared.Rent() / Return(),热路径上的临时大数组不再反复分配;
  • 避免分配:Span<T> 切片代替子串、string.Create / stackalloc 代替临时串和临时数组;
  • 关注 GC 模式:吞吐敏感的服务用 Server GC,容器里注意 DATAS(.NET 8 引入、.NET 9 起 Server GC 默认开启)让堆大小向应用长存数据量自适应。

这些在《.NET 性能优化与 Profiling》《深入 .NET Stream》里有完整展开,这里只点题:分配不是免费的,只是账单分期到了 GC

3.3 大页:给 TLB 减负(元策略 1/2)

虚拟地址到物理地址的翻译要查 TLB(旁路缓冲),它是缓存条目有限的小硬件表——L1 级几十条(Intel Skylake 64 条、AMD Zen 4 72 条),L2 级上千条(Skylake 1536 条)。默认 4KB 页下,这千余条 TLB 只能覆盖约 6MB 的"翻译窗口";大堆程序一跑,TLB miss 陡增,每次 miss 都要走页表多次访存。

大页(x86-64 上 2MB)把单条 TLB 的覆盖放大 512 倍——同样 1536 条二级 TLB,全用 2MB 页就能覆盖约 3GB。Linux 内核文档对 THP 收益的定性很明确:主要收益是长期性的(TLB miss 处理更快、单条目映射区间大 512 倍),“每 2MB 才缺页一次"只是首次访问时的一次性收益。数据库、大缓存、JIT 代码区是典型受益者。

边界:THP 曾因后台整理内存引入延迟尖峰和内存浪费被部分生产环境禁用——Redis 官方排障文档就把"延迟敏感实例关闭透明大页"列为建议项;用前按自己的延迟预算实测。

3.4 NUMA:局部性扩展到主板(元策略 1)

多路服务器上每个 CPU 插槽有自己的内存控制器,本地内存通常快于远端内存——差距依处理器拓扑和访问模式而变;例如文末所引 Haswell-EP 研究报告了约 1.5 倍的跨插槽内存延迟,其他平台可能接近 2 倍。Linux 默认 first-touch 策略:页通常分配到首次触碰它的线程所在节点。多线程程序若在节点 0 上初始化、主要在节点 1 上消费,就可能默默付出远端访问成本。

对策:线程与数据同节点。numactl --cpunodebind=0 --membind=0 把进程钉在单节点;数据库、消息队列这类大内存服务通常直接按 NUMA 节点切分实例。虚拟化/容器环境里要注意 CPU 拓扑可见性(K8s 的 CPU Manager / NUMA 拓扑策略管这个)。单插槽机器没有这个问题——但它也提醒你:“内存"不是一种均匀的资源

四、并发:让所有核同时转

4.1 锁的真实价格(元策略 1/2)

未争用的 lock 加解锁只要几十纳秒(《延迟数字》表里那个 25ns 就是它)。但争用发生时,账单换了一副面孔:

  • 缓存行乒乓:锁字段所在的缓存行在核间迁移。未争用的原子读改写约 20 个周期,一旦另一个核碰同一位置就涨到约 120 个周期;跨插槽的缓存行通信则是同插槽的 2–7.5 倍(SOSP'13 多路服务器实测)——慢的从来不是原子指令,是缓存行所有权的跨核迁移;
  • 上下文切换:抢不到就挂起,直接开销微秒级(现代机器实测 1–2µs),间接开销是被污染的缓存要重新热起来——工作集一旦超出缓存,实测总成本可达直接成本的十倍以上。

高争用下,加核不涨反跌——线程都在锁上排队,核越多乒乓越凶,吞吐可能低于单线程串行。识别信号:perfcontext-switches 飙升、火焰图上粗壮的 futex_wait 腰带。

4.2 去争用的三段式(元策略 1/2/3)

第一段:缩小临界区。 最便宜的去争用是让临界区变短——把 I/O、序列化、计算搬出锁外,锁内只留真正的共享写。

第二段:切细锁。 一把大锁拆成分片锁:分片计数器(每核/每槽一个计数器,读时求和)、ConcurrentDictionary 的桶级锁、数据库的行锁代替表锁,都是同一思想——让不同线程摸不同的锁字段,自然不乒乓(伪共享警告:分片记得隔缓存行)。

第三段:换协议。 真正的读多写少场景可以走到无锁:CAS(比较交换)循环实现无锁 push,读端完全无锁的 RCU(Read-Copy-Update) 更是 Linux 内核读路径的支柱——在内核文档讨论的经典非抢占 RCU 实现里,rcu_read_lock()rcu_read_unlock() 可以“不做任何事”,读端同步开销因而为零;可抢占 RCU 等实现需要额外 bookkeeping,不能套用“精确为零”的结论。写者复制一份、改好、原子发布,旧版本等所有读者离开后再回收。代价是写路径复杂、内存回收有讲究,这是用正确性复杂度换读吞吐的典型交易。《深入理解程序中的锁》与《线程安全的本质》分别从 .NET 实现和内存模型两侧展开过,这里不重复。

4.3 批处理:把固定开销除以 N(元策略 3)

锁、系统调用、网络往返、页脏写回——大量开销是按次计费的。批处理是最常见的跨层摊销手段之一:

  • 数据库:把 100 条 INSERT 放进一次批量请求和同一事务提交,可把网络往返与日志刷盘的固定开销摊到多条记录上;实际摊销比例取决于数据库、日志策略和批大小;
  • 网络:Redis pipeline 把 N 次 RTT 压成 1 次(《延迟数字》篇算过这笔账);
  • 进程内:Channel<T> 消费端攒批处理,比逐条处理摊薄同步和唤醒成本。

边界:批意味着等——凑批延迟、单批变大、失败重试粒度变粗。吞吐与延迟的这条权衡线在每一层都出现(后面的 GPU 节、io_uring 节还会遇到它)。

4.4 异步:把"等待"从线程里解耦(元策略 2)

C10K 问题(1999 年 Dan Kegel 提出的著名命题:一台服务器如何同时服务一万个客户端)有个值得玩味的细节:Kegel 原文明确说硬件早已不是瓶颈——当时一千多美元就能买到 1GHz CPU + 2GB 内存 + 千兆网卡的机器,卡住的是操作系统和软件写法。这个口径正是全文主题的注脚,它的答案演进出两个层次:

  • 系统层:一个线程阻塞等一个连接的时代被 Reactor 模式终结——epoll 一个线程监视成千上万个连接,谁就绪处理谁(演进史见《epoll 的前世今生》,模式详解见《操作系统学习笔记(三):Reactor 与 Proactor 模式》);
  • 语言层async/await 把回调式控制流编译成状态机,等待真正的异步 I/O 时不必占住线程——线程数不再与在途连接数一一绑定。某些同机微基准测得 goroutine 切换约 170ns,而文末所引上下文切换研究测得直接成本约 1–2µs;这些数字受运行时、机器和基准方法影响,只用于说明用户态调度通常更轻,不能当作跨平台常数。

必须澄清一个常见误解:异步让单个操作变快了吗?没有。它提高的是并发密度(同样多的线程承载更多在途操作),本质是元策略 2——让等待中的执行单元去干别的。CPU 密集任务没有"等"可重叠,async 化毫无收益(还搭上状态机开销)。

五、I/O 与网络:把慢设备"变快”

5.1 系统调用不免费:io_uring 的答案(元策略 3)

2002 年 futex 论文把当时系统调用的直接开销概括为数百条指令;换算成时间会随处理器、内核、缓解措施和调用路径变化。系统调用还可能扰动流水线、缓存与地址翻译状态,但是否刷新 TLB 取决于架构与内核配置,不能一概而论。安全缓解会让某些路径更贵——Retbleed 研究团队在受影响 CPU 上测得缓解开销最高为 14%(AMD)和 39%(Intel),这不是所有工作负载的固定损失。传统模型里一次 I/O 一次 syscall,高频小 I/O 的成本大头可能落在内核边界而非设备。

Linux 5.1(2019,Jens Axboe)引入的 io_uring 把 syscall 也做成批处理:提交队列(SQ)和完成队列(CQ)是与内核共享的内存环,应用可把多个 I/O 请求写进 SQ,再用一次 io_uring_enter 批量提交;完成事件由内核写进 CQ,应用稍后收割。具体能减少多少 syscall 取决于批大小、轮询模式和完成收割方式;注册缓冲区与固定文件还可摊薄页固定、映射与文件描述符查找等重复工作。它对高 IOPS 存储和部分高性能网络框架都很有价值——编程模型与适用边界在《epoll 的前世今生》里专门讲过,这里只点透一件事:io_uring 的批提交体现了元策略 3

5.2 零拷贝:少搬一次是一次(元策略 4)

传统的"读文件发网络”(read + write)要走四次拷贝、四次上下文切换(详见《操作系统学习笔记(四):零拷贝技术》)。sendfile 把拷贝全部收进内核完成(man page 的说法:“因为在内核态完成拷贝,sendfile 比 read+write 组合更高效”);splice 在两个文件描述符间经管道搬运,内核只复制页指针、不复制数据本身。

最有名的受益者是 Kafka:broker 把消息文件发给消费者时走 sendfile,官方设计文档专门一节解释这个设计——传统路径"四次拷贝、两次系统调用",优化后"只剩最后一次到网卡缓冲区的拷贝",数据进页缓存恰一次、每次消费复用,官方结论是"消费速率可逼近网络连接的极限"。这加上顺序写和批量发送,构成了 Kafka 高吞吐的三大支柱(《消息队列(三):Kafka 深入》)。

边界:TLS 加密需要触碰数据时,零拷贝路径会被打断——Kafka 官方文档明确"启用 SSL 时不使用 sendfile";小消息、低吞吐场景收益有限,别为用而用。

5.3 内核旁路:C10K 之后是 C10M(元策略 4)

epoll 把"一万个连接"变可行之后,极限玩家把目标提到一千万(C10M,Robert Graham 2013 年的系统论述)。结论很残酷:瓶颈不再是唤醒哪个连接,而是内核本身——每次中断、每次协议栈处理都在烧 CPU。于是激进派选择绕过内核:

  • NAPI 与中断合并(温和版):网卡高流量时从"每包一中断"切换为轮询,把中断开销摊薄(内核自带的自动化);对延迟敏感的场景手动开中断合并;
  • DPDK(激进版):网卡驱动整个搬进用户态,进程独占 CPU 核做轮询模式收发(poll mode,官方定位"绕过内核网络栈以降低延迟、提高吞吐";也有省电用的中断模式,性能为主时不用)——没有中断、没有系统调用、没有内核协议栈。基于 DPDK 的打流工具 TRex 标称单核 10–30 Mpps,接近打满万兆小包线速(64B 帧线速 14.88 Mpps)。代价也直白:烧掉整颗核、TCP/IP 要自己带(或用配套用户态栈)、运维复杂度陡增;
  • RDMA:更进一步,在连接和内存注册等控制面准备完成后,网卡可在数据面直接读写已注册的远端内存,绕过远端 CPU 的逐请求处理;它并不意味着操作系统完全退出初始化、权限和资源管理。HPC、部分存储与 AI 训练互连(InfiniBand/RoCE)在用。

内核旁路是"拿 CPU 换延迟和确定性“的明牌交易:生态和通用性换极限性能。绝大多数业务系统不该碰它,但它的存在划出了这条路的尽头——也反过来解释了 io_uring 这类"留在内核里但把开销摊薄"的工程价值。

5.4 存储:随机换顺序,浅队列换深队列(元策略 1/3)

存储侧的压榨就两条主旋律:

  • 随机写换顺序写:HDD 的机械寻道代价很高;SSD 的随机与顺序差距则取决于块大小、读写比例、队列深度和控制器,并不总是相差一个数量级。WAL、LSM 树、Kafka 追加日志都在利用顺序追加或批量化——先把改动顺序落盘,之后再整理(数据库侧的完整机制见《数据库系列(七):日志与崩溃恢复》);
  • 把队列喂深:SSD(尤其 NVMe)是并行设备,单线程同步 I/O 每次只给它一个请求,等于让流水线只有一个工位。NVMe 规范支持最多 65,535 个 I/O 队列、每队列 64K 深度(对比 SATA/AHCI 的单队列 32 深度),Linux 3.13 起干脆把块层重写成多队列(blk-mq)来喂饱它;io_uring 批量提交 + 高队列深度才能把 IOPS 打满,fio 压测时的 iodepth 参数、数据库的异步 I/O 都在做这件事。

另外两个存储侧开关值得知道但别乱动:页缓存(page cache)让读写先落内存、内核异步写回——应用顺序写飞快,但持久性取决于 fsyncO_DIRECT 绕过页缓存直读直写设备——数据库这类自带缓存的程序用它避免双重缓存,普通应用用它反而变慢。

六、GPU:把吞吐机器喂饱(压轴)

6.1 另一种哲学:用延迟换吞吐

GPU 不是"更快的 CPU”,是另一种设计哲学。把两者并排放着看:

CPUGPU
设计目标压低单线程延迟最大化总吞吐
结构几十个超强核:乱序执行、分支预测、大缓存上万个极简核:顺序执行为主,缓存小
隐藏延迟靠硬件乱序调度(从指令窗口找无依赖工作)硬件 warp 调度(软件需提供足够多可驻留线程)
内存带宽双通道 ~百 GB/s,服务器 ~几百 GB/sHBM 堆栈,TB/s 级(H100 SXM 为 3.35TB/s)
峰值算力TFLOPS 量级Tensor Core 数百至数千 TFLOPS

GPU 的算式很简单:单个线程遇到长延迟没关系,只要还有就绪 warp,调度器就能发射别的工作。以 NVIDIA 为例,线程按 32 个一组(warp)调度执行——同一时刻发射的指令作用于 warp 中的活动线程(SIMT;Volta 起支持 Independent Thread Scheduling,但发散路径仍会降低有效并行度)。CUDA 难写的根源在于:硬件负责在就绪 warp 间调度,软件必须提供足够并行度并管理数据布局、同步和局部性。

为什么大规模 AI 训练普遍使用 GPU?矩阵乘具有规则的数据并行结构,适合 SIMT 与 Tensor Core;带宽与算力的硬件比值也摆在那里——H100 的 3.35TB/s 约为 12 通道 DDR5 服务器(约 460GB/s)的 7 倍,BF16 Tensor Core 稠密峰值约为其 FP32 CUDA Core 的 15 倍。优势幅度仍取决于数据类型、算子形状、批量和软件栈,但"规则的矩阵乘 + 海量并行"正好落在 GPU 的甜区。

但注意第一节的总纲在 GPU 上加倍成立:同一张卡,朴素实现与充分优化的实现差出数倍到十倍是常态——后文的 FlashAttention(论文在不同配置和基线下分别报告注意力模块最高约 3 倍和 7.6 倍)和 PagedAttention(吞吐 2–4 倍)都是实测口径。下面把差距的来源一个个拆掉。

6.2 合并访存:GPU 版的局部性(元策略 1)

warp 是访存的基本单位。32 个线程访问连续的 128 字节(32×4B),硬件合并成 1–4 次内存事务;各自随机访问,就是 32 次独立事务——同一段代码,仅仅因为线程-数据的映射方式不同,访存吞吐差可达一个数量级。这就是 GPU 世界的局部性:不是"数据离得近",是"同一批线程要的数据挨在一起"

线程怎么映射到数据是 CUDA 编程第一课:block/grid 维度怎么切、每线程处理连续元素还是跨步元素,直接决定合并与否。它与 CPU 端"数组优于链表"同源——都是让每次内存搬运物尽其用。

6.3 Tiling:把数据搬进片上 SRAM 复用(元策略 1/3)

GPU 内存层级同样陡峭:寄存器和每 SM 约 228KB 的共享内存(H100,可配置)延迟极低,主存 HBM 远慢于此且带宽全员共享。FlashAttention 之前,标准注意力计算把 N×N 的中间矩阵写回 HBM 再读回来——正是"算得快、搬得慢"的 Roofline 反面教材。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 概念示意:tiled 矩阵乘——把分块数据搬进 shared memory 复用,减少 HBM 流量
//N  TILE 的整数倍时成立;省略边界分支,只保留结构)
#define TILE 32

__global__ void gemm_tiled(const float* A, const float* B, float* C, int N)
{
    __shared__ float As[TILE][TILE];   // 片上 SRAM,全员共享,延迟远低于 HBM
    __shared__ float Bs[TILE][TILE];

    int row = blockIdx.y * TILE + threadIdx.y;
    int col = blockIdx.x * TILE + threadIdx.x;
    float acc = 0.0f;

    for (int t = 0; t < N / TILE; ++t) {
        As[threadIdx.y][threadIdx.x] = A[row * N + t * TILE + threadIdx.x]; // 合并访存装入
        Bs[threadIdx.y][threadIdx.x] = B[(t * TILE + threadIdx.y) * N + col];
        __syncthreads();                                   //  warp 们都装好
        for (int k = 0; k < TILE; ++k)                     // 在片上完成乘加
            acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        __syncthreads();
    }
    C[row * N + col] = acc;
}

tiled GEMM 的本质:一块数据从 HBM 搬进来,在 SRAM 里被复用 TILE 次——全局内存流量近似除以 TILE。cuBLAS/cuDNN 里的矩阵与卷积内核,内部全是这道题的变体。

FlashAttention 是这道题的代表作(Dao et al., 2022)。它抓住了问题的本质——论文里那张 A100 内存层级表:HBM 40–80GB、带宽 1.5–2.0TB/s;片上 SRAM 每份 192KB、论文估计带宽约 19TB/s。标准注意力计算 QK^T → softmax → 乘 V 若物化 N×N 中间矩阵,会反复读写 HBM;FlashAttention 用分块与重计算避免把这张大矩阵物化到 HBM,输出和必要统计量仍会写回显存。它的显存占用随序列长度线性增长(论文称相对精确注意力基线显存效率最高提升 20 倍),而且不做任何近似,数学上与标准注意力等价。论文报告的加速分两个口径:注意力模块——常见序列长度(128–2K)上比标准实现最多快约 3 倍,GPT-2(短序列)上最高 7.6 倍;端到端训练——BERT-large 提速 15%(对照 MLPerf 1.1 纪录)。次年的 FlashAttention-2 进一步重排并行与循环结构,把 A100 上注意力算力利用率从 FA1 的 25–40% 推到理论峰值的 50–73%。这些比例都绑定论文的模型、序列长度、精度、硬件与基线,不能直接外推到任意模型或 H100。

记住那句行话:“HBM 是新的磁盘”——GPU 上的优化十有八九是在优化"别去 HBM"。

6.4 Occupancy 与分支发散:并行度攥在你手里(元策略 2)

延迟隐藏靠的是"有别的 warp 可发射"。每 SM 能同时驻留的 warp 数(occupancy)受寄存器、共享内存、block/warp 上限等多项资源限制;H100 每 SM 最多有 65,536 个 32 位寄存器,每线程用得越多,通常可驻留线程越少。开发者可用 __launch_bounds__ 向编译器声明启动边界,间接影响寄存器分配与 occupancy;若要直接限制每线程寄存器数,还可使用 --maxrregcount,两者都可能以溢出到 local memory 等代价换取更多驻留 warp。

分支发散(divergence):warp 内 32 线程走了不同分支时,硬件把两边串行执行——SIMT 没有分岔的路。对策与 CPU 同源:branchless(掩码)、数据重排让同一 warp 内的数据"同质"(排序竟是为了局部性,GPU 上再次成立)。

6.5 Fusion 与图:消灭 kernel 之间的缝隙(元策略 3/4)

深度学习里大量小算子(逐元素加、归一化、激活)单个都只吃几十微秒,但每读一次输入写一次输出都要过 HBM——中间结果的搬运比计算本身贵。两条解法:

  • 算子融合(fusion):把一串小算子合成一个 kernel,中间结果留在寄存器/SRAM。PyTorch 2 的 torch.compile 和 Triton 编译器在做这件事——官方汇总:163 个开源模型 93% 可被编译,A100 上训练平均提速 43%;Triton 还带 autotune——对 tile 大小、循环展开等参数逐一实测择优,等于让编译器替你调挡位;
  • CUDA Graphs:kernel 启动开销在微秒级,几十个短 kernel 的启动序列本身可能成为开销。把整段调用序列录制成图并重复重放,可用一次 CPU 侧图启动提交多个相互依赖的操作,显著降低累计启动开销;图中的 kernel 仍是多个执行节点,收益也要摊销图创建与实例化成本。

6.6 精度阶梯:用数值余量换吞吐(元策略 2)

精度(H100 SXM,稠密峰值)TFLOPS相对 FP32
FP32~67
BF16/FP16(Tensor Core)~990~15×
FP8(Tensor Core)~1979~30×

(NVIDIA 规格表标注的是"含稀疏加速"的值——BF16 标 1,979 TFLOPS*、FP8 标 3,958 TFLOPS*;稠密值为其一半,即上表数字。引用时务必分清口径。)

Tensor Core(2017 年 Volta 架构引入)是专门为矩阵乘加设计的电路。这张表就是"拿数值余量换吞吐"的阶梯:FP32 到 BF16/FP16 约 15 倍,BF16 到 FP8 再翻一倍——但这是 H100 Tensor Core 路径上的规格,“精度每降一档峰值翻倍"不能套用到所有精度档位和所有代硬件。混合精度训练常让矩阵乘使用 BF16/FP16,并把部分权重、累加或优化器状态保留为 FP32;loss scaling 主要用于动态范围较窄、容易下溢的 FP16 路径,BF16 通常不依赖它。推理侧的 INT8/FP8 量化同样是拿数值余量换吞吐的交易——前提是模型精度评估说得清边界。

6.7 PCIe 是毒药(元策略 4)

CPU↔GPU 之间的 PCIe 5.0 x16 理论单向带宽约 63GB/s,对面是 H100 SXM 的 3.35TB/s HBM 带宽——口径上相差约 50 倍,端到端有效带宽还取决于平台和传输方式。于是 GPU 编程的第一军规:数据尽量留在卡上,非搬不可时用锁页内存(pinned memory)提高 DMA 效率、用双缓冲把“搬运”和“计算”在多流(stream)里重叠起来。多卡之间靠 NVLink 缓解:H100 第四代 NVLink 为 900GB/s 总带宽;NVIDIA 的“约 7 倍”比较采用 PCIe Gen5 x16 的 128GB/s 双向合计口径,而不是前述约 63GB/s 单向口径。即便如此,“通信与计算重叠”仍是分布式训练工程的核心命题。

6.8 系统层:vLLM 把 OS 的老思想搬进 GPU(元策略 1/4)

LLM 推理的显存大头是 KV cache:每个请求的上下文都要缓存注意力中间态,且长度不定。传统实现按"最大可能的长度"为每个请求预留连续显存——vLLM 论文实测,现有系统的 KV cache 显存里只有 20.4%–38.2% 存的是真实 token 状态,其余浪费在碎片和预留上(官方博客四舍五入为"浪费 60–80%")。

PagedAttention(vLLM, SOSP 2023)把操作系统的分页思想整个搬了进来:KV cache 切成固定大小的块(页),逻辑位置到物理块通过页表映射,按需分配、离散存储。显存浪费压到 4% 以下(论文口径"接近零浪费”——浪费只剩每个序列最后一个块的内部碎片),同样的卡能同时服务的请求数翻倍,配合 continuous batching,论文报告在同等延迟下吞吐提升 2–4 倍(对照 FasterTransformer、Orca 等当时最优系统;对照更弱的基线差距更大)。

这是全文我最喜欢的故事:虚拟内存——操作系统 1960 年代的思想——在 2023 年的 GPU 推理系统里被重新发明一遍,同等延迟下把吞吐做到原来的 2–4 倍。硬件没变,变的全是软件。

6.9 分时与切分:压榨的经济学(元策略 2)

最后一块拼图是利用率经济学:

  • MIG(Multi-Instance GPU):把一张 H100 硬件级切成最多 7 个隔离实例,各配独立显存与算力——云厂商把"整卡出租利用率低"的问题硬件化了;
  • 多卡:NVLink/NVSwitch 缝合的 8 卡 DGX 是训练标配,数据/张量/流水线三种并行组合切分模型;
  • 度量:LLM 圈用 MFU(Model FLOPs Utilization,模型算力利用率)衡量压榨程度——这个指标就是 PaLM 论文提出的:PaLM 540B 训练 MFU 46.2%,而论文对照的 GPT-3 为 21.3%、Gopher 32.5%、MT-NLG 30.2%。在 PaLM 论文列举的这些历史大模型训练中,即使最高者也只吃到一半上下的理论算力,剩下的全是软件空间。

七、编译器与运行时:让工具链替你压榨

7.1 PGO:用真实数据指导代码生成(元策略 1/3)

PGO(Profile-Guided Optimization)先用带插桩的构建跑一遍真实负载,再带着 profile 数据重新编译:编译器据此决定内联谁、基本块怎么排布(热路径贴合缓存)、分支怎么预测。有官方口径的锚点:Chrome M85 用 Clang PGO 重新构建后,页面加载中位数最高提升 10%;Meta 的 BOLT 甚至在链接之后再做一次指令布局重排,把热代码聚合到相邻地址(已用于其大规模数据中心服务)。

.NET 把这套做成了运行时服务:分层编译(.NET Core 3.0 起默认开启)先快速 JIT、对热方法再重编译,.NET 8 起 Dynamic PGO 默认开启——运行时用真实分支与类型 profile 指导优化。官方口径的收益:平均约 15%,约 4600 个基准中 23% 提升 20% 以上。

7.2 .NET 的武库清单

散在各节的 .NET 工具在此归拢:数据布局(Span<T>structstackalloc)、复用(ArrayPool<T>)、SIMD(Vector<T>System.Runtime.Intrinsics)、并发(Channel<T>、并发集合、lock 语义与伪共享对齐——见《线程安全的本质》与《深入理解程序中的锁》)、GC 模式(Server GC、.NET 9 DATAS)、发布形态(ReadyToRun 换启动速度、NativeAOT 换完全 AOT)。系统性的 Profiling 方法见《.NET 性能优化与 Profiling》——工具链再全,也得先测量再动手。

7.3 自动调优:把挡位交给编译器搜

第六节里 Triton 的 autotune 不是孤例:cuDNN 按输入形状从内核库里挑最优实现、TVM 对算子调度做搜索、数据库优化器枚举执行计划——“压榨"的参数空间大到人搜不动时,就写程序去搜。这也是元策略 3 的变体:搜索成本一次付,运行收益 N 次收。

八、方法论:先测量,再动手

8.1 你不能优化你测不了的东西

每层有每层的仪表盘:

工具看什么
CPUperf stat / perf record + 火焰图IPC、cache-misses、branch-misses、热点函数
内存带宽实测(STREAM 类)、numastat带宽饱和度、NUMA 命中率
并发火焰图、锁争用分析futex/切换腰带的宽度
I/Oiostatfio利用率、队列深度、IOPS 是否到设备上限
GPUNsight Compute 的 Speed Of Light、Nsight Systems 时间线SM/内存吞吐占持续峰值的比例、kernel 间缝隙
LLMMFU、显存利用率距理论算力的差距

系统性的方法还有两个值得记的名字:Brendan Gregg 的 USE 方法(对每个资源检查 Utilization/Saturation/Errors,用于尽早发现系统性瓶颈或错误)和他在 2011 年发布的火焰图(聚合采样调用栈;横向宽度表示样本占比而非时间轴,适合定位 CPU 热路径)。

8.2 三条定律框住所有优化

  • Amdahl 定律(Gene Amdahl,AFIPS Spring Joint Computer Conference 1967):在固定问题规模下,整体加速比受未被加速部分所限——把占比 P 的部分加速 k 倍,整体最多提升 1/((1−P) + P/k)。工程含义:优化只占 5% 时间的代码,就算无限快,整体也最多快约 5%——先优化占比最大的可改善部分(Gustafson 1988 的补充:若问题规模随处理器数扩展,加速比可以接近线性——Amdahl 的天花板建立在固定问题规模的前提上);
  • Little 定律(John D. C. Little,Operations Research 1961):在原论文给出的均值有限、过程严格平稳等条件下,系统内平均数量 = 平均到达率 × 平均停留时间(L = λW)。它是容量估算和压测判读的基础关系,但不能把瞬时采样值或尚未稳态的过载过程直接代入;
  • Roofline(2009):先判断撞的是带宽墙还是算力墙,再决定优化方向——方向错了,一切努力都是零头。

8.3 压榨的对面:让算法少干活

最后补一个反向注脚。NVIDIA 的 DLSS Super Resolution 是另一条路的标准样本:按较低分辨率渲染,再用 AI 重建更高分辨率图像,以减少原生高分辨率渲染工作量;实际帧率与画质收益随游戏、模式和硬件而变,不能笼统保证“翻倍”。2022 年公布的 DLSS 3 又加入 Optical Multi Frame Generation:它综合前后帧、Ada Optical Flow Accelerator 生成的光流以及引擎运动矢量和深度来生成中间帧。这是帧生成,不应与超分辨率混为一谈。算法复杂度的台阶(O(N²) → O(N log N))通常优先于微优化;把算法层面的浪费降下来之后,才轮到这篇讲的分层压榨。

两句收束全文:

  1. CPU 更依赖乱序窗口隐藏延迟,GPU 更依赖大量就绪 warp——越往吞吐架构走,软件越要主动暴露并行度与局部性;
  2. 硬件峰值与你的现状之间若存在明显差距,其中一部分可能来自软件。升级硬件之前,应先测量还有多少可通过布局、批处理、并行或算法改进拿回的性能。

九、小结

按层回顾这张地图:

  • 总纲:未经针对性优化的软件与硬件峰值经常存在明显差距,特定热路径可达数倍乃至一个数量级;四条元策略——局部性、并行度、批处理、绕过——贯穿所有层;
  • CPU:缓存行 64B、分支预测对赌、SIMD 宽度阶梯、伪共享;仪表是 IPC 与 cache-misses;
  • 内存:带宽是稀缺资源;分配靠池化与减分配;大页救 TLB;NUMA 把局部性扩展到主板;
  • 并发:未争用锁几十 ns,争用后乒乓与切换吃掉一切;缩临界区→切细锁→换协议三段式;批处理摊薄固定开销;异步提高并发密度但不加速单操作;
  • I/O 与网络:syscall 批量化(io_uring)、零拷贝(sendfile/Kafka)、内核旁路(DPDK/RDMA)三级火箭;存储侧随机换顺序、队列喂深;
  • GPU:SIMT 用并发隐藏延迟,硬件调度就绪 warp、软件负责暴露并行度与局部性——合并访存、tiling(FlashAttention)、occupancy、fusion、精度阶梯、数据留卡、PagedAttention(vLLM)、MIG;PaLM 论文所列历史系统的 MFU 约为 21%–46%;
  • 工具链:PGO/LTO/BOLT、.NET 分层编译与 Dynamic PGO、autotune 把挡位交给搜索;
  • 方法论:先测量(USE、火焰图、SOL、MFU),Amdahl 定瓶颈、Little 定容量、Roofline 定方向;能减少工作量级的算法改进通常优先于微优化。

三句话带走:

  1. 搬运数据比计算贵,所以压榨的主旋律是让数据少动、成批动、就近动;
  2. 固定开销按次计费,所以批处理是最通用的摊销手段之一——从数据库批量提交到 io_uring 再到 CUDA Graphs;
  3. 优化前先问三句:瓶颈在哪层(Amdahl)、撞的什么墙(Roofline)、怎么证明(测量)。

参考资料

延迟与总纲:

CPU 与内存:

并发与 I/O:

GPU:

方法论: