推理引擎的编译优化:从计算图到高效执行

当 PagedAttention 解决了显存碎片问题之后,真正决定单 token 推理延迟下限的,是计算图的编译质量。

一、计算图:推理引擎的中间表示

现代推理引擎(vLLM、TensorRT-LLM、llama.cpp、GGML)虽然架构各异,但都共享同一个计算模型:将有向无环图(DAG)作为推理任务的中间表示(IR),然后对 IR 施加一系列变换,最终生成可在 GPU 上执行的 kernel。

一个典型的 transformer 单层计算图包含以下算子节点:

Input (hidden_states)
    │
    ├──→ LayerNorm ──→ [分支A]
    │                      │
    │                      ▼
    │                 Linear(Q) ──→ RotaryEmbedding ──→ [Q branch]
    │                 Linear(K) ──→ RotaryEmbedding ──→ [K branch]
    │                 Linear(V) ──────────────→ [V branch]
    │                                               │
    │                                               ▼
    │                                          KV Cache (external state)
    │                                               │
    ▼                                               ▼
              Attention(Q, K_cache, V_cache) ──→ Softmax ──→ [scores]
                                                           │
                                                           ▼
                                                  Linear(O_proj) ──→ [output_A]
                                                                          │
                                               [分支A] ──→ Add ───────────┘
                                                           │
                                                           ▼
                                                     FeedForward:
                                                    Linear(up) → GELU → Linear(down)
                                                           │
                                                           ▼
                                                      Add ──→ Output

这张图只是逻辑视图。当 batch_size=1、sequence_length=1(decode 阶段)时,每个 Linear 的矩阵乘法退化为矩阵-向量乘法(GEMV),计算密度极低,瓶颈从算力转为显存带宽。此时编译优化的目标完全不同——不是最大化并行度,而是最小化显存搬运。

1.1 编译优化的核心矛盾

推理引擎编译面临三重矛盾:

矛盾维度 训练优化 推理优化
计算粒度 大矩阵乘法,batch 越大越好 GEMV 主导,算术强度低
显存策略 重计算(activation checkpointing)换显存 预分配 + 静态规划,避免碎片
并行模式 张量并行 + 流水线并行 张量并行 + 连续批处理(continuous batching)

理解这些矛盾是把握推理编译优化的前提。

二、算子融合:降低显存带宽瓶颈的第一法则

算子融合(operator fusion)是将多个相邻的计算节点合并为一个 kernel 的技术。其核心收益是避免中间结果写回 HBM,直接在寄存器或共享内存中流转。

2.1 为什么融合能提升性能

以 FFN 层的 Linear → GELU → Linear 为例,未融合时需要 3 次 kernel launch + 2 次 HBM 中间张量读写。对于隐藏维度 4096、intermediate 维度 11008 的 Llama-2-7B 模型,中间张量大小为 batch × seq × 11008 × 2 bytes ≈ 22KB/token。每个中间结果写入 HBM 再读回,消耗约 22KB × 2 × 152(单层数)/ 1.6TB/s ≈ 4.2ms。而融合的 kernel 只在首尾各访问一次 HBM,节省约 60% 的显存带宽。

2.2 融合的分类

推理引擎中的融合策略可分为三个层次:

垂直融合(Vertical Fusion)——将同一数据流上的连续算子合并:

# 原始:三个独立 kernel
silu_out = silu(x @ gate_proj)        # kernel 1
up = x @ up_proj                       # kernel 2
hidden = silu_out * up @ down_proj     # kernel 3

# 融合后:单个 kernel(SwiGLU-Fused)
def swiglu_fused(x, gate_w, up_w, down_w):
    gate = x @ gate_w                   # 共享内存中间结果
    up = x @ up_w
    hidden = silu(gate) * up @ down_proj
    return hidden

水平融合(Horizontal Fusion)——将共享输入的多个线性投影合并为一个大矩阵乘法。QKV 投影是最经典的例子:

# 未融合:三次 GEMM
Q = X @ W_q      # [1, 4096] × [4096, 4096]
K = X @ W_k      # [1, 4096] × [4096, 4096]
V = X @ W_v      # [1, 4096] × [4096, 4096]

# 融合后:单次 GEMM + split
W_qkv = concat(W_q, W_w, W_v)  # [4096, 12288]
QKV = X @ W_qkv                # 一次大矩阵乘
Q, K, V = split(QKV, dim=-1)

当 batch_size=1 时,这三次 GEMV 被合并为一个 GEMM,每个 SM 从只处理一个小矩阵变为处理一个规整的大矩阵,GPU 利用率提升 2-3 倍。

注意力融合(FlashAttention Fusion)——将 QK^T、softmax、dropout、@V 合并为单个 kernel,彻底避免 attention score 矩阵写回 HBM。FlashAttention-2 在此基础上进一步将 Q 的分块循环移到最外层,最大化共享内存的复用。

2.3 Llama.cpp 中的融合实践

Llama.cpp 的 ggml 后端定义了一组融合算子,位于 ggml-backend.h:

// 融合 silu + mul + matmul(SwiGLU FFN 的 ggml 定义)
struct ggml_tensor * swiglu = ggml_silu_mul(ctx, 
    ggml_mul_mat(ctx, gate_proj, x),      // silu(gate @ W_gate)
    ggml_mul_mat(ctx, up_proj, x)          // up @ W_up
);
// 完整的 FFN:融合为单个 GPU kernel
struct ggml_tensor * ff = ggml_mul_mat(ctx, down_proj, swiglu);

在 CMakeLists.txt 或编译宏控制下,这些融合会映射到具体的 CUDA kernel 实现(如 ggml-cuda/fn_*.cu)。

三、编译期常量折叠:消灭"已知计算"

常量折叠(constant folding)是指在编译阶段将已知结果的计算直接替换为常量。在推理场景中,权重矩阵、scale 因子、位置编码系数都是编译期已知量。

3.1 权重量化折叠

INT8/INT4 权重量化的反缩放因子可以折叠进相邻算子:

# 原始计算(per-channel INT8 量化)
scale = weight_scale[channel_idx]        # 运行时读取
y = (x @ (w_int8 / scale)) + bias       # 先反量化,再计算

# 折叠后:将 scale 融合进下一层的输入 scale
y_scale = input_scale * weight_scale     # 编译期预计算
y_int8 = (x_int8 @ w_int8) >> shift      # 全 INT8 计算

GPTQ、AWQ 等量化方法的编译期预处理本质上就是对量化参数(group scale、zero point)在计算图中做折叠和重排。

3.2 旋转位置编码融合(RoPE Fusion)

RoPE 对 Q/K 施加如下旋转变换:

[θ₁, θ₂, ..., θ_{d/2}]  ← 序列位置 m 决定的频率
Q' = [q₀·cos(θ₁) - q₁·sin(θ₁), q₀·sin(θ₁) + q₁·cos(θ₁), ...]

编译优化中,RoPE 可以有两种融合策略:

与 QKV 投影后融合:将 cos/sin 表预先计算并存入常量内存,在 kernel 中直接完成旋转。由于 θ 只与序列位置 m 和有关,可以按层预计算 [max_seq, head_dim/2] 大小的 LUT,推理时查表+乘加即可。

与投影前融合:更进一步,将旋转矩阵的 cos/sin 分量直接乘到 W_q、W_k 权重上——这等价于对权重做一个位置相关的线性变换。但这种融合牺牲了灵活性(无法跨位置共享权重),所以主流引擎选择第一种。

3.3 LayerNorm 参数折叠

LayerNorm 的 γ(scale)和 β(bias)可以部分折叠进相邻的线性层:

# 原始:y = γ * (x - μ) / √(σ²+ε) + β;  z = W @ y + b
# 折叠:将 γ 合并到 W 的列方向
W_fused = W * γ[num_col]   # 列方向缩放
b_fused = b + W @ β         # 偏置平移(若 β ≠ 0)
# 结果:y' = (x - μ) / √(σ²+ε);  z = W_fused @ y' + b_fused

这消除了推理时读取 γ/β 的显存访问,在 decode 阶段(batch=1)这种访存敏感场景收益可观。

四、静态内存规划:预分配的艺术

推理引擎不需要通用分配器——在引擎启动时,模型形状、最大序列长度、batch 容量都是已知的(或可配的)。这允许编译器做全生命周期的静态内存规划。

4.1 内存规划的基本模型

以 Llama-2-7B(hidden=4096, layers=32, heads=32, head_dim=128, intermediate=11008)为例,令 max_batch=256, max_seq=4096:

张量 形状 大小
权重(FP16) 全部参数 ≈ 14GB
KV Cache(每层/每token) [2, heads, head_dim] × 2 matrices 16 MB/token
KV Cache 总计 256 batch × 4096 seq × 16 MB ≈ 16 GB
激活(峰值) 取决于最大并行 batch+seq ≈ 2-4 GB
显存总计 — ≈ 34 GB

静态规划的核心是:确定每个张量在显存中的起始偏移,使得在推理过程中没有两个活跃期间不重叠的张量共享内存。

4.2 张量生命周期分析

编译器构建活跃区间:

Timeline ──────────────────────────────────────────►

Input Embedding    [──────────]
Attention Mask              [──────────]
KV Cache (layer 0)    [═══════════════════════════════]  持续到第一次使用
QKV Linear               [──]
RoPE                     [──]  
FlashAttention                 [──────────]
O Proj                       [──]
FFN                           [──────────]  
Add + LayerNorm                 [────]
KV Cache (layer 1)         [══════════════════════════]
... (重复 32 层)

[═══] 表示长生命周期(KV Cache),[──] 表示短生命周期(激活张量)。静态规划的做法是:

  1. 按活跃区间排序:尽量让短生命周期张量共享内存池
  2. KV Cache 和权重常驻,不可复用
  3. 激活内存池大小 = 同时活跃的最大激活张量之和

4.3 vLLM 的显存预分配策略

vLLM 的 CacheEngine 在启动时一次性分配最大容量的 KV Cache 显存:

class CacheEngine:
    def __init__(self, model_config, cache_config):
        self.gpu_memory = self._profile_available_memory()  # 实际显存
        self.num_gpu_blocks = self.gpu_memory // self._block_size()
        # 一个大 tensor,内部用 block table 索引
        self.gpu_kv_cache = torch.zeros(
            num_layers * 2,           # K and V
            self.num_gpu_blocks, 
            block_size,
            num_heads, 
            head_dim,
            dtype=torch.float16
        ).cuda()

这里的 block_size(通常 16 tokens/block)和 num_heads、head_dim 在编译期确定,所以 KV Cache 的 shape 完全静态。真正的动态性来自 block 的分配/释放,由 BlockTable 管理,而非底层 tensor 大小变化。

4.4 CUDA Graph 捕获与静态地址

最极致的静态优化是 CUDA Graph:将一次 forward 的所有 kernel 调用序列录制为固定图,推理时直接回放。CUDA Graph 要求所有 buffer 地址固定、所有 kernel 参数确定。

# 录制阶段
with torch.cuda.graph(static_graph, stream=capture_stream):
    static_output = model(static_input)   # 地址固定的 buffer

# 推理阶段(每次 batch 拿不同的 input buffer 地址?)
# 实际上需要每次 copy 新数据到预录制的 static_input buffer
static_input.copy_(dynamic_input)    # 唯一的动态操作
static_graph.replay()                 # 零 launch overhead

TensorRT-LLM 的 inflight batching 中,encoder 部分用 CUDA Graph 录制(shape 固定),decoder 部分用动态 planning(shape 随 batch_size 变化)——混合策略兼顾了 encode 阶段的极致效率和 decode 阶段的灵活性。

五、调度与并行:让 GPU 永不空闲

编译优化不只停留在静态变换——运行时的调度策略同样可以用"编译思维"建模。

5.1 连续批处理与抢占调度

传统的静态批处理(static batching)等所有请求处理完才一起返回,新请求在排队等待。连续批处理(continuous batching / iteration-level scheduling)每次迭代都可以插入/驱逐请求:

def continuous_batch_schedule(self, running_batch):
    # 每轮 iteration 检查
    for req in running_batch:
        if req.is_finished():
            free_blocks(req)
        if req.out_of_blocks():
            preempt(req)  # 驱逐到 CPU,释放 GPU blocks

    # 从 waiting queue 中调度新请求
    while self.gpu_blocks.available > new_request.required_blocks:
        new_req = waiting_queue.pop(0(self.gpu_blocks.available))
        running_batch.add(new_req)

    return running_batch

这种调度的"编译"体现在用预计算的成本模型来决定预驱逐阈值。vLLM 的 scheduling policy(FCFS with preemption)可视为一个静态配置:内存压力超过 threshold → 触发驱逐,驱逐选择 last-served 策略。

5.2 张量并行的图切分

当模型太大单卡放不下时,推理引擎需要在多卡间切分计算图。切分策略对性能有决定性影响:

切分维度 切分方式 通信量 适用场景
hidden dim 列并行(Column Parallel) AllReduce(QKV output) FFN 列投影
hidden dim 行并行(Row Parallel) AllReduce(O_proj output) FFN 行投影
num_heads 头并行 AllGather(KV after attention) Attention

TensorRT-LLM 的编译期会自动确定每个算子的并行策略,编译输出为多个 GPU kernel 和 NCCL 通信的交错序列。这种"自动并行"本质上就是编译器对计算图的切分 + 通信插入。

六、实战:从零构建融合 kernel

以下是一个完整的 CUDA kernel 示例,实现 SwiGLU + ResidualAdd 融合:

// swiglu_residual_kernel.cu
#include <cuda_fp16.h>

// FP16 SwiGLU: output = (silu(gate) * up) + residual
// 对应 llama.cpp 中 2/3 * hidden 维度的 SwiGLU
template <int BLOCK_SIZE = 256>
__global__ void swiglu_residual_kernel(
    const half* __restrict__ x,           // 输入 [seq_len, inter_dim]
    const half* __restrict__ residual,    // 残差 [seq_len, hidden_dim]
    half* __restrict__ output,            // 输出 [seq_len, hidden_dim]
    const half* __restrict__ down_weight, // [hidden_dim, inter_dim]
    int hidden_dim,
    int inter_dim
) {
    int row = blockIdx.x;   // token 维度
    int tid = threadIdx.x;  // hidden_dim 内的并行线程
    int half_inter = inter_dim / 2;  // gate 和 up 各一半

    __shared__ half gate_val[BLOCK_SIZE / 2];
    __shared__ half up_val[BLOCK_SIZE / 2];

    extern __shared__ half smem[];

    // 1. Gate 分支
    if (tid < half_inter) {
        float sum = 0.0f;
        for (int k = 0; k < hidden_dim; k += 32) {
            // 分块加载权重
            half w = down_weight[k * half_inter + tid];  // 简化的列主序
            half v = x[row * hidden_dim + k + (tid % 32)];
            sum += __half2float(__hmul(w, v));
        }
        // SiLU: x * sigmoid(x)
        float sig = 1.0f / (1.0f + expf(-sum));
        gate_val[tid] = __float2half(sum * sig);
    }

    __syncthreads();

    // 2. Up 分支
    // ... 与 Gate 类似(实际工程会用 VectorizedLoad)

    // 3. SwiGLU + Residual
    if (tid < hidden_dim) {
        float result = __half2float(output_load);
        // ... 完整的矩阵乘
        half res = residual[row * hidden_dim + tid];
        output[row * hidden_dim + tid] = __float2half(result + __half2float(res));
    }
}

// 启动配置
void launch_swiglu_residual(
    const half* x, const half* residual, half* output,
    const half* down_weight, int tokens, int hidden, int inter
) {
    int threads = 256;
    int blocks = tokens;
    int smem = (inter / 2) * sizeof(half) * 2; // gate + up
    swiglu_residual_kernel<<<blocks, threads, smem>>>(
        x, residual, output, down_weight, hidden, inter
    );
}

这段代码揭示了推理编译优化的精髓:算法、并行策略、显存层级三者必须协同优化。融合 SwiGLU + Residual 不只是写出代码,更需要考虑:

  • 权重是按 [out, in](行主序)还是 [in, out](列主序)排布
  • 共享内存够不够同时容纳 Q 块和 KV 块
  • 如果 hidden_dim=4096,一个 warp 32 线程能覆盖一行还是只覆盖 32 列
  • FP16 的尾数位只有 10-bit,累加过程中是否溢出

七、生态全景:主流推理引擎的编译策略

引擎 编译模型 融合策略 内存规划 并行支持
TensorRT-LLM TensorRT 编译 + 自定义 plugin 极致融合(FlashAttention + GEMM + Residual)全匹配 静态预分配 + CUDA Graph TP + PP
vLLM PyTorch eager + PagedAttention(动态) torch.compile( inductor 后端)自动融合 Paged KV Cache(块级别稀疏) TP
llama.cpp / GGML 手写融合 kernel per arch 硬编码融合(SwigLM + KV copy + FFN) 全静态预分配 TP(实验性))
MLC-LLM TVM 编译优化 Ansor 自动调优融合算子 静态规划 + KV Cache 换出 TP
tgi (Hugging Face) Python + Rust 混合 Selective fusion(FlashAttention + GEMM) Paged KV Cache TP + Continuous Batching

核心差异在于"自动" vs "手写":TensorRT-LLM 和 MLC-LLM 依赖自动调优来搜索最优 kernel;vLLM 依赖 torch.compile 在运行时融合;llama.cpp 用工程师的手写 kernel 来保证每种硬件上的性能上限。不同路径的 trade-off 本质上是 编译灵活性 vs 峰值性能 的取舍。

八、前沿方向

8.1 自动算子融合搜索

TVM Ansor 和 Meta 的 AOT Inductor 正在做编译期自动融合:给定计算图和硬件描述,搜索最优的融合矩阵分块 + 线程映射策略。2024 年的进展已经能在 Hopper(H100)上自动匹配手写 kernel 的 95% 性能。

8.2 混合精度计算图编译

Blackwell 架构(B100/B200)引入了 microscaling 格式(MXFP4/MXFP6),推理编译器需要在图上自动决定哪些层用 FP8、哪些用 MXFP4、哪些保持 FP16,使得精度损失 < 0.1% 且计算速度最大化。这类似于硬件综合中的资源分配问题。

8.3 投机解码(Speculative Decoding)的编译优化

投机解码中,draft model 和 target model 的计算图需要合并调度。LLM.d(AuxLossFree)和 DeepMind Sequoii 等方法通过将 draft 的推理提前到 target forward 的流水线气泡中,实现编译期优化的指令级并行(ILP)。

8.4 WASM 边缘推理编译

WAMR 和 wasm Micro Runtime 正在探索用 WASI-NN API 将推理 engine 编译为 WASM。这意味着整个推理引擎的计算图编译 + kernel 融合要让位于 WASM IR 变换 ——一层新的"编译的编译"。

九、总结

推理引擎的编译优化不是某一项孤立技术,而是一个系统工程:

  • 算子融合 消除显存带宽瓶颈
  • 常量折叠 消除冗余计算
  • 静态内存分配 消除运行时碎片
  • CUDA Graph 消除 CPU launch overhead
  • 自动并行插入 释放多卡算力

当 PagedAttention 解决了显存外部碎片之后,最后这张拼图就是"让每次推理的每一步都精确落在 GPU 的算力、带宽、延迟的最优平衡点上"。而这一点,靠的不是更快的硬件,靠的是编译器的计算图优化。

优秀的推理引擎不只是"跑模型"——它是一台编译器,把 GPU 的硬件语义精确映射到 transformer 的数学语义上。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部