GPU 利用率才 10%?——LLM 推理到底慢在哪?

0 阅读15分钟

GPU 利用率才 10%?——LLM 推理到底慢在哪?

你花几万买的显卡,90% 的时间在等数据搬运。 本篇从一段最朴素的生成代码出发,亲手测量 LLM 推理到底慢在哪——答案可能和你想的不一样。

前置依赖:无(系列第一篇)
本篇代码:约 100 行 | 阅读时间:约 20 分钟

0. 环境准备

import warnings
warnings.filterwarnings("ignore")
%config InlineBackend.figure_formats = ['svg']

import torch
import time
import numpy as np
import matplotlib.pyplot as plt

from transformers import AutoTokenizer, AutoModelForCausalLM

# 中文字体设置(如果需要显示中文图表)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

print(f"PyTorch 版本: {torch.__version__}")
print(f"CUDA 可用: {torch.cuda.is_available()}")
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"使用设备: {device}")
PyTorch 版本: 2.5.1
CUDA 可用: False
使用设备: cpu

1. 问题引入:为什么 model.generate() 这么慢?

你一定用过 HuggingFace 的 model.generate() 来生成文本。体验大概是这样的:

  • 输入一段 prompt,等……等……等……终于一个字一个字蹦出来了
  • 想同时处理 10 个请求?要么显存爆了,要么慢得离谱
  • GPU 利用率一看,才 10%?这块几万的卡就这?

这些问题不是你代码写得不好,而是 LLM 推理本身有结构性的性能瓶颈

vLLM 就是为了解决这些问题而生的。但在理解 vLLM 之前,我们得先搞清楚:瓶颈到底在哪里?

让我们从最朴素的方式开始——用 Qwen3-0.6B 跑一次文本生成,亲眼看看问题出在哪。

加载一个小模型

我们用 Qwen3-0.6B(约 6 亿参数)来做实验。它虽然不大,但推理流程和 LLaMA-70B 是完全一样的——瓶颈的本质不会因为模型大小而改变。

# 加载 Qwen3-0.6B 模型和分词器
model_name = "Qwen/Qwen3-0.6B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float32).to(device)
model.eval()

print(f"模型: {model_name}")
print(f"参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M")
print(f"层数: {model.config.num_hidden_layers}, 隐藏维度: {model.config.hidden_size}, 注意力头: {model.config.num_attention_heads}")
`torch_dtype` is deprecated! Use `dtype` instead!


模型: Qwen/Qwen3-0.6B
参数量: 596.0M
层数: 28, 隐藏维度: 1024, 注意力头: 16

最朴素的文本生成

先用 model.generate() 跑一下,感受一下速度。

# 最朴素的生成方式
prompt = "The future of artificial intelligence is"
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
generate_length = 100

# 计时
start = time.time()
with torch.no_grad():
    output = model.generate(
        input_ids,
        max_new_tokens=generate_length,
        do_sample=False,  # greedy decoding,结果确定
    )
elapsed = time.time() - start

generated_text = tokenizer.decode(output[0], skip_special_tokens=True)
tokens_per_sec = generate_length / elapsed

print(f"Prompt: {prompt!r}")
print(f"生成了 {generate_length} 个 token,耗时 {elapsed:.2f} 秒")
print(f"速度: {tokens_per_sec:.1f} tokens/s")
print(f"\n生成结果:\n{generated_text}")
The following generation flags are not valid and may be ignored: ['temperature', 'top_p', 'top_k']. Set `TRANSFORMERS_VERBOSITY=info` for more details.
The attention mask is not set and cannot be inferred from input because pad token is same as eos token. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.


Prompt: 'The future of artificial intelligence is'
生成了 100 个 token,耗时 36.00 秒
速度: 2.8 tokens/s

生成结果:
The future of artificial intelligence is a topic that has sparked considerable debate and speculation in the scientific community. As we delve into this subject, it's essential to understand the multifaceted nature of AI, its potential applications, and the ethical considerations that accompany these advancements. The narrative presented here is a blend of scientific inquiry and philosophical reflection, aiming to provide a comprehensive yet engaging exploration of the topic.

The first step in understanding the future of AI is to recognize that AI is not a singular entity but a complex system of interconnected technologies.

2. LLM 推理的两个阶段:Prefill 与 Decode

看起来 model.generate() 就是一个函数调用,但其实内部发生了两件截然不同的事情

2.1 Prefill(预填充)阶段

当你把 prompt 喂给模型时,模型需要一次性处理所有 prompt token。这个阶段叫 Prefill

  • 输入:整个 prompt(比如 6 个 token)
  • 计算:一次前向传播,所有 token 并行计算
  • 输出:最后一个位置的 logits(用来生成第一个新 token)+ 所有位置的 KV Cache

这个阶段的特点是:计算密集(compute-bound),GPU 的算力能被充分利用。

2.2 Decode(解码)阶段

生成第一个新 token 之后,接下来的 token 只能一个一个生成——因为第 N+1 个 token 依赖第 N 个 token 的结果。这个阶段叫 Decode

  • 输入:刚生成的 1 个 token
  • 计算:一次前向传播,但只算 1 个 token 的 Attention
  • 输出:下一个 token 的 logits

这个阶段的特点是:访存密集(memory-bound),每一步都要从显存读取整个模型的权重,但只做很少的计算。

Prefill 阶段(并行处理):
┌─────────────────────────────────────┐
│  [The] [future] [of] [AI] [is] [→]  │  ← 6 个 token 一次算完
└─────────────────────────────────────┘
                  ↓ 得到第 1 个新 token

Decode 阶段(逐个生成):
┌───────┐   ┌───────┐   ┌───────┐
│ [a]→  │ → │ [new]→│ → │ [era]→│ → ...  ← 每次只算 1 个 token
└───────┘   └───────┘   └───────┘

关键洞察:生成 100 个 token,Prefill 只做 1 次,但 Decode 要做 100 次。虽然每次 Decode 的计算量小,但次数多、每次都要搬运大量数据,所以 Decode 阶段往往才是真正的性能瓶颈

眼见为实:手动拆解 Prefill 和 Decode

让我们不用 model.generate(),而是手动一步一步来,分别计时 Prefill 和 Decode。

def manual_generate(model, input_ids: torch.Tensor, max_new_tokens: int = 50):
    """手动实现 greedy decoding,分别记录 prefill 和每步 decode 的耗时"""
    prefill_time = 0.0
    decode_times: list[float] = []
    generated_ids = input_ids.clone()

    with torch.no_grad():
        # === Prefill 阶段 ===
        start = time.time()
        outputs = model(generated_ids, use_cache=True)
        prefill_time = time.time() - start

        # 取最后一个 token 的 logits,选概率最大的作为第一个生成 token
        next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True)
        generated_ids = torch.cat([generated_ids, next_token], dim=-1)
        past_key_values = outputs.past_key_values

        # === Decode 阶段:逐个生成 ===
        for _ in range(max_new_tokens - 1):
            start = time.time()
            outputs = model(next_token, past_key_values=past_key_values, use_cache=True)
            decode_times.append(time.time() - start)

            next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True)
            generated_ids = torch.cat([generated_ids, next_token], dim=-1)
            past_key_values = outputs.past_key_values

    return generated_ids, prefill_time, decode_times

运行手动生成,看看 Prefill 和 Decode 各花了多少时间。

# 运行手动生成
prompt = "The future of artificial intelligence is"
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
num_prompt_tokens = input_ids.shape[1]
num_generate = 100

generated_ids, prefill_time, decode_times = manual_generate(model, input_ids, num_generate)

total_decode_time = sum(decode_times)
total_time = prefill_time + total_decode_time

print(f"Prompt tokens: {num_prompt_tokens}")
print(f"生成 tokens: {num_generate}")
print(f"{'='*40}")
print(f"Prefill  耗时: {prefill_time*1000:>8.1f} ms  ({prefill_time/total_time*100:.1f}%)")
print(f"Decode   耗时: {total_decode_time*1000:>8.1f} ms  ({total_decode_time/total_time*100:.1f}%)")
print(f"{'='*40}")
print(f"总耗时:        {total_time*1000:>8.1f} ms")
print(f"Decode 每步:   {np.mean(decode_times)*1000:>8.1f} ms/token")
Prompt tokens: 6
生成 tokens: 100
========================================
Prefill  耗时:   1149.8 ms  (11.7%)
Decode   耗时:   8712.6 ms  (88.3%)
========================================
总耗时:          9862.4 ms
Decode 每步:       88.0 ms/token

可视化:每一步 Decode 的耗时

让我们画一张图,看看每步 decode 的耗时分布。

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左图:Prefill vs Decode 总耗时对比
phases = ["Prefill\n(处理 prompt)", "Decode\n(生成 token)"]
times_ms = [prefill_time * 1000, total_decode_time * 1000]
colors = ["#4CAF50", "#FF5722"]
bars = axes[0].bar(phases, times_ms, color=colors, width=0.5)
axes[0].set_ylabel("耗时 (ms)")
axes[0].set_title("Prefill vs Decode 总耗时")
for bar, t in zip(bars, times_ms):
    axes[0].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5,
                 f"{t:.0f}ms", ha='center', fontsize=12)

# 右图:每步 Decode 的耗时变化
decode_ms = [t * 1000 for t in decode_times]
axes[1].plot(range(len(decode_ms)), decode_ms, color="#FF5722", alpha=0.7, linewidth=1.5)
axes[1].axhline(y=np.mean(decode_ms), color='gray', linestyle='--', label=f'平均: {np.mean(decode_ms):.1f}ms')
axes[1].set_xlabel("Decode 步数")
axes[1].set_ylabel("耗时 (ms)")
axes[1].set_title("每步 Decode 的耗时")
axes[1].legend()

plt.tight_layout()
plt.show()

01-why-vllm_14_0.svg

3. 为什么 Decode 这么慢?——Compute-bound vs Memory-bound

从上面的结果可以看到:Decode 占了绝大部分时间。但每步 Decode 只处理 1 个 token,计算量明明很小啊,为什么反而慢?

答案在于 算力利用率

3.1 Arithmetic Intensity(算术强度)

衡量 GPU 使用效率的关键指标是算术强度:每从显存搬运 1 字节数据,做了多少次浮点运算。

Arithmetic Intensity=FLOPs(计算量)Bytes(访存量)\text{Arithmetic Intensity} = \frac{\text{FLOPs(计算量)}}{\text{Bytes(访存量)}}

  • Prefill:处理 N 个 token,矩阵乘法是 [N, d] × [d, d],计算量正比于 N,但权重只需要读一次 → 算术强度Compute-bound → GPU 算力充分利用
  • Decode:只处理 1 个 token,矩阵乘法是 [1, d] × [d, d],权重依然要全部读一遍,但计算量只有 Prefill 的 1/N → 算术强度极低Memory-bound → GPU 大部分时间在等数据搬运

打个比方:

Prefill 像是工厂流水线满负荷运转——原料(数据)源源不断,机器(GPU)不停歇。
Decode 像是整个工厂开动所有机器,只为了加工一颗螺丝钉——机器大部分时间在空转等材料。

3.2 用数字说话:Prefill vs Decode 的计算效率

让我们估算一下 Qwen3-0.6B 在 Prefill 和 Decode 时的理论计算量和访存量。

# Qwen3-0.6B 的配置
n_layers = model.config.num_hidden_layers
d_model = model.config.hidden_size
n_params = sum(p.numel() for p in model.parameters())
bytes_per_param = 4  # float32

# 模型权重的总大小
model_size_bytes = n_params * bytes_per_param
model_size_gb = model_size_bytes / 1e9

# Prefill: 处理 N 个 token
N_prompt = num_prompt_tokens

# 简化估算:每个 token 的前向传播约 2 * n_params 次浮点运算
prefill_flops = 2 * n_params * N_prompt
decode_flops = 2 * n_params * 1  # Decode 只处理 1 个 token

# 访存量:两个阶段都需要读一遍模型权重(简化估算)
memory_access = model_size_bytes  # 字节

# 算术强度
prefill_intensity = prefill_flops / memory_access
decode_intensity = decode_flops / memory_access

print(f"模型: {model_name}, 参数量: {n_params/1e6:.0f}M, 权重大小: {model_size_gb:.2f} GB")
print(f"{'='*50}")
print(f"{'指标':<20} {'Prefill':>12} {'Decode':>12}")
print(f"{'='*50}")
print(f"{'输入 tokens':<20} {N_prompt:>12} {1:>12}")
print(f"{'计算量 (GFLOPs)':<20} {prefill_flops/1e9:>12.2f} {decode_flops/1e9:>12.2f}")
print(f"{'访存量 (GB)':<20} {memory_access/1e9:>12.2f} {memory_access/1e9:>12.2f}")
print(f"{'算术强度 (FLOP/B)':<20} {prefill_intensity:>12.1f} {decode_intensity:>12.1f}")
print(f"{'='*50}")
print(f"\nPrefill 的算术强度是 Decode 的 {prefill_intensity/decode_intensity:.0f} 倍!")
模型: Qwen/Qwen3-0.6B, 参数量: 596M, 权重大小: 2.38 GB
==================================================
指标                        Prefill       Decode
==================================================
输入 tokens                       6            1
计算量 (GFLOPs)                 7.15         1.19
访存量 (GB)                     2.38         2.38
算术强度 (FLOP/B)                 3.0          0.5
==================================================

Prefill 的算术强度是 Decode 的 6 倍!

3.3 换个视角:如果模型更大呢?

Qwen3-0.6B 才 6 亿参数,现实中的模型动辄 7B、70B��让我们看看模型变大后,Decode 的瓶颈有多严重。

# 不同模型规模下,Decode 一个 token 需要搬运多少数据
models = {
    "Qwen3-0.6B":    0.6e9,
    "LLaMA-7B":      7e9,
    "LLaMA-13B":     13e9,
    "LLaMA-70B":     70e9,
}

# A100 GPU 显存带宽: ~2 TB/s
a100_bandwidth = 2e12  # bytes/s

print(f"假设使用 A100 GPU(显存带宽 ~2 TB/s),FP16 推理:")
print(f"{'='*65}")
print(f"{'模型':<18} {'权重大小':>10} {'Decode 搬运耗时':>18} {'理论上限':>15}")
print(f"{'='*65}")

for name, params in models.items():
    weight_bytes = params * 2  # FP16: 2 bytes per param
    weight_gb = weight_bytes / 1e9
    # Decode 一步至少要把所有权重读一遍
    decode_latency_ms = (weight_bytes / a100_bandwidth) * 1000
    max_tokens_per_sec = 1000 / decode_latency_ms

    print(f"{name:<18} {weight_gb:>8.1f} GB {decode_latency_ms:>14.1f} ms {max_tokens_per_sec:>11.0f} tok/s")

print(f"{'='*65}")
print(f"\n注意:这是单请求的理论上限——实际更慢,因为还有 KV Cache 的访存开销。")
假设使用 A100 GPU(显存带宽 ~2 TB/s),FP16 推理:
=================================================================
模型                       权重大小        Decode 搬运耗时            理论上限
=================================================================
Qwen3-0.6B              1.2 GB            0.6 ms        1667 tok/s
LLaMA-7B               14.0 GB            7.0 ms         143 tok/s
LLaMA-13B              26.0 GB           13.0 ms          77 tok/s
LLaMA-70B             140.0 GB           70.0 ms          14 tok/s
=================================================================

注意:这是单请求的理论上限——实际更慢,因为还有 KV Cache 的访存开销。

4. 吞吐量 vs 延迟:推理引擎的两难

理解了 Prefill 和 Decode 之后,我们来看推理服务面临的核心权衡。

4.1 两个关键指标

  • 延迟(Latency):单个请求从输入到输出的总时间。用户体验直接相关——没人想等 10 秒才看到回复。
  • 吞吐量(Throughput):单位时间内处理的 token 总数。服务商的成本命脉——同样的 GPU,处理更多请求 = 赚更多钱。

4.2 矛盾在哪?

单个请求的 Decode 是 memory-bound 的,GPU 算力严重浪费。一个直觉的想法是:把多个请求放在一起算(batching),这样一次搬运权重可以为多个请求服务,算术强度提高了!

但问题来了:

  • batch 越大,单个请求的延迟越高(要等凑齐一批)
  • 不同请求的生成长度不同,短的等长的,GPU 又空转了
  • 显存有限,batch 不能无限大——KV Cache 会吃掉大量显存

这就是推理引擎需要解决的核心问题:如何在有限的显存下,同时优化吞吐量和延迟?

4.3 模拟:Batching 对吞吐量的影响

让我们用代码感受一下 batch size 对吞吐量的影响。

# 测试不同 batch size 下的吞吐量
batch_sizes = [1, 2, 4, 8, 16]
results: list[dict] = []

# 确保有 pad token
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
    model.config.pad_token_id = tokenizer.eos_token_id

for bs in batch_sizes:
    # 构造一个 batch:重复同一个 prompt
    prompts = [prompt] * bs
    inputs = tokenizer(prompts, return_tensors="pt", padding=True).to(device)
    gen_tokens = 50

    # 预热
    with torch.no_grad():
        _ = model.generate(**inputs, max_new_tokens=5, do_sample=False)

    # 计时
    start = time.time()
    with torch.no_grad():
        _ = model.generate(**inputs, max_new_tokens=gen_tokens, do_sample=False)
    elapsed = time.time() - start

    total_tokens = bs * gen_tokens
    throughput = total_tokens / elapsed
    latency_per_request = elapsed  # 所有请求同时完成

    results.append({
        "batch_size": bs,
        "total_tokens": total_tokens,
        "elapsed": elapsed,
        "throughput": throughput,
        "latency": latency_per_request,
    })
    print(f"batch_size={bs:>2}: 吞吐 {throughput:>7.1f} tok/s, 延迟 {latency_per_request:.2f}s")
batch_size= 1: 吞吐     4.6 tok/s, 延迟 10.95s
batch_size= 2: 吞吐     6.0 tok/s, 延迟 16.60s
batch_size= 4: 吞吐     9.9 tok/s, 延迟 20.19s
batch_size= 8: 吞吐    21.5 tok/s, 延迟 18.65s
batch_size=16: 吞吐    28.7 tok/s, 延迟 27.87s

可视化 batch size 与吞吐量、延迟的关系。

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

bs_list = [r["batch_size"] for r in results]
tp_list = [r["throughput"] for r in results]
lat_list = [r["latency"] for r in results]

# 左图:吞吐量
axes[0].plot(bs_list, tp_list, 'o-', color="#2196F3", linewidth=2, markersize=8)
axes[0].set_xlabel("Batch Size")
axes[0].set_ylabel("吞吐量 (tokens/s)")
axes[0].set_title("Batch Size vs 吞吐量")
axes[0].set_xticks(bs_list)
axes[0].grid(True, alpha=0.3)

# 右图:延迟
axes[1].plot(bs_list, lat_list, 'o-', color="#FF5722", linewidth=2, markersize=8)
axes[1].set_xlabel("Batch Size")
axes[1].set_ylabel("延迟 (s)")
axes[1].set_title("Batch Size vs 延迟")
axes[1].set_xticks(bs_list)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print("观察:batch size 增大 → 吞吐量提升(好),但延迟也增加(坏)。")
print("这就是朴素 batching 的局限性。")

01-why-vllm_24_0.svg

观察:batch size 增大 → 吞吐量提升(好),但延迟也增加(坏)。
这就是朴素 batching 的局限性。

5. vLLM 的核心思路

好,我们现在知道了 LLM 推理的三大问题:

  1. Decode 阶段是 memory-bound 的,GPU 算力浪费严重
  2. KV Cache 吃显存,限制了能同时处理的请求数
  3. 朴素 batching 要等最慢的请求,进一步浪费资源

vLLM 是怎么解决这些问题的?核心有三板斧:

5.1 PagedAttention —— 像操作系统一样管理 KV Cache

传统方式为每个请求预分配 max_seq_len 大小的 KV Cache,哪怕实际只用了一半,剩下的显存也被浪费了。

vLLM 的做法:借鉴操作系统的虚拟内存机制,把 KV Cache 分成固定大小的"页",按需分配。这样显存利用率从约 20-40% 提升到接近 100%。

传统方式(连续分配,浪费严重):
┌──────────────────────────────┐
│ Req 1 KV Cache ████████░░░░░ │  ← 预分配了 max_len,但只用了一半
│ Req 2 KV Cache ████░░░░░░░░░ │  ← 更浪费
│ [   无法再分配新请求   ]       │
└──────────────────────────────┘

vLLM PagedAttention(分页按需分配):
┌──────────────────────────────┐
│ [1][1][1][2][2][3][3][3][3]  │  ← 不同请求的页交错存放
│ [1][4][4][4][  free  ][  ]   │  ← 空间紧凑,还能放新请求
└──────────────────────────────┘

5.2 Continuous Batching —— 不要等最慢的

传统 batching 要等一批请求全部完成才能处理下一批。vLLM 采用连续批处理

  • 每一步(iteration)重新决定哪些请求参与计算
  • 某个请求生成完毕?立刻踢出去,新请求立刻补进来
  • 不再有"等最慢的"的问题

5.3 智能调度 —— 显存不够时优雅处理

当显存紧张时,vLLM 的调度器可以:

  • 暂停(swap) 低优先级请求的 KV Cache 到 CPU 内存
  • 抢占(preempt) 某些请求,释放显存给更紧急的请求
  • 在吞吐量和延迟之间动态平衡

6. 与真实 vLLM 的对比

本篇我们只是感受了问题,还没有开始解决。但值得先看看我们的朴素实现和真实 vLLM 之间的差距:

方面我们的朴素实现真实 vLLM
KV Cache 管理HuggingFace 自动管理,预分配PagedAttention,按需分页
批处理策略静态 batch,等最慢的Continuous Batching,动态调度
显存利用率20-40%(大量浪费)接近 100%
并发处理batch size 受限数十到数百个请求并发
调度策略无(先到先得)优先级调度 + 抢占
Kernel 实现PyTorch 默认定制 CUDA kernel

真实 vLLM 的论文报告,在相同硬件上,vLLM 的吞吐量是 HuggingFace 原生推理的 2-4 倍,在某些场景下甚至达到 24 倍

在这个系列中,我们会用纯 Python 逐步实现上表左列向右列的演进。虽然我们不会写 CUDA kernel,但核心算法和数据结构是完全一致的。

7. 小结

本篇我们从最朴素的 model.generate() 出发,拆解了 LLM 推理的性能瓶颈:

  1. Prefill vs Decode:推理分为两个截然不同的阶段。Prefill 是 compute-bound(并行处理 prompt),Decode 是 memory-bound(逐个生成 token)。Decode 才是性能瓶颈。

  2. Memory-bound 的本质:Decode 每步都要从显存搬运整个模型的权重,但只做极少的计算。模型越大,这个问题越严重——70B 模型在 A100 上,理论 Decode 速度上限也就几十 tok/s。

  3. 吞吐量 vs 延迟的权衡:Batching 能提高吞吐量但增加延迟,朴素方案还有"等最慢的"问题。推理引擎需要在两者之间找到平衡。