【编译器】从计算机系统原理到 LLM 编译器与算子优化

【编译器】从计算机系统原理到 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、驱动、框架及编译器版本有关。升级其中任一项,都可能出现以下问题:

  1. 动态库找不到符号,程序在启动阶段失败;
  2. kernel 能加载但性能下降,原因是没有命中适合当前架构的实现;
  3. Engine 或缓存与新版本不兼容,加载时直接报错;
  4. 同样的模型在不同 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 访存优化的四个抓手

  1. 合并访存(coalesced access):让同一线程束访问相邻地址,减少显存事务。矩阵转置、列主序访问和不规则 stride 经常破坏这一特性。
  2. 寄存器复用:把反复使用的中间值留在寄存器,减少 Shared Memory 和显存读写。但寄存器使用过多会降低 occupancy,甚至触发 spill。
  3. Shared Memory 分块:把可复用数据搬到片上存储,并通过同步保证线程看到正确结果。分块尺寸需要与 bank conflict、线程数和寄存器占用一起调节。
  4. 融合与布局变换:将相邻算子融合,避免中间张量落到显存;同时选择适合后续算子的 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 编译器和算子优化的实际效果上。

相关推荐
虎鲸不是鱼1 小时前
AI 自动化逆向(十七)解码器、反汇编器、反编译器:能力边界表
人工智能·自动化·大模型·逆向·ai逆向
蚰蜒螟1 小时前
vmware17 ubuntu24.04编译android aosp16
1024程序员节
天树星智源2 小时前
相机轨迹为什么对不上:问题不在控制模块,而在两段噪声不是同一份
论文阅读·机器人·大模型
希望20172 小时前
杂|使用dfs遍历数组
1024程序员节
码完就睡2 小时前
Linux——以太网协议
运维·服务器·网络·1024程序员节
职豚求职小程序刷题2 小时前
光大证券在线测评|时间&考点整理好啦(附练习题库)
1024程序员节
Magicdata大数据2 小时前
AI辅助教学:调用大模型从教材中生成习题,方便学员练习
java·spring boot·大模型·知识图谱·neo4j·ai辅助教学
天远API2 小时前
零信任架构实战:基于天远企业二要素验证构建自动化跨境物流货代授信签约网关
人工智能·1024程序员节·工具分享
@嵌入式扫地僧2 小时前
实战教程:NVIDIA Jetson Nano 8GB 搭建本地 RAG 大语言模型知识库完整指南
人工智能·ai·jetson nano·1024程序员节·rag