本站内容由 AI 生成,可能存在错误。如发现问题,欢迎到 GitHub Issues 反馈。

代码生成(上):指令选择、Vectorization 与 Register Allocation

代码生成(上):指令选择、Vectorization 与 Register Allocation

更新于 2026-04-23

查看全景图用户代码全景图计算图捕获IR 设计优化 Pass算子融合代码生成12. 指令选择 & Vectorization你在这里调度与执行硬件执行

简介

IR 与硬件之间的语义鸿沟高层 IR映射选择硬件 ISAlinalg.matmultorch.relumath.expTensor CoreCUDA CoreFMNMXSFU多项式HMMA.16816FFMAFMNMXMUFU.EX2LDG.128SFU.EX2Instruction Selection = 为每个高层 op 选择最优映射

经过前面几篇文章的讨论——算子融合的分类与策略Cost Model 的设计Tiling 与内存层次优化以及动态 Shape 的挑战——我们已经有了一套经过优化的 IR(中间表示)。IR 中的算子已经被融合、tiled、分析过数据依赖。但这些优化后的 IR 仍然是抽象的描述,它们距离 GPU 能够真正执行的机器指令还有一步之遥。

代码生成(Code Generation, Codegen) 就是这最后一步——将优化后的 IR 转化为 GPU 可以直接执行的硬件指令。这不仅仅是简单的翻译,codegen 阶段本身包含了大量的优化机会:

  • 指令选择(Instruction Selection):同一个 IR 操作可能有多种硬件指令实现方式,编译器需要选择最优的
  • Vectorization(向量化):将标量内存访问合并为更宽的向量访问,提升带宽利用率
  • Register Allocation(寄存器分配):在寄存器复用与 occupancy 之间找到平衡

这三个子任务共同构成了 codegen pipeline 的前半部分。本文将逐一深入讲解。

代码生成的任务

输入与输出

Codegen 的输入是经过 Pass、Fusion、Tiling 优化后的 IR。以 Triton 为例,这时的 IR 已经是 Triton Dialect(或 MLIR 层级的 IR),其中包含了:

  • tt.dot(矩阵乘法)
  • tt.load / tt.store(内存读写)
  • arith.addfarith.mulf(标量算术)
  • math.expmath.tanh(超越函数)

Codegen 的输出是 GPU 可执行的指令。在 NVIDIA 生态中,这个过程分为两步:

  1. IR → PTX(Parallel Thread Execution):编译器将高层 IR 降低为 PTX 虚拟指令集。PTX 是 NVIDIA 定义的虚拟 ISA,具有良好的跨代兼容性。
  2. PTX → SASS(cubin):NVIDIA 的 ptxas 汇编器将 PTX 编译为实际的 GPU 微码(SASS),这一步对开发者通常是黑盒。

语义鸿沟

高层 IR 操作与底层硬件指令之间存在显著的语义鸿沟(Semantic Gap)

  • 一个 linalg.matmul 可能对应 Tensor Core 上的 HMMA 指令,也可能对应标量 FMA 循环
  • 一个 math.exp 可能由 SFU(Special Function Unit)硬件加速,也可能用多项式逼近
  • 一个简单的 arith.addf 可以直接映射为 FADD,但也可以被融合进 FFMA

编译器的工作就是在这些选择中,找到最优的映射方案。

LLVM 作为通用后端

大部分 ML 编译器(包括 Triton、XLA、TVM)最终都将 IR 降低到 LLVM IR,然后利用 LLVM 的 NVPTX 后端生成 PTX。LLVM 的代码生成器使用两种主要算法:

  • SelectionDAG:传统的基于 DAG 的指令选择,将 LLVM IR 转化为目标特定的 DAG,再通过 pattern matching 选择指令
  • GlobalISel(Global Instruction Selection):更新的框架,直接在 LLVM IR 上做指令选择,支持更细粒度的优化

对于 GPU 目标,LLVM 的 NVPTX 后端负责将 LLVM IR 映射到 PTX 指令,而 ptxas 再将 PTX 进一步优化并生成 SASS。

指令选择 (Instruction Selection)

IR Op → Hardware Instruction Mapping

指令选择的核心是一对多映射:一个 IR 操作通常有多个合法的硬件指令序列可以实现它。编译器的任务是从中选择吞吐量最高、延迟最低、资源利用最优的方案。

考虑以下几个例子:

矩阵乘法:

  • linalg.matmul<f16> → HMMA.16816.F32(Tensor Core,1024 FLOPs/cycle/SM)
  • linalg.matmul<f16> → HFMA2 循环(FP16 ALU,128 FLOPs/cycle/SM)

选择 Tensor Core 可以获得 8x 的吞吐量提升。

指数函数:

  • math.exp<f32> → MUFU.EX2 + MUL(SFU 硬件加速)
  • math.exp<f32> → 6 条 FFMA 组成的多项式逼近

SFU 方案用 2 条指令完成,ALU 方案需要 6 条。虽然 SFU 吞吐量较低(16 ops/cycle/SM vs FP32 ALU 的 64 ops/cycle/SM),但指令数更少意味着更少的 register pressure 和更低的 instruction cache 压力。

GPU-Specific Instruction Selection

NVIDIA GPU(以 A100 / Ampere 架构为例)提供了多类执行单元,每类有不同的指令集和性能特征:

Tensor Core 指令:

  • HMMA.16816.F32:FP16 输入,FP32 累加,16×8×1616 \times 8 \times 16 矩阵块
  • HMMA.16816.F16:FP16 输入,FP16 累加
  • 对应的 PTX:mma.sync.aligned.m16n8k16.f32.f16
  • 吞吐量:1024 FLOPs/cycle/SM(A100,4 个 Tensor Core),远超标量 ALU

FP32 ALU 指令:

  • FADD(加法)、FMUL(乘法)、FFMA(fused multiply-add)
  • FMNMX(fused min/max,用于 ReLU 等激活函数)
  • 吞吐量:64 ops/cycle/SM
  • 延迟:4 cycles

FP16 ALU 指令:

  • HFMA2:同时处理 2 个 FP16 值的 FMA
  • 对应 PTX:fma.rn.f16x2
  • 吞吐量:128 FLOPs/cycle/SM(因为 2-wide)

SFU(Special Function Unit)指令:

  • MUFU.EX22x2^x)、MUFU.RCP(倒数)、MUFU.RSQ(逆平方根)、MUFU.SINMUFU.COS
  • 对应 PTX:ex2.approx.f32rcp.approx.f32
  • 吞吐量:16 ops/cycle/SM
  • 延迟:~20 cycles
  • 精度:近似值(约 22-bit mantissa),对 ML 训练/推理通常足够

选择标准综合考虑:吞吐量延迟精度需求可用执行单元的竞争情况

指令选择演示:IR → GPU 指令

步骤 1/4
IR 操作linalg.matmul<f16>FP16 矩阵乘法需要映射到 GPU 硬件指令?HMMA.16816.F32SASSPTX: mma.sync.aligned.m16n8k16.f32.f16执行单元: Tensor Core吞吐量: 1024 FLOPs/cycle/SM延迟: 16 cyclesFP16 输入 + FP32 累加,Tensor Core 最佳选择HFMA2 (FP16 FMA)SASSPTX: fma.rn.f16x2执行单元: FP16 ALU吞吐量: 128 FLOPs/cycle/SM延迟: 4 cycles标量 FP16 FMA,吞吐量只有 Tensor Core 的一半SM 执行单元Tensor Core活跃FP32 ALUFP16 ALUSFU

Peephole Optimization

在初始指令选择之后,编译器会进行 Peephole Optimization(窥孔优化)——在局部指令窗口内查找可优化的模式:

强度折减(Strength Reduction):

  • 乘以 2 的幂 → 移位:x * 4.0FMUL x, 4.0(或直接用指数加法)
  • 除以常数 → 乘以倒数:x / 3.0FMUL x, 0.333...(避免昂贵的除法指令)

指令融合(Instruction Merging):

  • 分离的 MUL + ADDFFMA(fused multiply-add)
  • 例如:y = x * 2.0 + bias 需要两条指令 FMUL + FADD
  • 融合后:FFMA(x, 2.0, bias) 一条指令完成
  • 好处:减少指令数、减少中间寄存器需求、可能提高精度(FMA 只做一次 rounding)

指令级死代码消除:

  • 移除结果未被使用的指令
  • 移除冗余的 move 指令
  • 简化恒等操作:x + 0.0xx * 1.0x

这些优化看似微小,但在一个被调用数十亿次的 GPU kernel 中,每减少一条指令都意味着显著的性能提升。

Vectorization

SIMD 映射

在 CPU 上,向量化通常指利用 SIMD(Single Instruction, Multiple Data)指令集——如 x86 的 SSE/AVX 或 ARM 的 NEON——将多个标量操作打包成一条向量指令。

GPU 的情况有所不同。GPU 采用 SIMT(Single Instruction, Multiple Threads) 模型:每个 warp 中的 32 个线程自然地执行相同的指令,只是操作在不同的数据上。因此,GPU 的”向量化”不是在指令层面打包多个操作,而是在内存访问层面——使用更宽的 load/store 指令来提升带宽利用率。

向量化内存访问

GPU 内存访问的基本单位是 32-byte sector。当一个 warp 中的线程发出内存请求时,硬件会将这些请求合并为对若干 32-byte sector 的访问(memory coalescing)。

但即使在已经合并的访问模式下,每条 load 指令的宽度仍然影响性能:

Load 类型每条指令字节数处理 16 个 FP32 元素需要的 load 指令数指令减少
float(标量)4B16baseline
float28B82x fewer
float416B44x fewer

关键理解:向量化减少的是 load 指令的条数,而不是 32-byte sector transactions。在同样的 memory coalescing 模式下,float4 load 让每个线程一条指令就能读取 16 字节数据,而标量 load 需要 4 条指令才能读取同样多的数据。

这带来的好处是:

  • 更少的指令:减少 instruction cache 压力和指令调度开销
  • 更高的每指令带宽:每条 load 搬运更多数据
  • 更少的 register 用于地址计算:一个地址对应更多数据

对齐要求:float4 load 需要 16-byte 对齐的地址。如果输入数据没有对齐,编译器需要回退到更窄的 load。

Vectorization:标量 vs 向量化内存访问

内存布局(16 个 FP32 元素,64 字节)Load 1Load 2Load 3Load 4Load 5Load 6Load 7Load 8...+8 more loads[0]4B[1]4B[2]4B[3]4B[4]4B[5]4B[6]4B[7]4B[8]4B[9]4B[10]4B[11]4B[12]4B[13]4B[14]4B[15]4B代码对比标量代码for i in range(16): x = tl.load(ptr + i) y = x * alpha + beta tl.store(out + i, y)向量化代码# Scalar (no vectorization)x = tl.load(ptr + offset)y = x * alpha + betatl.store(out + offset, y)效率指标Load 指令数: 161x (baseline)每指令字节数: 4B4B / instr带宽利用率: 25%25%注:向量化减少的是 load 指令条数,不是 32B sector transactions

Vectorization Legality

并非所有内存访问都可以向量化。编译器需要检查以下条件:

数据独立性(Independence): 向量化 load 中的各元素必须是独立的——即它们之间不存在数据依赖。例如,如果 a[i] 的计算依赖于 a[i-1],就不能将它们放入同一个向量 load。

地址对齐(Alignment): 向量化 load 要求起始地址对齐到向量宽度。例如:

  • float2 需要 8-byte 对齐
  • float4 需要 16-byte 对齐

如果 base pointer 不满足对齐要求,编译器需要:

  1. 在开头使用标量 load 处理非对齐的部分
  2. 主循环使用向量化 load
  3. 在结尾使用标量 load 处理剩余元素

访问步长(Stride): 只有 stride-1(连续访问)的模式可以直接向量化。如果访问模式是:

  • a[0], a[2], a[4], a[6](stride-2)→ 不能直接向量化
  • a[indices[0]], a[indices[1]], ...(gather)→ 不能向量化,需要使用 gather 指令(性能很差)

在 Triton 中,编译器通过分析 tl.loadtl.store 的 offset 模式来判断是否可以向量化,以及选择什么向量宽度。

Register Allocation

GPU Register File 特性

GPU 的 Register File(寄存器文件)是片上最快的存储层级,但它的使用方式与 CPU 有根本性的不同。

以 NVIDIA A100 为例:

  • 每个 SM 有 65,536 个 32-bit 寄存器,这些寄存器由 SM 上所有活跃的线程共享
  • 每个线程最多使用 255 个寄存器(硬件上限)
  • 每个 SM 最多 64 个 warp(即 2048 个线程)

这三个数字之间的关系直接决定了 kernel 的 occupancy(占用率)

max warps=min(64,65536regs_per_thread×32)\text{max warps} = \min\left(64, \left\lfloor \frac{65536}{\text{regs\_per\_thread} \times 32} \right\rfloor\right) occupancy=max warps64\text{occupancy} = \frac{\text{max warps}}{64}

例如,如果一个 kernel 每线程使用 32 个寄存器:

max warps=min(64,6553632×32)=min(64,64)=64\text{max warps} = \min\left(64, \left\lfloor \frac{65536}{32 \times 32} \right\rfloor\right) = \min(64, 64) = 64

occupancy = 100%。但如果每线程使用 128 个寄存器:

max warps=min(64,65536128×32)=min(64,16)=16\text{max warps} = \min\left(64, \left\lfloor \frac{65536}{128 \times 32} \right\rfloor\right) = \min(64, 16) = 16

occupancy = 25%。

GPU 与 CPU 的关键差异

在 CPU 上,寄存器溢出(register spill)会将数据溢出到 L1 cache,代价约为 4-5 个周期。但在 GPU 上,register spill 走的是 local memory 路径:

RegisterspillL1 CacheL2 CacheDRAM (HBM)\text{Register} \xrightarrow{\text{spill}} \text{L1 Cache} \rightarrow \text{L2 Cache} \rightarrow \text{DRAM (HBM)}

如果 L1 miss,延迟可以从几个周期飙升到 数百个周期(HBM 延迟约 400-600 cycles)。这意味着 GPU 上的 register spill 代价远比 CPU 上更高,是性能优化中必须避免的情况。

Register Pressure vs Occupancy 权衡

这是 GPU kernel 优化中最核心的权衡之一:

低寄存器使用 → 高 Occupancy:

  • 更多 warp 同时活跃 → 更好的延迟隐藏(latency hiding)
  • 当一个 warp 等待内存访问时,调度器切换到另一个 warp
  • 对 memory-bound kernel 非常重要

高寄存器使用 → 低 Occupancy,但更多数据复用:

  • 中间结果保留在寄存器中 → 避免重复从内存读取
  • 对 compute-bound kernel,数据复用比延迟隐藏更重要
  • 典型场景:GEMM kernel 将多个 tile 的部分和(partial sums)保存在寄存器中

经验法则: 对于 compute-bound kernel(如 GEMM),32-64 个寄存器/线程通常是较好的平衡点。对于 memory-bound kernel(如 element-wise 操作),尽量减少寄存器使用以最大化 occupancy。

寄存器文件 (65536 regs/SM)已用: 16,384 / 65,536活跃 warps: 64 / 64每线程寄存器: 8融合的算子reluOccupancy vs 数据复用单个 ReLUOccupancy: 100%数据复用: 1x有效性能: 1.00ReLU + Mul (2-op)Occupancy: 100%数据复用: 1.5x有效性能: 1.50ReLU+Mul+Add+TanhOccupancy: 100%数据复用: 2.5x有效性能: 2.50GEMM+8-opOccupancy: 33%数据复用: 4x有效性能: 1.31过度融合 (spill)Occupancy: 16%数据复用: 3x有效性能: 0.75最少寄存器,最高 occupancy,但无数据复用

Fusion 和 Tiling 对 Register Pressure 的影响

前面文章中讨论的 Fusion 和 Tiling 策略直接影响 register pressure:

更多融合 → 更高寄存器压力: 每个被融合的算子都需要寄存器来保存中间结果。融合 2 个算子可能需要 16 个寄存器,融合 8 个可能需要 96 个。超过一定阈值后,寄存器溢出(spill)会导致性能急剧下降。

更大的 Tile → 更高寄存器压力: 每个线程处理更大的 tile 意味着需要更多寄存器来暂存输入数据和部分结果。例如,一个处理 4×44 \times 4 输出元素的线程需要的寄存器远多于处理 1×11 \times 1 的。

性能悬崖(Performance Cliff): Register pressure 存在一个阈值效应。当寄存器使用量刚好超过 spill 阈值时,性能会突然大幅下降——这就是”性能悬崖”。例如:

  • 每线程 64 个寄存器:occupancy 50%,无 spill,性能良好
  • 每线程 96 个寄存器:occupancy 21%,无 spill,性能尚可(靠数据复用弥补)
  • 每线程 200 个寄存器:超过 255 限制,必须 spill,性能骤降

这就是为什么编译器的 register allocator 需要精确控制寄存器使用量,有时甚至主动牺牲一些数据复用来避免 spill。

编译器的 Register Allocation 策略

LLVM 的 Graph Coloring 算法: LLVM 的 register allocator 基于 graph coloring(图着色) 算法。它将每个变量的生命周期建模为干涉图(interference graph)中的节点,如果两个变量的生命周期重叠,就在它们之间连一条边。寄存器分配等价于对该图进行 kk-coloring(kk = 可用寄存器数)。

GPU 特定优化: LLVM 的 NVPTX 后端会考虑 GPU 的特殊约束:

  • 平衡寄存器数量与 occupancy
  • 考虑 register bank conflict(某些架构上,同一 bank 的寄存器同时读取会冲突)
  • 对 Tensor Core 指令使用的寄存器做特殊布局

Triton 的策略: Triton 编译器通过 tile size 选择间接控制 register allocation。更小的 tile → 更少的寄存器需求。Triton 的 auto-tuner 会在不同的 tile size 配置之间搜索,找到寄存器使用量和性能的最佳平衡。

CUDA 用户的手动控制: 在 CUDA 中,开发者可以通过以下方式提示编译器:

  • __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) — 告知编译器每个 block 的最大线程数和每个 SM 的最小 block 数,编译器据此调整寄存器分配
  • #pragma unroll — 控制循环展开程度,间接影响寄存器压力
  • __maxnreg=N — 直接限制每线程的最大寄存器数(不推荐,通常让编译器自动决定更好)

实际案例:GELU Kernel 的 Codegen

GELU Codegen DAGxx * xFFMAx * 0.5FFMA× 0.044715FFMAx + 0.044715x³FFMA× √(2/π)FFMAtanhSFU / MUFU1 + tanh(…)FADD最终乘积FMUL关键决策:SFU (快, ~2 ULP) vs 多项式 (慢, 精确)数学运算ISA 指令

让我们将上述三个概念串联起来,看一个完整的 GELU 激活函数 kernel 是如何经历 codegen pipeline 的。

GELU 的数学定义

GELU(x)=0.5x(1+tanh(2π(x+0.044715x3)))\text{GELU}(x) = 0.5 \cdot x \cdot \left(1 + \tanh\left(\sqrt{\frac{2}{\pi}} \cdot \left(x + 0.044715 \cdot x^3\right)\right)\right)

这个函数包含:乘法、加法、立方、tanh(超越函数)、常数乘法。

Step 1: 指令选择

编译器将 GELU 的各操作映射到具体指令:

操作SASS 指令执行单元
x3=xxxx^3 = x \cdot x \cdot xFFMAFP32 ALU
0.044715x30.044715 \cdot x^3FFMAFP32 ALU
x+0.044715x3x + 0.044715 \cdot x^3FFMA(与上一步融合)FP32 ALU
2/π()\sqrt{2/\pi} \cdot (\ldots)FFMAFP32 ALU
tanh()\tanh(\ldots)MUFU.EX2 + 系列指令SFU + FP32 ALU
1+tanh()1 + \tanh(\ldots)FADDFP32 ALU
0.5x()0.5 \cdot x \cdot (\ldots)FFMAFP32 ALU

其中 tanh 的实现最复杂。一种常见方法是利用恒等式:

tanh(x)=e2x1e2x+1=12e2x+1\tanh(x) = \frac{e^{2x} - 1}{e^{2x} + 1} = 1 - \frac{2}{e^{2x} + 1}

其中 e2xe^{2x} 可以通过 MUFU.EX2(计算 2y2^y)和换底公式实现:ex=2xlog2(e)e^x = 2^{x \cdot \log_2(e)}

Step 2: Vectorize Loads

GELU kernel 是 element-wise 的,输入和输出具有完美的 stride-1 访问模式。编译器将标量 load 提升为 float4 load:

  • 原始:每个线程 1 次 LD.E(4 bytes)→ 处理 1 个元素
  • 优化:每个线程 1 次 LDG.E.128(16 bytes)→ 处理 4 个元素

这将 load 指令数减少为原来的 1/4

Step 3: Register Allocation

GELU kernel 每线程需要约 12 个寄存器:

  • 4 个用于 float4 输入
  • 4 个用于 float4 输出
  • 4 个用于中间计算
max warps=min(64,6553612×32)=min(64,170)=64\text{max warps} = \min\left(64, \left\lfloor \frac{65536}{12 \times 32} \right\rfloor\right) = \min(64, 170) = 64

occupancy = 100%。这是理想情况——GELU 是一个简单的 element-wise 操作,寄存器需求很低。

Step 4: 最终 PTX 指令概览

生成的 PTX 代码大致如下(简化版):

// Load float4
ld.global.v4.f32 {%f1, %f2, %f3, %f4}, [%rd1];

// 对每个元素计算 GELU(以 %f1 为例)
mul.f32     %f5, %f1, %f1;        // x^2
fma.rn.f32  %f6, %f5, %f1, 0.0;   // x^3
fma.rn.f32  %f7, %f6, 0.044715, %f1;  // x + 0.044715*x^3
mul.f32     %f8, %f7, 0.7978845;   // sqrt(2/pi) * (...)
mul.f32     %f9, %f8, 1.4426950;   // convert to base-2
ex2.approx.f32 %f10, %f9;          // 2^(...)
// ... tanh 计算的后续步骤 ...
fma.rn.f32  %f15, %f1, %f14, 0.0; // 0.5 * x * (1 + tanh)

// Store float4
st.global.v4.f32 [%rd2], {%f15, %f16, %f17, %f18};

注意指令混合:大量的 fma.rn.f32(FP32 ALU)加上 ex2.approx.f32(SFU)。SFU 的吞吐量限制(16 ops/cycle/SM)是 GELU kernel 的性能瓶颈所在。

总结

本文深入讨论了 GPU 代码生成的三大核心任务:

  1. 指令选择将 IR 操作映射到硬件指令——在 Tensor Core、FP32 ALU、FP16 ALU 和 SFU 之间选择最优方案。关键洞察:同一操作可能有截然不同的实现方式,编译器需要基于吞吐量、延迟和精度需求做出选择。

  2. Vectorization 将标量内存访问提升为向量访问——float4 load 将 load 指令数减少为 1/4。关键约束:需要 stride-1 访问模式和地址对齐。

  3. Register Allocation 在数据复用与 occupancy 之间寻找平衡——A100 的 65,536 个寄存器/SM 看似很多,但在 2048 个线程之间共享后,每线程只有 32 个。过多的融合或过大的 tile 会导致 register spill,性能骤降。

这三个任务紧密耦合:指令选择决定了需要多少寄存器(不同指令的 register footprint 不同),向量化影响了 load/store 的寄存器需求,而 register allocation 的结果又可能反过来影响指令选择的策略。

下一篇文章我们将深入 Triton 编译 Pipeline 和编译器后端的具体实现,看看这些概念如何在实际的 ML 编译器中落地。

延伸阅读