yuzheng310
← 全部笔记

GPU 算子与执行优化:从 26 到 100 token/s 的推理引擎破茧之路

使用 Nsight Systems 定位同步、传输与发射瓶颈,集成 FlashInfer 分页注意力、融合算子与 Decode CUDA Graph 的全景深度复盘。

作者:肖小云(yuzheng310) | 发布时间:2026年9月15日
副标题:使用 Nsight Systems 定位同步、传输与 Kernel 启动开销,集成 FlashInfer 分页注意力、融合 Norm/RoPE、按需 LM Head 与 Decode CUDA Graph
基准对象:Qwen3-4B BF16 在单卡 NVIDIA RTX 4090 上的端到端 Serving 执行路径
源码仓库yuzheng310/qwen3-runtime


在许多关于大模型推理引擎的技术讨论中,人们往往容易陷入一种“算子神话”式的归因幻觉:

“只要接上 FlashAttention 或 FlashInfer,吞吐就能直接飞起。”
“性能上不去,肯定是因为没有写手写定制的高性能 Triton/CUDA GEMM 算子。”

然而,当你真正从第一性原理出发,使用 NVIDIA Nsight Systems 抓取一份真实的自回归生成(Autoregressive Decode)Trace 时,冰冷残酷的系统真相会瞬间击碎所有想当然的浪漫幻想:

在单请求或低并发自回归解码阶段,GPU 绝大多数时间根本不是在算力爆炸的矩阵乘法中飞驰,而是在 CPU-GPU 隐式同步、碎裂的主机小张量传输(Memcpy)、以及空转等待 Kernel 启动的 CPU-GPU 气泡(Bubbles)中绝望挣扎。

本文将全景式复盘我们在开源单卡推理运行时 Qwen3 Agentic Rollout Runtime 中的完整性能破局之路。我们将记录如何从一个仅有 26.34 token/s 的原生 serving 阶段基准出发,通过 Nsight Systems 层层剥开系统瓶颈,通过 Host 传输剥离、FlashInfer 分页注意力批量化、动静分离 Decode CUDA Graph、36 层融合 Norm/RoPE 算子、按需 LM Head 以及上下文感知 Split-KV,最终将单流解码推向 100.40 token/s(3.81×),并在六类标准化批处理负载下达到工业级基准 vLLM 0.27.1 的 94.7%~97.7%

这是一篇献给每一个渴望理解现代 LLM 推理引擎真实执行路径的系统工程师的技术长文。


目录

  1. 宏观战场与终局性能全景:3.81× 加速究竟从何而来?
  2. Nsight Systems 破除迷雾:Attention 居然只占 7.4% 的内核时间?
  3. 第一战:主机端开销与隐式同步风暴:从 26.34 到 30.58 token/s
  4. 第二战:消灭 36,864 次碎片循环——FlashInfer 分页注意力批量化:从 32.84 到 40.08(并发吞吐暴涨 290%)
  5. 第三战:捅破 CPU 发射墙——动静解耦的 Decode CUDA Graph:从 40.08 跃升至 76.92 token/s
  6. 第四战:36 层 Transformer 的隐形失血——融合 Norm 与 RoPE:从 76.92 稳步推进至 95.93 token/s
  7. 第五战:大词表投影的代价——按需 LM Head 与分块 Prefill 瘦身
  8. 第六战:长上下文维度的反转——CUDA Graph 的定长 CTA 陷阱与 Split-KV 动态回退
  9. 对齐生产级基线:与 vLLM 0.27.1 的公平对决与评测口径
  10. 第一性原理总结:推理优化的本质是消除不变量的反复协商

第一部分:宏观战场与终局性能全景

在深入细节前,我们先建立对整体性能演进的高层心智模型。

我们的目标模型是 Qwen3-4B-Instruct(BF16 精度,36 层 Transformer,Hidden Size 2560,32 Q Heads,8 KV Heads 即 GQA 架构,Vocab Size 151,936),运行环境为单张 NVIDIA RTX 4090(24GB VRAM,Ada Lovelace 架构)。

为了衡量真实的多轮 Agent 与 Serving 负载,我们定义了涵盖单流延迟、中高并发吞吐、长 Prompt Prefill 与长上下文 Decode 的 6 类标准化负载(Workloads A~E):

负载代号典型场景Prompt 长度生成长度并发度 (Concurrency)初始 Baseline终局优化 (d1c479d)加速比对齐 vLLM 0.27.1 比例
Workload A单流极低延迟解码512128126.34 tok/s100.40 tok/s3.81×96.4%
Workload B中并发交互服务2561288111.90 tok/s693.26 tok/s6.20×97.7%
Workload C高并发高吞吐服务25612816132.20 tok/s1,212.83 tok/s9.17×95.4%
Workload D长输入 Prefill 约束2048128121.97 tok/s57.33 tok/s2.61×95.1%
Workload E超长上下文解码819264116.87 tok/s48.70 tok/s2.89×94.7%
[阶段终局端点验证]
100.398328 / 26.343931 = 3.811x (吞吐提升 +281.1%)

💡 极其关键的口径警示(Evidence Rule)
必须强调:3.81× 是系统工程中多项优化环环相扣累积后的端到端跃迁。任何试图将 3.81× 的功劳全部归因于某一个孤立算子(例如“用了 FlashInfer 提升了 3.8 倍”或“用了 CUDA Graph 提升了 3.8 倍”)的说法,在严谨的工程事实面前都是站不住脚的伪命题。Amdahl 定律决定了:每拔掉一根钉子,系统就会立刻暴露下一个全新瓶颈。


第二部分:Nsight Systems 破除迷雾——Attention 居然只占 7.4%?

在项目的最初版本中,系统功能完全正确:实现了物理分页 KV Cache 池、请求状态机和按 Token 预算的连续批处理。然而实测单请求解码输出仅有 26.34 token/s(相当于每生成一个 Token 需要耗费 38 毫秒!)。

我们立刻使用 Nsight Systems(nsys profile)对 Workload A 抓取了毫秒级的底层 CUDA API 与 GPU Kernel 执行分布。

导出的真实数据让团队所有人大吃一惊:

1. CUDA API 耗时分布(Wall-Clock 时间消耗)

API 调用类型耗时占比 (Time %)调用总次数 (Calls)单次中位耗时诊断结论
cudaMemcpyAsync56.3%14,180 次~7.5 µs最大的全剧耗时黑洞:充斥着大量主机与设备间的细碎搬运
cudaLaunchKernel39.1%406,900 次~0.8 µs极其密集的 Kernel 启动请求(平均每 step 发射上千次)
cudaStreamSynchronize2.8%5,540 次变异极大强制流水线断流的致命同步点

在整个运行期的 CUDA API 侧,搬运数据与发射核函数占掉了 95.4% 的时间

2. GPU Kernel 物理执行时间分布

Kernel 算子类型执行耗时占比实例数 (Instances)物理本质分析
cuBLAS GEMV BF16 (gemvx)54.6%~18,000Decode 阶段特征:Batch=1,矩阵乘向量(GEMV),极度访存受限
PyTorch Mem-Efficient SDPA7.4%4,608Attention 计算本身仅占 GPU 计算耗时的零头!
Elementwise Copy / Cast6.3%~65,000碎裂的类型转换与非连续内存浅拷贝
Index Elementwise (Paged Gather/Scatter)3.1%~18,000自研分页逻辑中的物理插槽搬运

核心洞察
注意力核函数从来不是自回归解码慢的第一罪魁祸首。
自回归解码是典型的 GEMV 访存受限 + 碎片拷贝 + 主机端开销(Host Overhead) 混合问题。如果我们在一开始就盲目手写替换 Attention Kernel,最多只能在 7.4% 的边际上微调,整体加速甚至超不过 5%!


第三部分:第一战:主机端开销与隐式同步风暴

顺着 Nsight Systems 的证据,我们首先追查:为什么会有 14,180 次小规模 Memcpy 和 5,540 次 Stream 同步?

深入代码走查,我们定位到了两个极其隐蔽的性能杀手:

1. 采样层的 DtoH 数据逃逸(Logits .tolist()

在每一步自回归生成的采样末端,为了获取生成的 Token ID,原生代码写了类似逻辑:

# 致命陷阱:为了在 Python 端做 argmax 或判断停止词,隐式将整个词表拉回 CPU
logits = model(tokens)
next_token = torch.argmax(logits, dim=-1).tolist() # 触发同步 DtoH 拷贝!

Vocab Size 为 151,936。把一个包含 15 万浮点数的 Tensor 拷回 CPU 主机,不仅触发了 cudaMemcpyAsync,更导致 Python 解释器在 .tolist() 上调用同步阻塞,彻底击碎了 GPU 驱动的异步执行流水线!

解法:全面推行设备端闭环采样(On-Device Sampling)torch.argmax 和 Top-p/Top-k 过滤必须严格在 GPU 上完成,仅将最终标量 Token ID 以异步方式写出,非必要绝不将整个 Logits 张量拉回主机。

2. 逐层重复构造的分页张量(Per-Layer Block Table Tensor Allocation)

在 36 层 Transformer 的每一层,为了将注意力定位到物理 KV 页,层内代码都在实时创建 torch.tensor(block_table, device="cuda")。 36 层 × 128 步 = 4,608 次动态主机张量创建与 H2D 拷贝!

解法元数据提升(Hoisting)。在整个 Forward 启动的最外层,仅构造一次设备端页表张量,36 层 Transformer 共享同一个不可变张量引用。

[优化成效:Primary Investigation]
Memcpy 调用次数:14,180 次 -> 9,700 次 (-32%)
Stream 同步次数:5,540 次 -> 1,060 次 (-81%)
Workload A 吞吐:26.34 -> 30.58 tok/s (+16.1% 纯净提升)

第四部分:第二战:消灭 36,864 次碎片循环——FlashInfer 分页注意力批量化

清除了浅层的主机同步后,系统的并发扩展瓶颈浮出水面。

当我们将并发度增加到 8(Workload B)时,Nsight Systems 呈现出了荒谬的一幕: 整个 Trace 中充斥着高达 36,864 次 fmha_cutlassF 内核调用!

计算公式:
8 (并发请求) × 128 (步数) × 36 (Transformer 层数) = 36,864 次 Attention Kernel 调用!

为什么标准 PyTorch SDPA 会如此脆弱?

因为原生 PyTorch 的 SDPA(Scaled Dot-Product Attention)只接受连续张量。面对不连续的物理分页 KV Cache,原生代码不得不为每一个独立的请求分别执行 Gather、切片、调用单次 SDPA、再写回! 这是一种与请求数完全线性耦合的“伪批处理”。并发度一高,GPU 瞬间被海量微小 Kernel 淹没。

引入 FlashInfer:统一 NHD 分页张量绑定

我们重构了注意力后端,全面引入 FlashInfer 的分页注意力加速库。

FlashInfer 的核心优势在于其支持批处理统一调度(Batched Paged Decode/Prefill)

  1. 零搬运原位访问:将全局物理 KV Cache 池(NHD 布局:[num_blocks, num_heads, block_size, head_dim])直接绑定到底层 CUDA 算子;
  2. 一次发射,全批处理完成:不管当前 Batch 内有 1 个、8 个还是 16 个请求,每一层 Transformer 在每一步 Decode 时,仅发射单个聚合的 BatchDecodeWithPagedKVCacheWrapper Kernel
# FlashInfer 包装器调用范式
self.decode_wrapper.begin_forward(
    paged_kv_indptr,
    paged_kv_indices,
    paged_kv_last_page_len,
    num_qo_heads,
    num_kv_heads,
    head_dim,
    page_size=16
)
attn_out = self.decode_wrapper.forward(q, paged_kv_data)
self.decode_wrapper.end_forward()

FlashInfer Paged Attention 架构示意图

这一改造彻底解决了注意力算子的并发扩展危机:

[优化成效:FlashInfer Paged Attention]
Workload B SDPA 调用数:36,864 次 -> 0 次
Workload A (单请求):32.84 -> 40.08 tok/s (+22.1%)
Workload B (并发 8):111.90 -> 294.99 tok/s (+163.7%)
Workload C (并发 16):132.20 -> 515.87 tok/s (+290.2% 巨幅暴涨!)

在并发场景下,单是消除算子碎片化和重复调度,就直接换来了接近 4 倍 的吞吐飞跃!


第五部分:第三战:捅破 CPU 发射墙——动静解耦的 Decode CUDA Graph

当 FlashInfer 将 Attention 压缩到仅占 GPU 执行时间的 2% 左右时,我们遇到了整个项目中最坚固的一堵高墙:

CPU 发射墙(CPU Launch Bound)

看此时 Workload A(单流)的 Nsight 概况:

  • GPU 算力利用率依然极低,SM 流式处理器经常陷入大面积饥饿;
  • cudaLaunchKernel 霸占了 65.9% 的 CUDA API 耗时;
  • 在 129 个推理 step 里,Python 解释器与 PyTorch 运行时向 GPU 累计发射了超过 137 万次 底层内核调用!
  • 每一个 GEMV 算子本身在 GPU 上只需要运行 24 微秒,但 CPU 准备环境、分发参数并下发驱动指令却需要 35 微秒。CPU 跑得比 GPU 慢,GPU 每算几微秒就必须停下来等 CPU 发送下一条指令!
[Eager 模式下的流水线气泡]
CPU:  |--Launch GEMV--|--Launch Bias--|--Launch Norm--| ...
GPU:        |--Run--|   (气泡)  |--Run--|   (气泡)  |--Run--|

终极解法:CUDA Graph 捕获与重放(Capture & Replay)

CUDA Graph 允许我们将整个前向图的所有节点、依赖关系和内存参数一次性录制固化为一个静态执行图。 后续执行时,CPU 只需要向驱动发送一条单一指令:cudaGraphLaunch。驱动层直接接管整张图的硬件并发执行,发射开销瞬间从几毫秒压低到纳秒级!

[CUDA Graph 重放模式]
CPU:  |--Graph Launch (单次驱动调用)--|
GPU:  |--Run GEMV--Run Bias--Run Norm--Run GEMV--Run Attention... (无缝流式衔接)--|

动静解耦(Dynamic-Static Decoupling):攻克 FlashInfer 捕获崩溃

在业界实践中,许多人尝试给 LLM Decode 打 CUDA Graph 都会遭遇崩溃报错,这是因为大多数推理库存在动态显存申请非安全元数据变更

我们设计了动静解耦架构

  1. 动态规划剥离在图外(Outer Plan):分页页表的寻址偏移(Indptr)、物理页索引(Indices)等动态元数据,在图捕获外部由 CPU/主机逻辑计算并拷贝到固定的预分配缓冲区;
  2. 静态执行封装在图内(Inner Graph):进入图捕获区域时,模型输入的 Tensor 地址、输出 Tensor 地址、中间隐藏层与 KV Cache 缓冲区的显存虚拟地址完全冻结
  3. 严格预热(Warmup Execution):在正式捕获图之前,必须使用完全相同的输入尺寸和固化地址执行数次预热,确保 cuBLAS 启发式算法(Heuristic Selection)与 FlashInfer 内核完成工作区固定;
  4. 单次重放与静态词表拷贝:仅捕获 Model Forward + Logits Static Buffer,彻底封死内部动态分支。
# Decode CUDA Graph 动静解耦核心模式
class DecodeGraphRunner:
    def capture(self, batch_size):
        # 1. 预分配固定虚拟地址的静态 IO 缓冲区
        self.static_input_ids = torch.zeros(batch_size, dtype=torch.long, device="cuda")
        self.static_positions = torch.zeros(batch_size, dtype=torch.long, device="cuda")
        
        # 2. 真实上下文预热,固定 cuBLAS / FlashInfer 执行状态
        stream = torch.cuda.Stream()
        with torch.cuda.stream(stream):
            for _ in range(3):
                self.model(self.static_input_ids, self.static_positions)
        stream.synchronize()
        
        # 3. 开启图录制
        self.graph = torch.cuda.CUDAGraph()
        with torch.cuda.graph(self.graph, stream=stream):
            self.static_logits = self.model(self.static_input_ids, self.static_positions)
            
    def replay(self, dynamic_input_ids, dynamic_positions):
        # 主机端仅负责将动态数值填入固化的静态缓冲区,不改变 Tensor 指针
        self.static_input_ids.copy_(dynamic_input_ids)
        self.static_positions.copy_(dynamic_positions)
        # 单次发射整图
        self.graph.replay()
        return self.static_logits

成效是震撼性的:

[优化成效:Decode CUDA Graph]
内核启动次数:从 1,370,000 次断崖式下跌至 19,971 次(降低 98.5%!)
CUDA Graph 单指令重放占比:替代了原本超 95% 的动态发射
Workload A (单流解码):从 40.08 tok/s 飙升至 76.92 tok/s (+91.9%!)
Workload B (并发 8):从 294.99 tok/s 飙升至 526.66 tok/s (+78.5%)
Workload C (并发 16):从 515.87 tok/s 飙升至 829.66 tok/s (+60.8%)

单流吞吐几乎在瞬间翻倍!这就是消除主机发射墙的威力。


第六部分:第四战:36 层 Transformer 的隐形失血——融合 Norm 与 RoPE

当 CUDA Graph 抹平了算子发射墙后,GPU 的物理执行流终于变成了连续的算子流水线。此时,真正的算子效率开始显现。

在 Qwen3-4B 模型中,共包含 36 个 Transformer 堆叠层。每一层都包含:

  • Attention 前的 RMSNorm
  • 投影后的 Q/K RMSNorm
  • NeoX 风格的 RoPE 旋转位置编码
  • MLP 块前的 RMSNorm
  • SwiGLU 激活函数(silu(gate) * up

在早期实现中,这些算子是用原生 PyTorch 表达式书写的:

# 原生 RMSNorm:一条由 5 个小内核组成的算子链!
def native_rmsnorm(x, weight, eps=1e-6):
    # 1. pow -> 2. mean -> 3. add -> 4. rsqrt -> 5. mul
    return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + eps) * weight

每个 token 在通过这 36 层时,光是 RMSNorm 就要反复触发 36 × 5 = 180 次 微小内核的访存读写,且因为中间要维护数值精度被自动提升到了 Float32。

我们逐一将其替换为成熟的低级融合内核:

1. FlashInfer Fused RMSNorm

将全流程压缩为单个专门的 GPU 核函数:线程块在 SRAM 内直接加载 BF16 数据,使用寄存器完成均方根与倒数平方根规约,单次写回。

  • 隔离测试:36 层 RMSNorm 的端到端耗时由 10.4 ms 缩减至 1.67 ms
  • 全局收益:Workload A 吞吐从 76.92 推进至 87.32 tok/s (+13.5%)

2. FlashInfer Fused NeoX RoPE

原生的 RoPE 切片与拼接(torch.cat([-x2, x1]))会强制破坏显存连续性,触发隐式临时内存重排。我们引入 apply_rope_pos_ids(interleave=False) 原位融合旋转算子:

  • 隔离测试:36 层 RoPE 耗时由 5.06 ms 骤降至 0.36 ms(整整压缩了 14 倍!);
  • 全局收益:Workload A 吞吐从 87.32 推进至 95.93 tok/s (+9.9%)

3. SwiGLU 算子融合与 Fused Add-Norm

进一步使用 FlashInfer 的 silu_and_mul 消除 Gate 与 Up 投影之间的临时向量驻留;在可能时将残差连接(Residual Add)与 Norm 合并(fused_add_rmsnorm)。

每一层虽然只抠出了几十微秒,但乘以 36 层以及全自回归序列后,单流解码顺利跨越了 96 token/s 大关!


第七部分:第五战:大词表投影的代价——按需 LM Head 与分块 Prefill 瘦身

在优化完自回归 Decode 后,我们在长 Prompt Prefill(Workload D)中又发现了反常的耗时毛刺。

通过 Nsight 抓取 Prefill 阶段的执行图,我们发现了一个长达 9.46 毫秒 的巨型 GEMM 内核正在独占整块芯片。

这是怎么回事? 答案是 LM Head(语言模型词表输出投影)

Qwen3-4B 的词表高达 151,936。在一次包含 2,048 个 Token 的长 Prompt Prefill 中:

  • 隐藏层尺寸为 [2048, 2560]
  • LM Head 投影权重尺寸为 [151936, 2560]
  • 原生实现直接做矩阵乘法:logits = hidden_states @ lm_head.weight.T
  • 计算出的 Logits 矩阵尺寸高达 [2048, 151936],不仅瞬间吃掉 622 MB 的临时显存,更耗费了漫长的 9.5 毫秒!

然而在自回归生成中,Prefill 阶段的输入 Token 是已知的上下文,我们根本不需要前 2047 个位置的 Logits!我们仅仅需要最后一个位置(Last-Token)的 Logits 来预测下一个生成的词!

按需投影优化(Last-Token Slicing)

我们在模型结构中实现了按需 LM Head

def forward(self, hidden_states, sampling_positions=None):
    # 仅在需要采样或计算 Logits 的位置执行庞大的词表线性映射
    if sampling_positions is not None:
        # 只取出需要采样的 Token 向量(例如最后一个 token:[1, 2560])
        hidden_states = hidden_states.index_select(0, sampling_positions)
    elif self.is_prefill and not self.return_all_logits:
        # Prefill 默认仅切片最后一行!
        hidden_states = hidden_states[-1:, :]
        
    logits = F.linear(hidden_states, self.lm_head_weight) # 从 9.51ms 骤降至 0.81ms!
    return logits

成效立竿见影:

  • 全词表 GEMM 计算时间从 9.51 ms 缩减至 0.81 ms
  • Workload D 的首字生成延迟(TTFT)从 0.122 秒压缩到 0.114 秒
  • 在 Chunked Prefill 中,未完成整个 Prompt 预算的中间分块彻底免除 Logits 投影,大幅降低了计算突刺。

第八部分:第六战:长上下文维度的反转——CUDA Graph 的定长 CTA 陷阱与 Split-KV 动态回退

在推进至长上下文测试(Workload E:Prompt 8192,Output 64)时,我们遇到了极其罕见的性能反转现象:

在上下文达到 8K 时,开启了 CUDA Graph 的 Decode 居然比不开启 CUDA Graph 的 Eager 模式慢了将近 50%!

[8K 长度下的单步解码耗时 TPOT 实测]
Eager 模式 (开启 Split-KV):       11.88 ms
CUDA Graph 模式 (固定静态图):     17.82 ms (严重倒退!)

为什么 CUDA Graph 在长文本上会失效?

这触及了 GPU 体系结构中极度深水区的 CTA(Cooperative Thread Array,线程块)网格调度 机制:

  1. 短上下文(Short Context):Key/Value 历史很短,Attention 是典型的带宽充足、计算轻量场景。此时瓶颈在发射延迟,CUDA Graph 消除发射开销带来巨大提升;
  2. 长上下文(Long Context,8K+):Key/Value 跨越成百上千个物理块。此时单个请求的 Query 需要与 8,192 个历史 Token 做点积规约。
    • 为了喂饱 GPU 的所有 SM,必须采用 Split-KV 算法:将一个 Head 的 KV 序列切成多份,由多个不同的 Thread Block 并发计算局部 Softmax,最后再归约;
    • 然而,CUDA Graph 录制时锁定了固定的 CTA 网格尺寸与执行流,无法在图内部动态根据当前的序列长度调整 Split-KV 的切分份数与并发块数量
    • 结果就是:CUDA Graph 内部只能使用固定退化的单 CTA 串行扫完整整 8K 历史,导致 GPU 108 个 SM 中的绝大部分陷入闲置(严重欠载欠饱和,Under-Occupancy)!

规则裁决:上下文感知的主动回退策略(Context-Aware Split-KV Bypassing)

我们没有盲目坚持全场景 Graph,而是建立了第一性原理驱动的分级接管策略:

def decode_step(self, batch):
    max_context_len = batch.get_max_context_len()
    
    # 严格的边界判断:短上下文利用 Graph 消灭发射延迟;长上下文回退到 Eager 释放 Split-KV 硬件并发
    if max_context_len <= 2048 and self.cuda_graph_runner.is_ready():
        return self.cuda_graph_runner.replay(batch)
    else:
        # 回退至 Eager FlashInfer Split-KV 模式,充分调度全部物理 SM
        return self.eager_forward_split_kv(batch)

通过设立 2048 上下文分界门,我们在长上下文 Workload E 上直接把吞吐从 38.30 tok/s 拔高至 48.70 tok/s(+28.2%),彻底抹平了长文本性能鸿沟。


第九部分:对齐生产级基线:与 vLLM 0.27.1 的公平对决与评测口径

在很多性能汇报中,最容易出现的学术不端或虚假繁荣,就是拿有缓存的自己去打无缓存的对手,或者拿无精度保障的快去打正确严格的实现

为了确保我们的优化是坚实、可复现且经得起同行审计的,我们建立了严苛的可证伪对照基准(Falsification Standard)

1. 对照组的语义修正(Fair vLLM Semantics)

在初期对标 vLLM 时,我们发现 vLLM 在长 Prompt 上的测试数据奇高。深入其源码发现:vLLM 0.27.1 默认开启了 enable_prefix_caching=True。在基准测试的 Warmup 轮次中,相同的 Prompt 已经被其缓存在显存中,后续正式测量全部命中了缓存! 我们果断在对照组中显式关闭了前缀缓存,确保两方都在纯冷启动、无任何历史作弊的真实条件下竞争。

2. 贪婪采样逐 Token 一致性校验(Hugging Face Bit-Level Parity)

在每一次算子替换(无论是 FlashInfer、Fused Norm、RoPE 还是 CUDA Graph)之后,我们都会启动自动化对齐测试套件:

  • 输入 128 Token 复杂语料;
  • 对比 Hugging Face 原生实现的 FP32/BF16 输出与本运行时的输出;
  • 要求连续贪婪生成的 64 个 Token ID 必须与 Hugging Face 达到 100% 精确比特级一致,Logits 最大相对误差严格控制在 1e-3 以内。绝不为了所谓的速度牺牲推理语义正确性。

最终性能对标汇总(Qwen3-4B BF16 / RTX 4090)

在最终版本(Commit d1c479d)下,我们在六项典型基准测试中全面逼近了经历工业级深度打磨的 vLLM 0.27.1:

[与 vLLM 0.27.1 吞吐对比对齐比例]
Workload A (单流解码):        100.40 vs 104.19 tok/s  -->  96.4%
Workload B (并发 8 解码):     693.26 vs 709.58 tok/s  -->  97.7%
Workload C (并发 16 解码):  1,212.83 vs 1,271.13 tok/s -->  95.4%
Workload D (2K 长输入):        57.33 vs  60.31 tok/s  -->  95.1%
Workload E (8K 上下文):        48.70 vs  51.46 tok/s  -->  94.7%

我们在没有引入复杂的 C++ 大一统服务框架前提下,依托高度精炼的 Python 运行时架构与精细的底层执行编排,达到了工业级推理框架 95% 以上的核心吞吐表现


第十部分:第一性原理总结:推理优化的本质是消除不变量的反复协商

回顾从 26.34 到 100.40 token/s 的完整历程,如果用一句话概括这 3.81 倍加速背后的工程哲学,那就是:

大模型推理优化的本质,是在硬件流水线中坚决消除“已知不变量”的反复动态协商。

  • 在主机与设备之间:页表映射和张量形状在整步生成中是不变量,因此绝不应在每层之间反复分配 Tensor 与往返搬运;
  • 在 CPU 与 GPU 之间:自回归解码的单步算子拓扑图是不变量,因此绝不应让 Python 解释器在每一个 step 里重复发射 1000 次相同的指令,必须用 CUDA Graph 将其凝固;
  • 在显存金字塔之间:中间激活值与其反复读写 HBM,不如直接锁定在片上 SRAM 中一次算完;
  • 在词表投影之间:只有最后一个 Token 的输出概率是有用变量,前两千个位置的词表投影是不折不扣的冗余开销。

性能分析不是玄学,也不是无脑调包。拿着 Nsight Systems 这面照妖镜,回归算力墙、显存带宽与硬件状态机的第一性原理,找到真正的制约点并施以手术刀式的精确切除——这正是底层系统工程最迷人、最硬核的魅力所在。


参考文献与延伸源码

  1. Qwen3 Runtime 项目工程仓库
  2. Nsight Systems 性能剖析记录与复盘
  3. FlashInfer: High-Performance GPU Kernel Library for LLM Serving
  4. CUDA Graph 官方编程指南
  5. vLLM: Easy, Fast, and Cheap LLM Serving for Everyone
返回笔记列表