【编译器】从计算机系统原理到 LLM 编译器与算子优化
文章目录
- [【编译器】从计算机系统原理到 LLM 编译器与算子优化](#【编译器】从计算机系统原理到 LLM 编译器与算子优化)
-
- [第 1 章|计算机系统概述](#第 1 章|计算机系统概述)
-
- 本章映射总览
- [1.1 从冯诺依曼结构到软硬件接口](#1.1 从冯诺依曼结构到软硬件接口)
- [1.2 为什么说算子像面向硬件的"扩展指令"](#1.2 为什么说算子像面向硬件的“扩展指令”)
- [1.3 LLM 编译器的分层职责](#1.3 LLM 编译器的分层职责)
- [第 2 章|数据的表示和运算](#第 2 章|数据的表示和运算)
-
- 本章映射总览
- [2.1 浮点、定点与量化](#2.1 浮点、定点与量化)
- [2.2 MAC 是神经网络算子的基本单元](#2.2 MAC 是神经网络算子的基本单元)
- [2.3 用代码检查数据类型和误差](#2.3 用代码检查数据类型和误差)
- [第 3 章|程序的转换及机器级表示](#第 3 章|程序的转换及机器级表示)
-
- 本章映射总览
- [3.1 从模型图到目标指令](#3.1 从模型图到目标指令)
- [3.2 循环变换就是算子调度](#3.2 循环变换就是算子调度)
- [3.3 Triton 示例:把线程映射交给编译器](#3.3 Triton 示例:把线程映射交给编译器)
- [3.4 控制流与分支发散](#3.4 控制流与分支发散)
- [第 4 章|可执行文件生成、链接、加载](#第 4 章|可执行文件生成、链接、加载)
-
- 本章映射总览
- [4.1 从目标文件到可执行程序](#4.1 从目标文件到可执行程序)
- [4.2 算子库与自定义 kernel](#4.2 算子库与自定义 kernel)
- [4.3 权重加载就是一次运行时装载](#4.3 权重加载就是一次运行时装载)
- [4.4 版本与架构必须成套验证](#4.4 版本与架构必须成套验证)
- [第 5 章|程序的存储访问、Cache、局部性原理](#第 5 章|程序的存储访问、Cache、局部性原理)
-
- 本章映射总览
- [5.1 存储层次与数据移动](#5.1 存储层次与数据移动)
- [5.2 局部性、分块与算术强度](#5.2 局部性、分块与算术强度)
- [5.3 GPU 访存优化的四个抓手](#5.3 GPU 访存优化的四个抓手)
- [5.4 一条可执行的优化流程](#5.4 一条可执行的优化流程)
- [第 6 章|I/O、中断、系统调用](#第 6 章|I/O、中断、系统调用)
-
- 本章映射总览
- [6.1 从同步调用到异步执行](#6.1 从同步调用到异步执行)
- [6.2 一个最小的 CUDA 异步示例](#6.2 一个最小的 CUDA 异步示例)
- [6.3 分布式训练中的通信与同步](#6.3 分布式训练中的通信与同步)
- [6.4 端到端排障清单](#6.4 端到端排障清单)

很多人学习大模型编译器时,会直接从 TVM、MLIR、XLA 或 Triton 的 API 开始。这样虽然能很快写出一个例子,但遇到性能问题时,往往不知道应该检查数据类型、指令生成、显存访问,还是异步执行。
理解这些问题的一个有效方法,是把大模型编译器放回计算机系统的完整链路中:
C 源码 → 汇编 / 机器码 → 链接与加载 → 存储层次访问 → I/O 与异常
对应到大模型和算子优化,可以写成:
模型图(PyTorch / ONNX)→ 算子 IR → 硬件指令 → 调度与内存排布 → GPU / TPU 执行
本文按照计算机系统原理课程的六章顺序,说明每一章知识如何映射到 LLM 编译器。文中的"对应"主要是帮助建立直觉,GPU 的硬件实现与 CPU 并不完全相同,实际优化仍需要结合目标 GPU、编译器版本和性能分析工具验证。
分成三种关系。第 2、3、5 章主要是机制上的直接对应;第 1、4、6 章更多是系统分层和工程流程上的类比。
| 对照类型 | 主要章节 | 可以迁移的内容 | 需要保留的边界 |
|---|---|---|---|
| 机制对应 | 第 2、3、5 章 | 数据格式、循环变换、局部性和访存优化 | 目标硬件的指令与缓存细节不同 |
| 系统类比 | 第 1、4、6 章 | 分层抽象、构建加载、异步提交和完成通知 | 不代表存在完全相同的硬件或内核实现 |
| 端到端联系 | 六章串联 | 从模型表达一直追踪到执行和性能指标 | 需要用 profiler 和基准测试验证收益 |
第 1 章|计算机系统概述
本章映射总览
这一章解决的是"程序为什么必须服从硬件契约"。在 LLM 场景中,模型图只是对计算的高层描述,真正决定性能的是目标 GPU 支持什么指令、能同时运行多少线程,以及每一级存储能容纳多少数据。因此,本章的核心迁移不是把 GPU 说成一台普通 CPU,而是学会从软硬件接口的角度审视一个算子。
| 课程概念 | LLM 编译器中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| 指令集架构(ISA) | CUDA/PTX、GPU 矩阵指令、TPU 指令 | 高层计算如何落成硬件可执行语义 | GPU 指令模型和 CPU ISA 不同 |
| 处理器与并行执行 | SM、线程块、线程束、Tensor Core | 如何把任务映射到执行单元 | SIMT 不是多核 CPU 的简单复制 |
| 存储器层次 | 寄存器、Shared Memory、L2、HBM | 数据放在哪里、何时搬运 | GPU 存储层次和 CPU Cache 组织不同 |
| 软硬件接口 | 算子 IR、Runtime API、kernel ABI | 编译器与运行时如何协作 | 算子不是一条真实的"扩展指令" |
1.1 从冯诺依曼结构到软硬件接口
冯诺依曼模型把计算机抽象为处理器、存储器、输入输出设备和连接它们的总线。程序和数据都以二进制形式存储,处理器按照指令读取数据、执行运算,再把结果写回存储器。
对编译器来说,最重要的概念是指令集架构(ISA)。ISA 规定了寄存器、数据类型、寻址方式和指令语义,是软件与硬件之间的契约。编译器不能只关注"结果是否正确",还要生成满足目标硬件约束的指令序列。
GPU 采用大量并行执行单元,并以 SIMT(Single Instruction, Multiple Threads)方式组织线程。它与 CPU 共享"取指、执行、访存"的基本思想,但线程层级、存储层次和同步方式不同。因此,GPU 上的高性能算子不是简单地把 CPU 代码复制多份,而是要同时考虑线程块、线程束、寄存器、片上共享存储和全局显存。
1.2 为什么说算子像面向硬件的"扩展指令"
矩阵乘法 C = A @ B 在 Python 中只是一行表达式,执行时却需要完成维度检查、数据类型转换、分块、线程映射、访存和同步。一个算子通常封装了比单条硬件指令更高层的计算语义,可以把它理解为面向特定硬件的"扩展指令"。这是一种工程类比,不代表 GPU 中真的存在一条名为 MatMul 的通用指令。
以 NVIDIA GPU 为例,编译器可能把矩阵乘法映射到 Tensor Core 的矩阵乘累加指令,也可能调用 cuBLAS 中已经调优的 kernel。最终选择取决于矩阵形状、输入精度、GPU 架构和工作负载。
1.3 LLM 编译器的分层职责
| 层次 | 典型对象 | 主要职责 | 计算机系统中的对应物 |
|---|---|---|---|
| 模型前端 | PyTorch Graph、ONNX | 解析模型结构、推导形状和类型 | 高级语言与编译器前端 |
| 算子 IR | MLIR linalg、tensor、affine |
表达循环、张量和数据依赖 | 中间表示 |
| 低层 IR | LLVM IR、GPU IR、Triton IR | 进行指令级和地址级变换 | 汇编前的低层表示 |
| 代码生成 | PTX、SASS、SPIR-V、TPU 指令 | 生成目标硬件可以执行的代码 | 汇编器与机器码生成 |
| 运行时 | CUDA Runtime、XLA Runtime、推理引擎 | 管理内存、流、事件和 kernel 调度 | 操作系统与运行库 |
因此,LLM 编译器的任务不是单纯"把 Python 翻译成 GPU 代码",而是把模型语义逐层降低,同时保留可优化的信息,并在最后交给运行时执行。
本章到下一章的衔接:既然指令集规定了硬件能执行什么,下一步就要回答"这些指令处理什么格式的数据"。因此,软硬件接口会自然落到浮点格式、量化和乘累加精度上。
第 2 章|数据的表示和运算
本章映射总览
这一章与 LLM 编译器的联系最直接:模型参数和激活值最终都要以某种位级格式存储和计算。补码、浮点、舍入和溢出等知识,直接影响量化方案、累加精度和数值稳定性;ALU 的乘加能力,则对应 GPU Tensor Core 等专用计算单元。
| 课程概念 | LLM 编译器中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| 浮点数格式 | FP32、FP16、BF16、FP8 | 用有限位数表示范围和精度 | 各 GPU 对低精度格式的支持不同 |
| 定点与编码 | INT8、INT4 量化 | 如何压缩数据并控制误差 | 量化还需要校准、缩放和布局策略 |
| 舍入与溢出 | 低精度转换、饱和、Loss Scaling | 如何避免数值异常 | 模型误差不能只用单次运算判断 |
| ALU 与乘加 | SIMD、Tensor Core、MAC Kernel | 如何提高规律计算吞吐 | 专用单元有形状、对齐和精度限制 |
2.1 浮点、定点与量化
模型训练和推理的性能、显存占用与数据格式密切相关。常见格式可以先用下面的表格建立整体认识:
| 格式 | 典型位宽 | 特点 | 常见用途 |
|---|---|---|---|
| FP32 | 32 bit | 精度和动态范围较好,显存与带宽开销大 | 训练基准、累加和部分敏感算子 |
| FP16 | 16 bit | 比 FP32 更省显存,动态范围较小 | 混合精度训练、推理 |
| BF16 | 16 bit | 指数位与 FP32 相同,尾数较短 | 大模型训练和推理 |
| INT8 | 8 bit | 存储和计算开销低,需要处理量化误差 | 推理量化、部分 Tensor Core 路径 |
| INT4 | 4 bit | 压缩率高,精度更敏感 | 大模型权重量化 |
FP32、FP16 和 BF16 都采用符号位、指数位和尾数位表示数值。BF16 保留了 FP32 的 8 位指数,因此动态范围接近 FP32,但尾数只有 7 位,表示精度低于 FP16。也就是说,BF16 是用精度换取动态范围,适合梯度或激活值范围较大的大模型场景。
量化通常把浮点值映射到整数:
text
q = round(x / scale) + zero_point
x ≈ (q - zero_point) * scale
scale 和 zero_point 的计算方式会影响误差;按张量、按通道或按分组量化,也会影响精度和 kernel 的实现复杂度。量化并不是把所有数值简单转换成整数,还要处理舍入、饱和、溢出和累加精度。
2.2 MAC 是神经网络算子的基本单元
全连接层和卷积层都可以拆解为大量乘累加(MAC,Multiply-Accumulate)操作:
text
C[i, j] = sum(A[i, k] * B[k, j])
GPU 的 Tensor Core、CPU 的向量指令以及许多 AI 加速器,都针对这种规律计算提供了专用硬件。算子编译器要做的事情包括:识别可并行的循环、选择适合的矩阵指令、安排输入数据的布局,以及决定中间结果使用什么精度保存。
例如,输入可以使用 FP16 或 BF16,乘法在低精度单元上执行,但累加使用 FP32。这样可以减少带宽和显存占用,同时降低长链路累加带来的误差。具体策略需要由硬件和框架支持情况决定。
2.3 用代码检查数据类型和误差
在 PyTorch 中,可以先用一个小实验确认数据类型转换是否符合预期,再进行大规模压测:
python
import torch
x = torch.randn(4096, 4096, device="cuda", dtype=torch.float32)
y = torch.randn(4096, 4096, device="cuda", dtype=torch.float32)
ref = x @ y
for dtype in (torch.float16, torch.bfloat16):
out = x.to(dtype) @ y.to(dtype)
# 先转换到 FP32,再与 FP32 参考结果比较
error = (out.float() - ref).abs().mean() / ref.abs().mean()
print(dtype, float(error))
这个实验只能观察数值误差,不能代表真实模型精度。实际模型还要使用代表性数据集评估准确率、困惑度或任务指标,并同时记录吞吐、显存和延迟。
本章到下一章的衔接:数据格式决定了可用的计算指令,但还没有决定循环如何排列、线程如何分工。下一章把"数据怎么表示"继续推进到"程序怎么变成指令"。
第 3 章|程序的转换及机器级表示
本章映射总览
这一章解释"同一个算法为什么会有不同的性能"。C 代码、MLIR、LLVM IR、PTX 和 SASS 都可以表达计算,但它们暴露的硬件信息不同。LLM 编译器的 lowering,就是把模型图逐步变成更接近目标机器的表示,并在每一步进行合法的重写。
| 课程概念 | LLM 编译器中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| 高级语言 | PyTorch Graph、ONNX、算子 DSL | 表达"要算什么" | 模型图包含张量形状和算子语义 |
| 中间表示 | MLIR、LLVM IR、Triton IR | 在不改变语义的前提下优化 | 不同 IR 的类型和合法变换集合不同 |
| 指令与寻址 | PTX、SASS、地址计算 | 如何生成少而有效的指令 | GPU 还要编码线程层级和地址空间 |
| 控制流 | 条件掩码、分支、同步 | 如何处理不同执行路径 | SIMT 分支发散会影响线程束效率 |
| 编译优化 | Tile、Unroll、Fuse、Vectorize | 改变执行顺序和资源分配 | 优化收益依赖形状、硬件和运行时 |
3.1 从模型图到目标指令
模型编译通常经过多次"降低(lowering)"。每一层 IR 都应该保留适合当前优化的信息,直到最后才落到硬件指令:
| 阶段 | 示例 | 关注点 |
|---|---|---|
| 图级表示 | PyTorch FX、ONNX | 算子融合、常量折叠、形状推导 |
| 张量 / 循环 IR | MLIR linalg、affine |
循环依赖、布局、分块和向量化 |
| 通用低层 IR | LLVM IR | SSA、指针、基本块和调用约定 |
| GPU 低层表示 | Triton IR、NVVM、PTX | 线程层级、地址空间和同步 |
| 机器指令 | SASS、矩阵指令 | 寄存器分配、指令调度和硬件吞吐 |
因此,可以把 MLIR 的 linalg 或 affine 看成比 C 更适合张量优化的中间表示,把 LLVM IR 或 PTX 看成接近汇编的表示。不同项目的具体 IR 名称可能不同,但"逐层降低、逐层暴露硬件约束"的思路是一致的。
3.2 循环变换就是算子调度
下面是一个朴素矩阵乘法的伪代码:
c
for (int i = 0; i < M; ++i)
for (int j = 0; j < N; ++j)
for (int k = 0; k < K; ++k)
C[i][j] += A[i][k] * B[k][j];
直接执行时,B[k][j] 的访问可能跨越较大的地址间隔,数据复用也没有被充分利用。编译器可以进行以下变换:
- Tile 分块 :把
i、j、k划分为小块,使一块数据能够驻留在共享存储或寄存器中; - Loop interchange:调整循环顺序,改善连续访存;
- Unroll:展开短循环,减少分支和循环控制开销;
- Vectorize:把相邻标量操作合并为向量指令;
- Fuse:把连续算子合并,减少中间张量的写回和再次读取。
这些变换不改变数学结果,却会改变内存访问顺序、并行度和指令数量,所以算子调度本质上是对程序机器级表示的系统性重写。
3.3 Triton 示例:把线程映射交给编译器
Triton 允许开发者用接近 Python 的方式描述 GPU kernel,同时显式表达块大小和掩码:
python
import triton
import triton.language as tl
@triton.jit
def add_kernel(x_ptr, y_ptr, z_ptr, n: tl.constexpr):
pid = tl.program_id(0)
offsets = pid * 256 + tl.arange(0, 256)
mask = offsets < n
x = tl.load(x_ptr + offsets, mask=mask, other=0.0)
y = tl.load(y_ptr + offsets, mask=mask, other=0.0)
tl.store(z_ptr + offsets, x + y, mask=mask)
代码中的 program_id、load/store 和掩码会被编译成 GPU 的线程块、地址计算和条件访存。真实项目中还要根据张量形状调节 BLOCK_SIZE、warp 数和流水线阶段,并用基准测试确认收益。
3.4 控制流与分支发散
CPU 通常擅长执行复杂分支,GPU 则希望同一个线程束中的线程执行相同路径。如果同一线程束中的线程进入不同分支,硬件可能需要分别执行两条路径,再屏蔽不满足条件的线程,这就是分支发散。
分支发散并不意味着所有 if 都有问题。边界掩码、稀疏模式和不规则数据经常需要条件判断;关键是评估分支比例、路径长度和对吞吐的影响。可以用 Nsight Compute 查看分支效率、占用率和访存指标,而不是只凭代码行数判断性能。
本章到下一章的衔接:IR 和循环变换解决了"生成什么代码",但代码还要经过编译、链接和加载才能真正运行。下一章关注算子库、设备代码、权重和运行时资源如何被装载。
第 4 章|可执行文件生成、链接、加载
本章映射总览
这一章的对应关系主要是工程流程类比,而不是说模型权重就是 ELF 文件、推理引擎就是链接器。两者的共同点是:代码和数据在真正执行前,都要经过版本匹配、符号或入口解析、地址/资源准备和运行时装载。这个视角有助于理解为什么"模型文件能下载"不等于"服务一定能启动"。
| 课程概念 | LLM 部署中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| 目标文件 | CUDA device code、Triton 编译产物、TensorRT Engine | 保存可执行代码和元数据 | 文件格式与链接方式并不相同 |
| 符号解析 | kernel 入口、算子库 API、运行时注册 | 找到可调用的实现 | 不同框架可能通过注册表或 JIT 查找 |
| 重定位与地址绑定 | buffer 地址、参数绑定、设备上下文 | 运行前确定资源位置 | GPU 地址空间由驱动和运行时共同管理 |
| 动态链接 | 加载 cuBLAS/cuDNN、插件和运行库 | 复用外部实现 | 库版本和 ABI 兼容性会影响启动 |
| 程序加载 | 权重、Tokenizer、KV Cache、workspace 初始化 | 把代码和数据准备到可执行状态 | 权重不是普通可执行文件的代码段 |
4.1 从目标文件到可执行程序
传统 C 程序通常经历"编译 → 汇编 → 链接 → 加载"几个阶段。目标文件中包含机器码、数据、符号表和重定位信息;链接器解析函数与全局变量引用,完成地址分配;操作系统加载可执行文件后,运行时再完成动态库映射和重定位。
GPU 程序也有类似链路:主机代码由 C/C++ 编译器生成,CUDA 或其他 GPU 编译器生成设备代码,运行时加载 kernel、建立上下文并把参数传递给设备。不同工具链的文件格式和阶段名称可能不同,但"符号解析、地址绑定、目标架构匹配"仍然存在。
4.2 算子库与自定义 kernel
cuBLAS、cuDNN、CUTLASS 以及框架内置 kernel,都是经过大量调优的算子实现。调用这些库时,应用程序通过运行库找到对应的符号和设备代码,这与动态链接的思想相近。自定义 kernel 则需要在编译时指定目标架构,并由运行时加载到 GPU 上执行。
需要注意,调用"库算子"不等于一定更快,自定义 kernel 也不等于一定更灵活。选择时应比较:
| 场景 | 优先考虑 |
|---|---|
| 常见 GEMM、卷积、归一化 | 先使用 cuBLAS、cuDNN 或框架现成实现 |
| 形状固定且追求极致性能 | 构建专用 kernel 或 TensorRT-LLM Engine |
| 算子融合、特殊布局或新算子 | Triton、CUDA 或 MLIR 自定义实现 |
| 模型结构频繁变化 | 通用编译器和运行时,降低构建成本 |
排查动态库问题时,可以使用 ldd 检查主机依赖,使用框架提供的版本信息确认 CUDA 与驱动匹配;不能只根据"机器上安装过 CUDA"就判断运行环境正确。
4.3 权重加载就是一次运行时装载
模型部署可以抽象成下面的过程:
text
磁盘上的 checkpoint
→ 校验 revision、配置和 tokenizer
→ 映射或读取主机内存
→ 按并行策略切分并复制到 GPU 显存
→ 编译 / 加载 kernel 与运行时
→ 创建 stream、KV Cache 和工作区
模型权重类似进程地址空间中的只读数据,但大模型通常不能把全部内容简单地一次性复制到一张卡上。张量并行、流水线并行、权重分片、量化格式和 CPU offload 都会改变加载方式。部署时还要为临时 workspace、通信 buffer 和 KV Cache 预留显存,不能只用"权重大小 < 显存大小"作为容量判断。
4.4 版本与架构必须成套验证
GPU kernel 往往和 GPU Compute Capability、CUDA、驱动、框架及编译器版本有关。升级其中任一项,都可能出现以下问题:
- 动态库找不到符号,程序在启动阶段失败;
- kernel 能加载但性能下降,原因是没有命中适合当前架构的实现;
- Engine 或缓存与新版本不兼容,加载时直接报错;
- 同样的模型在不同 GPU 上出现数值或边界行为差异。
生产环境应固定镜像、驱动、CUDA、模型 revision 和编译参数,并把启动日志、GPU 型号和构建信息记录下来,方便回滚和复现。
本章到下一章的衔接:程序能够启动,只说明代码和数据已经就位;执行速度还取决于数据在寄存器、片上存储和显存之间如何流动。下一章进入算子优化最核心的局部性和访存问题。
第 5 章|程序的存储访问、Cache、局部性原理
本章映射总览
这一章是算子性能优化中最值得直接迁移的一章。CPU 课程中的时间局部性、空间局部性、Cache 命中率和分块,在 GPU 上会转换成线程束访存、Shared Memory 复用、寄存器压力和全局显存带宽问题。需要注意的是,GPU 的 Shared Memory 是显式管理的片上 SRAM,不能简单写成"GPU 版 L1 Cache"。
| 课程概念 | LLM 算子中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| 时间局部性 | 寄存器复用、KV Cache、权重缓存 | 数据能否被再次使用 | KV Cache 是模型运行时缓存,不是硬件 Cache |
| 空间局部性 | 合并访存、连续张量布局 | 相邻数据能否一次高效取出 | 线程束访问模式决定显存事务 |
| 分块(tiling) | GEMM tile、Shared Memory tile | 把工作集放进较快存储 | tile 还受 warp、bank 和寄存器约束 |
| Cache 命中 | L1/L2 命中、Shared Memory 复用 | 减少慢速存储访问 | Shared Memory 命中不是硬件 Cache 命中 |
| 虚拟内存 | CUDA UVM、CPU offload | 通过地址抽象管理容量 | 页面迁移可能引入明显延迟 |
5.1 存储层次与数据移动
算子优化的核心通常不是减少一条加法,而是减少昂贵的数据移动。CPU 和 GPU 的存储层次可以这样对照理解:
| 层次 | CPU 示例 | GPU 示例 | 典型特点 |
|---|---|---|---|
| 线程私有高速存储 | 寄存器 | 寄存器 | 容量最小,访问最快 |
| 片上缓存 / SRAM | L1、L2 Cache | Shared Memory、L1 | 需要利用局部性,容量有限 |
| 芯片级缓存 | L3 Cache | L2 Cache | 多线程或多 SM 共享 |
| 设备外存储 | DDR 内存 | HBM / GDDR 显存 | 容量较大,带宽和延迟受限 |
| 外部存储 | SSD、网络存储 | SSD、CPU 内存、统一内存 | 访问代价最高,常用于装载或换入 |
Shared Memory 是程序员可管理的片上 SRAM,不能简单等同于 CPU 的 L1 Cache;GPU 的 L1、L2 和 Shared Memory 的关系还会因架构而变化。这里的对应关系主要用于建立"越靠近计算单元,容量越小但访问越快"的直觉。
5.2 局部性、分块与算术强度
局部性分为两类:
- 时间局部性:一个数据被访问后,短时间内还会再次使用;
- 空间局部性:访问一个地址后,很可能访问相邻地址。
矩阵乘法分块就是同时利用两种局部性。把 A 和 B 的小块搬到 Shared Memory 后,同一数据可以被多个线程重复使用,减少从显存读取的次数。对一个 kernel 来说,还可以用算术强度粗略判断它更可能受计算还是带宽限制:
text
算术强度 = 浮点运算次数 / 访问的字节数
算术强度较低时,继续增加计算单元通常收益有限,应优先减少全局访存;算术强度较高时,才更值得优化指令吞吐、Tensor Core 利用率和并行度。这是 Roofline 分析的基本思路。
5.3 GPU 访存优化的四个抓手
- 合并访存(coalesced access):让同一线程束访问相邻地址,减少显存事务。矩阵转置、列主序访问和不规则 stride 经常破坏这一特性。
- 寄存器复用:把反复使用的中间值留在寄存器,减少 Shared Memory 和显存读写。但寄存器使用过多会降低 occupancy,甚至触发 spill。
- Shared Memory 分块:把可复用数据搬到片上存储,并通过同步保证线程看到正确结果。分块尺寸需要与 bank conflict、线程数和寄存器占用一起调节。
- 融合与布局变换:将相邻算子融合,避免中间张量落到显存;同时选择适合后续算子的 layout,减少转置和重排。
统一内存(CUDA UVM)提供了更方便的地址空间,但页面迁移仍然有成本。它适合简化编程或处理超出显存的数据,不应被误认为可以免费隐藏显存不足。
5.4 一条可执行的优化流程
以一个自定义归一化 kernel 为例,可以按下面的顺序排查:
text
先测基线:输入形状、dtype、批量、延迟和吞吐
↓
确认瓶颈:带宽、计算、同步、启动开销还是形状不规则
↓
检查访存:stride、合并访存、缓存命中、bank conflict
↓
调参数:tile、warp 数、流水线阶段和寄存器预算
↓
验证结果:数值误差、边界形状、不同 batch 和不同 dtype
↓
回归压测:平均延迟、P99、显存、功耗与多流并发
不要只看单次 kernel 的平均耗时。LLM 推理还要关注 Prefill、Decode、KV Cache 命中率、请求长度和并发度;一个局部更快的 kernel,可能因为额外的 layout 转换让端到端性能变差。
比较两个实现时,应先预热,让 CUDA 完成首次上下文初始化和 kernel 编译,再用 CUDA Event 计时。主机端直接用 time.time() 包住异步调用,测到的往往只是任务提交时间:
python
for _ in range(20):
kernel() # 预热,排除首次编译和初始化
torch.cuda.synchronize()
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(100):
kernel()
end.record()
end.synchronize()
print("平均耗时 (ms):", start.elapsed_time(end) / 100)
测试时应固定输入形状、数据类型和 GPU 状态,分别记录冷启动与稳定运行结果。还要确认基准函数完成了相同工作,并检查输出误差;只比较耗时而不验证结果,可能把错误的实现当成优化。
本章到下一章的衔接:局部性决定单个 kernel 的数据移动效率,但完整系统还要处理主机与设备、设备与设备之间的任务提交和数据传输。下一章把视角从存储访问扩展到异步 I/O、事件和通信。
第 6 章|I/O、中断、系统调用
本章映射总览
这一章的联系也主要是抽象层面的:CPU 提交工作、设备异步执行、事件完成、主机继续推进,这种"提交---排队---完成---同步"的生命周期与异步 I/O 很相似。不要把 CUDA API 直接等同于系统调用,也不要把每次 kernel 完成都理解成一次 CPU 中断;现代 GPU 通常由驱动、设备队列和事件机制共同完成通知。
| 课程概念 | GPU / 分布式系统中的对应对象 | 共同问题 | 对应边界 |
|---|---|---|---|
| I/O 请求提交 | kernel launch、cudaMemcpyAsync、NCCL 操作 |
如何把工作放入设备队列 | API 调用不一定等同于一次系统调用 |
| 设备队列 | CUDA Stream、通信 stream | 如何保持顺序并重叠独立任务 | stream 并行受资源和依赖约束 |
| 中断 / 完成通知 | CUDA Event、fence、运行时回调 | 主机如何知道设备工作完成 | 不保证每个完成事件都对应一个 CPU 中断 |
| 阻塞等待 | cudaEventSynchronize、cudaDeviceSynchronize |
何时需要结果或全局一致性 | 过多同步会破坏流水线并行 |
| I/O 带宽 | PCIe、NVLink、InfiniBand | 数据移动是否成为瓶颈 | 分布式通信还受拓扑和算法影响 |
6.1 从同步调用到异步执行
CPU 与 GPU 的协作可以抽象成一个异步 I/O 系统:CPU 准备数据并提交任务,GPU 通过 DMA 读取数据、执行 kernel,再把结果写回。CUDA Stream 是按顺序执行的一条命令队列,多个 stream 可以在满足依赖的情况下重叠拷贝与计算。
text
CPU 准备输入
│ cudaMemcpyAsync
▼
GPU 全局显存 ──► kernel 在 stream 中排队执行
│ │
└────── cudaEvent ────────┘
│
CPU 查询或等待结果
"异步"描述的是 API 对主机线程的返回方式和队列提交关系,并不代表所有操作都能并行。默认流规则、内存是否为 pinned memory、不同操作之间是否存在依赖,都会决定实际执行顺序。
6.2 一个最小的 CUDA 异步示例
cpp
cudaStream_t stream;
cudaEvent_t done;
cudaStreamCreate(&stream);
cudaEventCreate(&done);
cudaMemcpyAsync(d_x, h_x, bytes, cudaMemcpyHostToDevice, stream);
my_kernel<<<grid, block, 0, stream>>>(d_x, d_y);
cudaMemcpyAsync(h_y, d_y, bytes, cudaMemcpyDeviceToHost, stream);
cudaEventRecord(done, stream);
// 需要结果时再等待;期间 CPU 可以处理其他任务
cudaEventSynchronize(done);
为了让主机到设备的异步拷贝真正发挥作用,主机缓冲区通常需要使用页锁定(pinned)内存。cudaDeviceSynchronize() 会等待设备上的相关工作完成,适合调试或明确的全局同步点,但在热路径中频繁调用会破坏流水线并行。
6.3 分布式训练中的通信与同步
NCCL 等通信库负责在多张 GPU 之间执行 AllReduce、AllGather、Broadcast 等操作。其性能受 GPU 拓扑、NVLink、PCIe、InfiniBand、消息大小和通信算法影响。
计算与通信可以通过不同 stream 重叠,但前提是数据依赖已经明确。例如,梯度分桶后可以在部分反向计算完成时立即启动 AllReduce,而不必等待整张网络反向结束。优化分布式训练时,应同时观察 kernel 时间、通信时间、链路带宽、同步等待和负载均衡,不能只看单卡算力。
6.4 端到端排障清单
遇到"GPU 利用率低"时,可以按下面的问题逐项确认:
| 检查项 | 典型现象 | 优先动作 |
|---|---|---|
| 数据准备 | CPU 占用高,GPU 队列经常为空 | 使用 pinned memory、预取和数据流水线 |
| kernel 启动 | kernel 很短但数量很多 | 融合小算子、批处理或使用 CUDA Graph |
| 显存访问 | 带宽接近上限,计算单元利用率低 | 调整 layout、tile 和访存合并 |
| 同步等待 | stream 之间出现长空洞 | 减少全局同步,检查事件和默认流依赖 |
| 多卡通信 | 链路带宽低或等待时间长 | 检查拓扑、通信分组和消息大小 |
| 数值问题 | 低精度误差或偶发溢出 | 提高累加精度,检查缩放、舍入和边界 |
建议使用 Nsight Systems 观察端到端时间线,用 Nsight Compute 分析单个 kernel,再回到模型级指标验证优化是否真正改善了 TTFT、TPOT、吞吐和 P99。这样才能把"计算机系统原理"落实到 LLM 编译器和算子优化的实际效果上。