Skip to content

第 10 章 生态对比与性能优化

第 10 章 | 上一章:第 9 章 调试与验证方法论 | 下一章:附录 A 术语总表

本章是整本书的「收尾章」。前面 9 章我们一砖一瓦盖起了自己的引擎, 这章把它放到整个推理引擎生态里:和 llama.cpp / vLLM / SGLang 比, 看清楚自己站在哪里、工业引擎强在哪、差距从哪来

本章整合自项目的 COMPARISON.md(965 行生态对比)和 BOOK.md 第十章。


目录


一张图看懂四种实现

先看全局。同样一个 Attention(Q,K,V) = softmax(QKᵀ/√d)V,四种实现做出来的样子天差地别:

你的引擎 (C 手写)     llama.cpp (C++ 手写)      vLLM (PyTorch+CUDA)
      │                     │                          │
      │  学习用              │  本地/边缘部署            │  数据中心生产部署
      │  ~2000 行            │  ~5 万行                 │  ~20 万行 + 30 个依赖
      │  纯 CPU             │  CPU/GPU 量化             │  必须 NVIDIA GPU
      │  3 tok/s           │  30 tok/s                │  3000+ tok/s
      │  1 个请求           │  1 个请求                 │  并发 100+ 请求
      │                     │                          │
      └───── 都在算同一个公式: Attention(Q,K,V) = softmax(QKᵀ/√d)V ─────┘

四种实现的多维度对比:

维度本项目(C 手写)llama.cppvLLM
定位学习原理个人 / 本地部署生产级服务
语言C99C++17Python + C++/CUDA
底层算子手写三重循环手写 + SIMD / 量化cuBLAS / cuDNN / FlashInfer
GPU 支持部分(Vulkan / Metal)✅ CUDA 必须
量化❌(fp32)✅(int4 / int8)✅(多种格式)
并发✅(continuous batching)
KV Cache 管理预分配数组预分配 + 量化PagedAttention(分页)
代码量~2,000 行~50,000 行~200,000 行
依赖0(只要 libc)极少30+ 个包
速度(0.5B)~3 tok/s~30 tok/s~3000 tok/s*
硬件需求任何 CPUCPU 或 GPUNVIDIA A100 / H100

*vLLM 速度基于多请求批量推理,单请求达不到。

核心事实:四者算的是同一个数学公式,差距完全来自工程实现。 理解了你的 2000 行 C 代码,就理解了 vLLM 在 GPU 上做的同样的事:只是并行化了、加上了调度。


技术栈分层对比

为了看清「差在哪」,把这三种实现的技术栈一层一层摆出来。

本项目(你手写的引擎)

┌──────────────────────────────────┐
│  run.c  (CLI 入口, 生成循环)      │
│  net.c  (前向传播: rmsnorm/rope)  │  ← 全部手写, 一层搞定
│  tokenizer.c (BPE 分词器)         │
│  safetensors.c (权重加载)         │
├──────────────────────────────────┤
│  libc (malloc/printf/expf)        │  ← 唯一依赖
├──────────────────────────────────┤
│  CPU 硬件                         │
└──────────────────────────────────┘

特点:所有逻辑在同一个抽象层级。打开 net.c,从 forward() 一眼能看完整个计算流程。没有间接调用,没有动态分发,没有 CUDA、没有 PyTorch、没有 HTTP server。

vLLM 的技术栈

┌─────────────────────────────────────────────────────┐
│  用户请求 (OpenAI API 格式)                          │  ← HTTP 接口
├─────────────────────────────────────────────────────┤
│  FastAPI + Starlette                               │  ← Web 服务层
│  (你的引擎没有这层, 纯 CLI)                          │
├─────────────────────────────────────────────────────┤
│  vLLM 调度器                                        │  ← vLLM 核心
│    PagedAttention (KV cache 分页管理)               │
│    Continuous Batching (动态批处理)                  │
├─────────────────────────────────────────────────────┤
│  transformers (模型定义/权重加载)                    │  ← 模型抽象层
│  tokenizers (BPE 分词)                              │  (你手写了等效功能)
├─────────────────────────────────────────────────────┤
│  PyTorch (torch.matmul, 张量运算前端)                │  ← 张量计算层
├─────────────────────────────────────────────────────┤
│  cuBLAS / cuDNN / FlashInfer / CUTLASS              │  ← CUDA 内核层
│  (真正的 GPU 并行矩阵乘法)                           │
├─────────────────────────────────────────────────────┤
│  NVIDIA GPU (CUDA cores + Tensor cores)             │  ← 硬件层
└─────────────────────────────────────────────────────┘

特点:7 层抽象。一个 model.generate() 调用,要穿过 Python → PyTorch → C++ → CUDA → GPU。每一层都做了优化,但也让「到底怎么算的」变得不透明。

llama.cpp 的技术栈(中间态)

┌─────────────────────────────────────────────────────┐
│  CLI / server (HTTP)                                │
├─────────────────────────────────────────────────────┤
│  llama.cpp 核心逻辑                                  │
│    GGUF 格式解析 (含量化权重)                        │
│    Transformer 前向 (量化算子)                       │
├─────────────────────────────────────────────────────┤
│  ggml (张量计算库, 手写)                             │  ← 自己的 cuBLAS
│    CPU: AVX2/NEON SIMD                              │
│    GPU: Metal (Mac) / Vulkan / CUDA                 │
├─────────────────────────────────────────────────────┤
│  CPU 或 GPU 硬件                                     │
└─────────────────────────────────────────────────────┘

特点:和你的引擎一样是「从底层手写」,但多了一个自研的张量库 ggml(相当于自己写了个迷你 cuBLAS),还加了量化和 SIMD。

三层对比一目了然

本项目     llama.cpp        vLLM
─────────────────────────────────────
1 层       3 层             7 层
纯 C       C++ + ggml       Python + PyTorch + CUDA
0 依赖     极少依赖         30+ 依赖
全透明     部分透明         黑盒居多

层数越多 = 功能越全、性能越高,但也越难看懂。本项目故意只保留一层,就是为了让你能看穿。


为什么不能「一个引擎跑所有模型」

你可能注意到:这个项目的 net.c专门为 Qwen2.5 写的。换一个模型就要改代码。这不是我们偷懒,而是整个 AI 推理生态的核心矛盾

每个模型都在基础架构上「加自己的东西」

                  归一化     位置编码        注意力           QKV bias    激活      embedding
Llama-2-7B        RMSNorm   RoPE          GQA             ❌ 无       SwiGLU   不绑定
Llama-3-8B        RMSNorm   RoPE(θ=50万)  GQA             ❌ 无       SwiGLU   不绑定
Qwen2.5 (本项目)   RMSNorm   RoPE(θ=100万) GQA             ✅ 有       SwiGLU   绑定 ★
Mistral-7B        RMSNorm   RoPE+滑动窗口 GQA+滑动窗口     ❌ 无       SwiGLU   不绑定
DeepSeek-V2       RMSNorm   RoPE(解耦式)  MLA(新结构)      特殊        MoE      部分绑定

每个模型都改了点东西:

  • Qwen 加了 QKV bias(Llama 没有):这就是为什么我们的 net.c 要传 bq/bk/bv
  • Mistral 加了滑动窗口注意力(只看最近的 token,省内存)
  • DeepSeek-V2 用 MLA 替代了 GQA,MLP 换成了 MoE(多个专家网络)
  • Llama-3 把 RoPE 的 theta 从 1 万调到 50 万(支持更长上下文)

这些差异对引擎意味着什么

从 Qwen2.5 改成支持其他模型,需要改的代码:

改成 Llama-3:     🟢 简单 (改 config 数字 + 删 QKV bias + 加载 lm_head)
改成 Mistral:    🟡 中等 (attention 加滑动窗口逻辑)
改成 DeepSeek:   🔴 重写 (attention 换成 MLA, MLP 换成 MoE, RoPE 解耦)

「加自己的东西」越多,引擎改动越大。DeepSeek 的 MLA 和 MoE 是全新结构,基本要重写 net.c 的 attention 和 MLP 两大部分

由此诞生了三种解决方案

方案代表怎么应对新模型代价
① 专用引擎本项目、TensorRT-LLM每个模型改代码简洁但不通用
② 配置驱动llama.cpp(GGUF)读 config 自动 if/else 适配代码里全是分支
③ 计算图驱动PyTorch、vLLM、ONNX模型自带计算步骤,引擎只执行引擎极复杂

三种方案的权衡:

方案 ① (专用):    通用性 ★          简洁性 ★★★★★     性能 ★★★★★
  我们这个引擎: 只跑 Qwen2.5, 但 2000 行代码全看得懂

方案 ② (配置):    通用性 ★★★        简洁性 ★★★        性能 ★★★★
  llama.cpp:   一个引擎 + GGUF 格式, 读配置自动适配
               代码里大量 if (sliding_window) { ... } else { ... }

方案 ③ (计算图):  通用性 ★★★★★     简洁性 ★          性能 ★★★
  PyTorch:     实现了所有可能的算子, 读模型定义文件执行
               PyTorch 有几千万行代码, 因为要支持所有结构

这解释了两个常见疑问

「vLLM 为什么依赖 PyTorch?」 因为 PyTorch 是方案 ③:实现了所有可能的算子,能跑任何模型。vLLM 站在 PyTorch 的肩膀上,自己只加调度优化。

「我们这个引擎为什么只有 2000 行?」 因为我们是方案 ①:只支持 Qwen2.5 一个模型。不用写 if/else 适配多种结构,所以代码极简。

这个矛盾还在推动新格式出现

每次有新模型架构出现(比如 DeepSeek 的 MLA),所有引擎都要跟进:

  • llama.cpp 要更新代码支持 MLA
  • vLLM 要加 MLA 的算子
  • ONNX 要定义 MLA 的算子规范

这是为什么推理引擎更新频繁:不是引擎自己想变,是模型一直在变

一句话:模型架构百花齐放,引擎要么「为每个模型定制」(快但不通用),要么「实现所有可能」(通用但复杂)。没有两全其美:这就是推理引擎生态的根本矛盾。


算得快 vs 调度好

很多人以为 vLLM 比 llama.cpp 快是因为「算法更优」。其实不是。同样的 attention 数学公式,vLLM 的真正优势在调度

算得快:GPU + 专用内核

这是最直觉的差异。同一个矩阵乘法:

c
// 你的引擎 (CPU, 单线程, 标量循环)
for (int i = 0; i < d; i++) {
    for (int j = 0; j < n; j++) {
        val += wrow[j] * x[j];
    }
}
// 结果: 896×896 矩阵乘法 ≈ 几十微秒
python
# vLLM / PyTorch (GPU, 数千核心并行)
output = torch.matmul(W, x)   # 底层调 cuBLAS
# 结果: 同样的矩阵乘法 ≈ 几纳秒 (快 1000x)

GPU 有几千个核心同时工作,加上 Tensor Core 专门做矩阵乘法。这不是「算法好」,是硬件并行度碾压。这部分是「算得快」,你可以在 C 里逐步加上(量化、SIMD)。

调度好:vLLM 真正的创新

这才是 vLLM 值钱的地方。考虑一个场景:

用户 A: "写一首关于秋天的诗"      → 生成 50 个 token, 慢慢吐
用户 B: "1+1="                    → 生成 1 个 token, 秒回
用户 C: "翻译这段话..."           → 生成 200 个 token

传统做法(你的引擎 / llama.cpp):

处理 A ████████████████████████████ (50 步)
                                       处理 B ██ (1 步)
                                                  处理 C ████████████████ (200 步)
GPU 大部分时间在等一个请求生成完

vLLM 的 continuous batching:

A ████████████████████████████
B ██                            ← B 只用 1 步就完成, 位置立刻让给 D
C                ████████████████████████████████████████████████
D                   ████████████████████   ← B 完成后动态填入新请求
   ↑ 每一步都把所有活跃请求拼成一个 batch 一起算, GPU 永不空转

效果:GPU 利用率从 ~30% 提到 ~95%。在并发场景下,vLLM 的吞吐量是 llama.cpp 的 10-20 倍。

PagedAttention:KV Cache 的内存管理

传统 KV Cache(你的引擎用的方式):

c
// 预分配固定大小的数组
float *key_cache = calloc(n_layers * seq_len * kv_dim, sizeof(float));
// 问题: 不管序列多长, 都按最大长度预留内存
// 多个请求时, 每个请求独占一块, 浪费严重

vLLM 的 PagedAttention(借鉴操作系统的虚拟内存分页):

把 KV cache 切成固定大小的 "页" (block), 按需分配:

传统 (你的引擎):
请求A [████████░░░░░░░░] 16 slots 预留, 只用了 8
请求B [██░░░░░░░░░░░░░░] 16 slots 预留, 只用了 2
浪费: 14/32 = 44%

PagedAttention (vLLM):
页1[AABB] 页2[AAAA] 页3[AACC] ...
按需分页, 没有浪费, 内存利用率 ~95%

这让 vLLM 能在同样的显存里塞下更多并发请求。

三件事的关系

"算得快" (硬件 + 算子层):
  - GPU 并行
  - SIMD / 量化
  - FlashAttention
  → 你可以逐步加上, 你的引擎也能变成 "算得快"

"调度好" (引擎架构层):
  - Continuous Batching
  - PagedAttention
  - Prefix Caching
  → 需要重构整个推理循环, 这是 vLLM 的护城河

为什么用 GPU 不用 CPU

一个根本问题:我们的引擎在 CPU 上跑(~3 tok/s),为什么工业界都用 GPU? 答案就藏在 Transformer 的参数清单里:矩阵乘法是海量独立运算,天生适合并行

算算单个 token 要做多少次乘法

以本项目 0.5B 模型为例,每生成 1 个 token:

每层内部:
  Q 投影 (896×896):     802,816 次乘加
  K 投影 (128×896):     114,688 次
  V 投影 (128×896):     114,688 次
  O 投影 (896×896):     802,816 次
  MLP 门控 (4864×896): 4,358,144 次   ← 大头
  MLP 上投 (4864×896): 4,358,144 次   ← 大头
  MLP 下投 (896×4864): 4,358,144 次   ← 大头
  单层合计:           14,909,440 次

× 24 层:             357,826,560 次
+ logits (896×151936): 136,134,656 次
────────────────────────────────────
总计:                493,961,216 次 ≈ 5 亿次乘加 / 每个 token

放大到真实的大模型:

模型               每个 token 的计算量    CPU 8核耗时    A100 耗时
Qwen2.5-0.5B       0.5 TFLOP            1.2 s          0.0008 s
Qwen2.5-7B         7 TFLOP              17.5 s         0.011 s
Qwen2.5-72B        72 TFLOP             180 s          0.115 s
Llama-3.1-405B     405 TFLOP            1012 s         0.649 s

CPU 和 GPU 差距: 100-1000 倍

为什么 GPU 快这么多

矩阵乘法 out[i] = Σ W[i][j] × x[j] 有个关键特性:每一行的计算完全独立

MLP 门控 4864×896 矩阵 × 896 维向量:

  out[0]    = W[0][:]    · x    ← 和别的行无关
  out[1]    = W[1][:]    · x    ← 和别的行无关
  out[2]    = W[2][:]    · x    ← 和别的行无关
  ...
  out[4863] = W[4863][:] · x    ← 和别的行无关

4864 行可以同时算!

CPU 和 GPU 的设计差异:

CPU (8-16 核):                   GPU (几千-上万核):
  设计目标: 少量复杂任务            设计目标: 海量简单任务
  每核超强, 适合逻辑判断            每核简单, 适合并行计算

  算 4864 行: 8 核分 608 轮        算 4864 行: 6912 核一轮搞定
  → 慢 (排队)                      → 快 (同时上)

用搬货类比:

4864 箱货要搬:
  CPU (8 人):  每人搬 608 箱, 排队轮流 → 慢
  GPU (6912 人): 一人一箱, 同时搬完    → 快 600+ 倍

这就是为什么 Transformer 天生适合 GPU

Transformer 每一层都是几个大矩阵乘法,每个矩阵乘法都是几千行独立计算,完美匹配 GPU 的「几千核心并行」。

CPU 适合:  if/else 逻辑、串行算法、操作系统
GPU 适合:  矩阵乘法、图像处理、神经网络  ← Transformer 全是这些

我们的 C 引擎在 CPU 上跑,所以 net.c 里的 for (i...) for (j...) 是串行的:一行一行算。GPU 会把这几千行分给几千个核心同时算。这就是 100-1000 倍差距的根源。

本项目用 CPU 是为了学习原理(代码简单、可读)。 工业引擎用 GPU 是为了速度(几千倍并行)。理解了这个最简单的 CPU 版本,就能理解 GPU 版本做了同样的事:只是并行化了。


性能差距从哪来

以 Qwen2.5-0.5B 为例,生成单 token 的速度对比:

你的引擎 (M-series CPU, fp32)     :   3 tok/s
  └─ 瓶颈: CPU 标量循环, 无并行

llama.cpp (M-series, Q4 量化)      :  30 tok/s   (10x)
  └─ 提升: int4 量化(4x 内存带宽) + NEON SIMD(4x 并行)

llama.cpp (RTX 4090, Q4)          : 200 tok/s   (66x)
  └─ 提升: GPU 数千核心并行

vLLM (A100, fp16, 单请求)         : 400 tok/s   (133x)
  └─ 提升: A100 比 4090 更强 + FlashAttention

vLLM (A100, fp16, 100 并发请求)   : 8000 tok/s  (2666x 总吞吐)
  └─ 提升: continuous batching 把 100 个请求拼成一个超 batch

分解这 2666 倍的来源:

优化手段倍数在哪做的
int4 量化×4llama.cpp 的算子层
SIMD(NEON / AVX)×4llama.cpp 的算子层
GPU 并行(CPU→GPU)×7硬件切换
FlashAttention×2CUDA 内核优化
Continuous Batching(并发)×12vLLM 调度层
合计×2688

注意:前 4 个优化是「算得快」,你的引擎可以逐步加上(量化、SIMD 都能在 C 里实现)。 最后 1 个是「调度好」,需要重构整个推理架构:这是 vLLM 的护城河。


大模型装不下怎么办:5 种加载方案

前面讲的都是 0.5B 这种小模型(权重 2GB,全部塞内存就行)。 但真实世界的大模型动辄几十上百 GB,单卡显存只有几十 G,装不下。 这是大模型工程的核心难题,行业有 5 种解决方案。

问题有多大

模型                  参数     fp16 权重    单卡能装下吗
──────────────────────────────────────────────────────
Qwen2.5-0.5B (本项目)  0.5B    1 GB        ✅ 随便装
Qwen2.5-7B            7B      14 GB       ✅ 一张 RTX 4090
Qwen2.5-72B           72B     144 GB      ❌ 单卡最大 141GB 勉强
Llama-3.1-405B        405B    810 GB      ❌ 要 6 张 H100
GPT-4 (估)            1.8T    3.6 TB      ❌ 要几十张卡

GPU 显存参考:
  RTX 4090 (消费级)     24 GB   ¥1.6 万
  A100 80G (数据中心)   80 GB   ¥18 万
  H200 (最新大显存)    141 GB   ¥30 万

核心矛盾:模型越来越大,显存增长跟不上。405B 要 825GB,单卡最大 141GB,差 6 倍。

方案 ①:量化压缩(最常用)

一句话:用更少的位数近似表示同一个权重,牺牲一点精度换更小体积和更快速度。

同一个模型不同精度的大小

同一个 72B 模型:
  fp32 (32位): 288 GB  ← 最精确, 几乎不用
  fp16 (16位): 144 GB  ← 训练用的精度
  int8  (8位):  72 GB  ← 压缩一半, 精度损失小
  int4  (4位):  36 GB  ← 压缩 4 倍, 主流推理精度 ★
  int2  (2位):  18 GB  ← 压缩 8 倍, 精度损失大

为什么能量化:模型靠「方向」找词,不靠精确数值

前面讲过,模型最后预测词时用的是余弦相似度(方向接近度),不是精确数值。 所以单个权重有点误差没关系,只要整体方向不变:

同一个权重值, 不同精度:
  fp32: 0.034123001  ← 9 位有效数字
  int4: 0.03         ← 2 位有效数字

看起来误差很大? 但 1000 个权重量化后, 整体方向几乎不变:
  fp16: 方向相似度 1.000000  ← 完全不变
  int8: 方向相似度 0.999970  ← 几乎不变
  int4: 方向相似度 0.991512  ← 还是 0.99+
  int2: 方向相似度 0.837232  ← 这才明显有损失

→ int4 对效果影响很小, 因为模型靠方向找词
→ int2 才会明显损害效果

量化具体怎么做:映射到固定离散值

int4 只有 4 个 bit,能表示 16 个值(-8 到 +7)。把连续的浮点权重映射到这 16 个值:

原始 fp16 权重 (8 个):
  [-0.42, -0.15, -0.03, 0.08, 0.21, 0.35, 0.41, -0.28]

第 1 步: 算缩放因子
  scale = 最大绝对值 / 7 = 0.42 / 7 = 0.06
  (把 [-0.42, 0.42] 范围压到 [-7, +7] 整数区间)

第 2 步: 每个权重除以 scale, 四舍五入成整数
  -0.42 / 0.06 = -7.0 → -7
  -0.15 / 0.06 = -2.5 → -2
   0.08 / 0.06 = +1.3 → +1
   0.41 / 0.06 = +6.8 → +7
  ...

第 3 步: 只存这些小整数 (每个 4 bit)
  存: [-7, -2, 0, 1, 4, 6, 7, -5]

推理时: 整数 × scale 恢复近似值
  -7 × 0.06 = -0.42  (原始 -0.42, 误差 0.00)
  -2 × 0.06 = -0.12  (原始 -0.15, 误差 0.03)
  ...
  平均误差 0.02, 方向几乎不变

量化的双重收益

收益 ①:省存储 / 显存(直觉上最容易理解的)

fp16 → int4: 每个权重从 16 bit 缩到 4 bit, 体积缩小 4 倍
  72B 模型: 144GB → 36GB → 一张 A100 80G 就能装下!
  405B 模型: 810GB → 200GB → 3 张 A100 就能跑

收益 ②:算得更快(更重要的收益,很多人忽略)

矩阵乘法的瓶颈是"读权重的速度"(内存带宽), 不是计算本身
  int4 权重小 4 倍 → 同样时间能读 4 倍多的权重 → 快 4 倍
  而且 GPU 的 int4 算力比 fp16 高 (Tensor Core 专门优化)

合计: 速度快 4-8 倍

类比搬货:
  fp16: 每箱 16 公斤, 一车搬 80 箱
  int4: 每箱 4 公斤,  一车搬 320 箱  ← 一车搬 4 倍

代价

精度略降,但实验证明 int4 对大多数任务影响很小(因为方向几乎不变)。 只有极端量化(int2)才会明显损害效果。

本项目用量化的吗

没有。本项目用 fp32(最精确,每个数 4 字节),原因:

  • 学习项目要数值精确(方便和 PyTorch 对答案)
  • 0.5B 只有 2GB,没必要量化
  • 量化算子(int4 解码 + 计算)比 fp32 难写

如果要加量化版,就是在 matmul 里把权重从 int4 解码成 fp32 再算。llama.cpp 的 Q4_K_M 格式就是这么做的。

方案 ②:张量并行(Tensor Parallelism): 切矩阵

原理:把一个大矩阵按行或按列切成几块,每块放一张卡。

405B 的 Wq 矩阵, 用 8 张卡按行切:

  整个 Wq (16384 行):
    ┌─────────────────┐
    │   GPU 0 算的    │ ← 2048 行
    ├─────────────────┤
    │   GPU 1 算的    │ ← 2048 行
    ├─────────────────┤
    │   ...           │
    ├─────────────────┤
    │   GPU 7 算的    │ ← 2048 行
    └─────────────────┘

  输入 x 广播给 8 张卡
  每张卡只算自己那块 → 输出部分结果 → All-Gather 拼起来

效果:每张卡只存 1/N 的权重。825GB / 8 ≈ 103GB/卡(A100 能装)。

代价:每层算完卡之间要通信拼结果(All-Gather),有延迟。卡越多通信开销越大。

本项目是单线程,没有这个概念。理解了 matmul 的行循环,就能理解切行的张量并行。

方案 ③:流水线并行(Pipeline Parallelism): 切层

原理:按层切,每张卡负责几层。

24 层模型, 2 张卡:

  GPU 0 (装 12 层的权重)        GPU 1 (装 12 层的权重)
  ┌───────────────────┐         ┌───────────────────┐
  │ Layer 0  → 11     │ 传过去 → │ Layer 12 → 23     │
  └───────────────────┘         └───────────────────┘

  数据流: GPU0 算完 0-11 层 → 结果传给 GPU1 → GPU1 算 12-23 层

效果:权重对半分,每张卡存一半。

代价:有「气泡」:GPU0 算的时候 GPU1 在等,利用率不满。可以用 micro-batching 缓解。

本项目的 forward()for (l = 0; l < L; l++) 这个循环,每层是独立的,天然可以切给不同卡。

方案 ④:CPU 卸载(Offloading): 按需搬

原理:显存放不下,但服务器内存大(常有 1TB)。权重放内存,用到哪层搬到哪层。

  内存 (1TB, 便宜)              显存 (80GB, 贵)
  ┌───────────────────┐         ┌───────────────────┐
  │ Layer 0-23 全部权重│ 用到才搬 →│ 当前在算的那层     │
  └───────────────────┘         └───────────────────┘

  算 Layer 0: 从内存搬到显存 → 算 → 丢弃
  算 Layer 1: 从内存搬到显存 → 算 → 丢弃
  ...

效果:显存只需装一层,任何大模型都能跑。

代价:搬运很慢(PCIe 带宽远低于显存带宽),推理速度下降 10-100 倍。

适用:没钱买多卡但想跑大模型。本项目用 mmap 把权重映射到内存,本质就是这种思路的 CPU 版本。

方案 ⑤:MoE 专家并行 : 只用一部分

原理:DeepSeek-V2、Mixtral、GPT-4 都用 MoE(混合专家)结构,每次只用一部分权重。

传统模型: 每个词都要过全部参数 → 全要装进显存
MoE 模型: 有 256 个"专家", 每个词路由器只挑 8 个

  ┌─────────────────────────┐
  │ 路由器: 这个词该找哪个专家?│
  └────────────┬────────────┘

  专家1  专家2  专家3 ... 专家256
  (用)  (用)  (没)       (没)

  单个词只用 8/256 = 3% 的参数!

效果:总参数 534B 但每次激活只用 37B,算得快。

代价:总参数还是要全部存(但可以换着用 + 量化)。

现实中怎么组合

实际部署一个大模型,通常多种方法叠加:

部署 405B 模型 (825GB fp16), 典型方案:

  ① 量化 int4:       825GB → 206GB   (压 4 倍)
  ② 张量并行 8 卡:    206GB → 26GB/卡 (切 8 块)
  ③ 一台 8×A100 服务器: 每卡 26GB < 80GB ✓

  结果: 一台 8 卡服务器 (¥150万) 就能跑 405B

5 种方法对照:

方法原理省几倍代价
量化少用位数存权重×4-8精度略降
张量并行矩阵切块放多卡×N 卡卡间通信
流水线并行按层切放多卡×N 卡有气泡
CPU 卸载权重放内存按需搬任意慢 10-100×
MoE每次只用部分专家×10-30总权重仍要存

这些方法不互斥,组合使用。vLLM 之所以流行,就是因为它自动化了这套组合(自动选量化、自动切分、自动调度)。

本项目用的什么:全量 fp32 一次性 mmap 进内存,不切分。因为 0.5B 只有 2GB,现代电脑随便装。 小模型用最朴素的方式,大模型才需要这些复杂技术。理解了这个最简单的情况,再看并行技术就有基础了。


vLLM 的依赖清单解读

vLLM 在 PyPI 上的 requires_dist(真实的依赖列表),按功能分组:

计算核心

依赖作用你引擎的对应物
torch==2.11.0张量运算前端net.c 手写循环
flashinfer-pythonFlashAttention 等注意力内核net.c 的 attention 循环
nvidia-cudnn-frontendNVIDIA 深度学习算子net.c 的 rmsnorm/silu
nvidia-cutlass-dsl矩阵乘法模板库net.c 的 matmul
quack-kernels量化内核(你没有量化)
humming-kernels推理专用内核-

模型 / 分词

依赖作用你引擎的对应物
transformers>=5.5.3模型定义 / 权重加载safetensors.c
tokenizers>=0.21.1BPE 分词tokenizer.c
safetensors>=0.6.2权重文件解析safetensors.c(你手写的)
sentencepiece另一种分词器-
tiktokenOpenAI 的分词器-

服务 / API

依赖作用你引擎的对应物
fastapi[standard]HTTP API 框架❌(你用 CLI)
starletteASGI 框架-
openai>=2.0.0OpenAI 兼容客户端-
prometheus_client指标监控-

结构化输出

依赖作用
xgrammar用语法约束生成(JSON mode)
outlines_core同上,另一种方案
llguidance同上
lm-format-enforcer同上

光「结构化输出」就有 4 个库可选:这是生产环境的需求复杂度。

对比:你的引擎的依赖树是:

libc (malloc, printf, expf, mmap, open)

就这一个。vLLM 有 30+ 个。这不是 vLLM 臃肿,而是生产环境需要的功能(API 服务、并发、监控、量化、多种模型格式…)远比学习原理复杂。


各引擎适用场景

需求推荐为什么
学习 LLM 原理✅ 本项目2000 行代码全部可见,数值已验证
本地聊天(Mac)llama.cpp有 Metal 加速 + 量化,速度快
本地聊天(有 GPU)llama.cpp / Ollama同上,Ollama 封装更友好
单机 API 服务TGI / llama.cpp server轻量 HTTP 接口
数据中心高并发vLLMPagedAttention + batching 碾压
嵌入式 / 边缘llama.cpp / 本项目精简版资源受限环境
研究实验transformers(PyTorch)灵活,易改模型结构
极致性能TensorRT-LLMNVIDIA 官方,比 vLLM 更快但更难用

从手写引擎到工业引擎的演进路径

如果你想从现在的手写引擎出发,逐步变成一个「能用的」引擎,路径如下:

第一级:性能优化(保持架构不变)

你现在 ──→ 加 int4 量化 ──→ 加 NEON SIMD ──→ 加多线程
  3 tok/s      12 tok/s        48 tok/s        ~150 tok/s (8核)

这些都在 C 里做得到,net.c 的结构基本不变。

第二级:功能扩展

加 HTTP API (libcurl 或手写) ──→ 支持 stream 输出 ──→ 支持 chat 模板
加 GGUF 格式支持 ──→ 支持更多模型 (Llama/Mistral)
加 GPU 支持 (Metal/CUDA) ──→ 速度再提 10x

这时你的引擎大概变成 llama.cpp 的雏形。

第三级:生产级调度(最难)

加 KV cache 分页管理 ──→ continuous batching ──→ 你就重写了 vLLM

这一步需要重构整个推理循环:从「一次一个请求」变成「一次一批请求」,注意力计算要改成 batch 维度。这是数月的工程量。


具体差什么:本项目 vs vLLM vs SGLang

前面几节讲了原理层面的差异。这节给出具体的功能清单和性能数字,让你清楚知道「距离生产引擎还差哪些零件」。

功能差距清单

类别           功能                  本项目      vLLM        SGLang
─────────────────────────────────────────────────────────────────
核心推理       前向传播              ✅ CPU      ✅ GPU       ✅ GPU
               BPE 分词器            ✅          ✅          ✅
               采样 (temp/topk)      ✅          ✅+topp     ✅+topp

并发/调度       单请求                ✅          ✅          ✅
               多请求并发            ❌          ✅          ✅
               Continuous Batching   ❌          ✅ ★        ✅ ★
               KV Cache 分页管理      ❌          ✅ PagedAttn ✅ PagedAttn
               Prefix Caching        ❌          ✅          ✅ RadixAttn ★

性能优化       GPU 加速              ❌          ✅ CUDA      ✅ CUDA
               量化 (int4/int8)      ❌          ✅ 多种      ✅ 多种
               Flash Attention       ❌          ✅          ✅
               张量并行 (多卡)        ❌          ✅          ✅
               Speculative Decoding  ❌          ✅          ✅

服务/API       HTTP API              ❌          ✅ FastAPI   ✅ FastAPI
               OpenAI 兼容           ❌          ✅          ✅
               流式输出 (SSE)        ❌          ✅          ✅
               多模态 (图/音/视频)    ❌          ✅          ✅

高级生成       结构化输出 (JSON)     ❌          ✅          ✅ ★强
               Function Calling      ❌          ✅          ✅
               LoRA 适配器           ❌          ✅          ✅

模型支持       架构种类              1种(Qwen)   几十种       几十种
               量化格式              无          10+种        10+种

三类差距详解

第一类:调度与并发(最核心的差距)

这是我们和 vLLM/SGLang 最根本的差距:我们只能一次处理一个请求

Continuous Batching(连续批处理):

我们的引擎 (串行):
  请求A ████████████████ (50个token)
                            请求B ██ (1个token)
                                       请求C ██████████
  → 三个请求排队, 算力大部分时间在等

vLLM/SGLang (continuous batching):
  请求A ████████████████
  请求B ██                   ← B完成, 位置立刻让给新请求
  请求C      ████████████
  请求D         ███████████  ← 动态填入
  → 每一步都把所有活跃请求拼成一个 batch 一起算
  → 算力永远满载, 利用率 30% → 95%

本质:我们的 forward() 每次只处理一个 token 序列。vLLM 改造成能同时处理多个序列(多了 batch 维度),让算力被多个请求共享。

PagedAttention(分页 KV Cache):

我们的 KV Cache (固定分配):
  每个请求预留 [2048位置 × 128维] 空间
  → 只生成 50 个 token 也占 2048 的内存, 浪费 97%

vLLM/SGLang (PagedAttention):
  KV Cache 切成固定大小的"页"(block), 按需分配
  → 生成 50 个 token 只占 50 个位置的内存
  → 同样显存能塞下更多并发请求

Prefix Caching(前缀缓存):

两个用户都问 "请解释量子力学" (前缀相同):
  用户1: "请解释量子力学...什么是薛定谔方程"
  用户2: "请解释量子力学...它有什么应用"

我们的引擎: 两个请求都要从头算前缀的 KV Cache
SGLang (RadixAttention): 第一个请求算完, 前缀 KV Cache 缓存住
                          第二个请求直接复用 → 快几倍

第二类:性能优化技术

GPU + CUDA(最大性能来源):

我们的 matmul (CPU 串行):  for(i) for(j) 一个个算
vLLM 的 matmul (GPU 并行): torch.matmul → cuBLAS → 6912 核同时算
差距: 100-1000 倍 (见 10.5 节详解)

Flash Attention:

标准 Attention: 完整 [seq_len, seq_len] 矩阵存显存
  seq_len=4096 时单矩阵 64MB, 多层多头 → 显存爆炸

Flash Attention: 分块计算, 不存完整矩阵
  → 显存从 O(seq_len²) 降到 O(seq_len)
  → 速度快 2-3 倍

Speculative Decoding(投机解码):

标准生成: 每个token都跑一遍完整 24 层 forward
投机解码:
  ① 小模型快速猜 4 个 token
  ② 大模型一次验证这 4 个 (成本≈算 1 个)
  ③ 猜对的直接用
  → 等于一次 forward 生成 2-3 个 token, 快 2-3 倍

量化:见 10.7.2 节详解。int4 让权重小 4 倍、速度快 4-8 倍。

第三类:服务与生态

这部分是「能不能用」的问题,不是「快不快」:

功能我们vLLM/SGLang作用
HTTP API别人能通过网络调用
OpenAI 兼容直接替换 OpenAI 客户端
流式输出边生成边返回(打字效果)
结构化输出强制输出合法 JSON
多模态支持图片 / 音频输入
LoRA加载微调权重

性能差距有多大

同样跑 Qwen2.5-7B:

引擎              硬件          单请求速度    并发吞吐量
─────────────────────────────────────────────────────────
本项目 (CPU)      8核CPU        ~3 tok/s     3 tok/s
llama.cpp         RTX 4090      ~80 tok/s    80 tok/s
vLLM              A100 × 1      ~150 tok/s   ~3000 tok/s ★
SGLang            A100 × 1      ~150 tok/s   ~4000 tok/s ★

关键差距:

单请求速度:   本项目慢 50 倍 (CPU vs GPU)
并发吞吐量:   本项目慢 1000 倍 (串行 vs batching) ← 差距最大

vLLM vs SGLang(两个顶级引擎的对比)

两者都有 PagedAttention、Continuous Batching、量化、多卡、HTTP API。差异:

SGLang 更强的地方:

  • RadixAttention:用基数树缓存前缀,比 vLLM 的哈希表能缓存更多组合 → 多轮对话、Few-shot 场景快 2-5 倍
  • 结构化输出:原生集成 + 编译期优化 → 强制输出 JSON 时快 10 倍+
  • 程序化生成(DSL):SGLang 独有,能用 Python 代码描述生成流程(分支生成、挑最优)
  • 调度更激进:kernel 融合更激进 → 综合吞吐量高 10-30%

vLLM 更强的地方:

  • 生态更成熟:社区更大、文档更多、插件更多
  • 硬件支持更广:NVIDIA / AMD / Intel / TPU
  • 稳定性:经过大规模生产验证

但这不是缺陷:是设计目标不同

本项目 (学习引擎):
  目标: 让你理解推理引擎的每个零件
  价值: 2000 行代码全看得懂, 数值已验证
  对标: Karpathy 的 llama2.c

vLLM / SGLang (生产引擎):
  目标: 高并发、高吞吐、低延迟
  价值: 扛住真实流量
  对标: 商业 API 后端

我们的引擎故意砍掉了 90% 的功能:不是偷懒,而是让剩下的 10%(核心算法)足够清晰。vLLM 有 20 万行代码、30+ 依赖;我们 2000 行、0 依赖。前者能上生产,后者能进脑子。


长上下文:模型和硬件缺一不可

你可能注意到不同模型宣称的上下文长度差别巨大:GPT-2 是 1024,Qwen2.5 是 32K,Claude 3 是 200K,Gemini 1.5 是 1M。支持长上下文不只是硬件的事,模型本身也必须支持:两者缺一不可。

模型侧:位置编码必须能覆盖那么远

这是最底层的前提。回顾第 5 章的 RoPE:位置编码靠旋转角度区分位置:

c
float freq = powf(theta, (float)(-2*i) / head_dim);
float angle = pos * freq;   // 位置 pos 的旋转角度

问题:如果 pos 很大(比如 100 万),不同位置的旋转角度会非常接近:相邻位置的角度差小到模型分不清谁是谁。这就像用刻度尺量毫米,但尺子的刻度只画到了厘米。

不同模型的应对:

模型               rope_theta    最大上下文   位置编码够用吗
─────────────────────────────────────────────────────────
GPT-2              10,000        1,024        ✓ 够
Llama 2            10,000        4,096        ✓ 够
Qwen2.5            1,000,000     32,768       ✓ 够 (theta 大, 覆盖远)
Llama 3            500,000       128K         ✓ 够
Claude 3 (YaRN)    ? + 缩放      200K         ✓ 靠 RoPE scaling
Gemini 1.5         ? + 压缩记忆  1M           ✓ 靠记忆压缩

↑ rope_theta 越大, 相邻位置的频率差异越小, 能编码的位置越远
  但太大又会牺牲近距离位置的区分度:需要平衡

如果模型的位置编码不支持 200K,光给硬件也没用:模型根本"看不清"第 15 万个位置的 token 和第 15 万零 1 个有什么区别。

长上下文模型的额外技术:

  • YaRN / RoPE Scaling:对远距离位置做插值,让原始 RoPE 能外推到更远
  • 记忆压缩(Gemini):把早期 token 压缩成摘要,不是真存 100 万个 KV
  • 滑动窗口(Mistral):只精确保留最近 N 个 token,远处的模糊处理

硬件侧:KV Cache 装得下吗

假设模型侧已经支持(位置编码没问题),硬件能不能装下 KV Cache 是第二个门槛

KV Cache 内存 = 2 × n_layers × seq_len × n_kv_heads × head_dim × 4 字节

模型              seq_len      KV Cache 内存   直观对比
──────────────────────────────────────────────────────
Qwen2.5-0.5B      2,048        50 MB           ← 本项目用的
Qwen2.5-0.5B      32,768       805 MB          ← 模型本身才 2GB!
Qwen2.5-0.5B      1,000,000    24.6 GB         ← 0.5B 小模型也要 24GB?!
Qwen2.5-7B        200,000      22.9 GB
Qwen2.5-72B       1,000,000    655 GB          ← 比模型权重还大 4 倍!
Llama-3-405B      1,000,000    1,032 GB        ← 光缓存就 1TB!

KV Cache 内存和 seq_len 成线性关系:seq_len 翻倍,内存翻倍。这就是为什么长上下文那么贵:不是模型算不动,是内存装不下

计算量:Attention 是平方增长

Attention 分数矩阵 = [seq_len, seq_len],计算量是 O(seq_len²):

seq_len = 2,048:     4 百万个分数
seq_len = 32,768:    10 亿个分数      ← 256 倍
seq_len = 200,000:   400 亿个分数     ← 1 万倍
seq_len = 1,000,000: 1 万亿个分数     ← 2.5 亿倍!

这就是为什么需要 Flash Attention:把 O(n²) 的内存降到 O(n),否则光存注意力矩阵就爆显存了。

工业引擎怎么让 1M 成为可能

光有模型支持 + 硬件够大还不够,还需要推理引擎做工程优化:

技术做什么谁用
PagedAttentionKV Cache 分页管理,按需分配,不预分配整个 seq_lenvLLM / SGLang
KV Cache 量化KV Cache 也量化(fp16→int4),内存缩 4-8 倍vLLM / SGLang
Flash AttentionAttention 分块计算,内存从 O(n²) 降到 O(n)所有 GPU 引擎
Ring Attention超长 attention 跨多卡分布Gemini / Llama 3
滑动窗口只关注最近 N 个 token,远处不管Mistral
记忆压缩早期 token 压缩成摘要Gemini 1.5

本项目的 seq_len

本项目用 seq_len = 2048run.c 里硬编码)。改成 32768 只需改一行:

c
qwen25_0_5b_config(&cfg, 32768);   // 从 2048 改成 32768

内存变化:

seq_len=2048:   KV Cache 50MB,  总内存 2.0GB
seq_len=32768:  KV Cache 805MB, 总内存 2.8GB  ← 还行, 普通电脑能跑
seq_len=200000: KV Cache 4.9GB, 总内存 6.9GB  ← 内存吃紧

模型本身(Qwen2.5-0.5B)支持 32768:它的 RoPE theta=1000000 足以覆盖这个距离。我们的引擎只是没分配那么大的 cache,是工程选择,不是模型限制。

总结

支持长上下文 = 模型支持 + 硬件够大 + 引擎优化

模型侧:
  RoPE theta 够大 / YaRN 缩放 / 记忆压缩
  → 位置编码能区分远距离 token

硬件侧:
  足够的显存装 KV Cache (线性增长)
  + 足够的算力算 attention (平方增长)

引擎侧:
  PagedAttention / Flash Attention / KV 量化 / 多卡分布
  → 把"理论上能做"变成"实际上跑得动"

一句话:200K 和 1M 的差距不是单一因素:模型的位置编码、硬件的显存、引擎的优化,三者缺一不可。Gemini 1M 是三方面都做到极致的工程奇迹。


核心洞察

vLLM 真正值钱的不是算法,是调度

很多人第一次听说 vLLM 会以为它用了什么「更优的 attention 算法」。其实没有:它和你手写的引擎算的是同一个数学公式:

Attention(Q,K,V) = softmax(QKᵀ/√d)V

vLLM 的真正创新在调度层:

  • PagedAttention 把 KV Cache 分页管理,内存利用率从 50% 提到 95%
  • Continuous Batching 把多个请求拼成一个 batch,GPU 利用率从 30% 提到 95%

这两件事和「算得快」无关:同样的 GPU、同样的算子,只是让多个请求共享算力。这是工程上的创新,不是数学上的创新。

你的引擎和 vLLM 算的数学完全一样

问题答案
你的引擎和 vLLM 算的数学一样吗?完全一样(同一个 attention 公式)
为什么 vLLM 快 1000 倍?GPU 并行 + 量化 + 调度优化
vLLM 最核心的创新是什么?PagedAttention + Continuous Batching(调度,不是算法)
你的引擎有价值吗?:它是理解上述一切的基础
该用 vLLM 还是手写?看目的:学习→手写,生产→vLLM

为什么理解原理很重要

不懂数学直接用 vLLM:
  → 调参靠试, 出问题靠重启, 加速靠抄配置
  → vLLM 对你是黑盒

理解了手写引擎再用 vLLM:
  → 知道每个参数在改什么
  → 出问题能定位到算子层
  → 能判断什么场景该用什么引擎
  → vLLM 对你是透明工具

这就是为什么这本书前 9 章带你手写一个引擎:不是为了取代 vLLM,而是为了让你真正看懂 vLLM 在做什么


一句话总结

问题答案
我们和工业引擎算的数学一样吗?完全一样:同一个 attention 公式
差距从哪来?「算得快」(GPU/量化/SIMD/FlashAttn,可逐步加)+「调度好」(Continuous Batching/PagedAttention,vLLM 的护城河)
大模型装不下怎么办?量化、张量并行、流水线、CPU 卸载、MoE:五种方案组合使用
vLLM 最核心的创新是什么?调度,不是算法
你的引擎有意义吗?:它是理解上述一切的基础

vLLM 用 PyTorch,而且不止 PyTorch:它站在 PyTorch + cuBLAS + FlashInfer + 自研调度器的肩膀上。你的引擎是自己从地面盖起。学习目的上,你的方式更能看透原理;生产目的上,vLLM 的方式才能扛住真实流量。

读完这章,你应该能回答:「如果给我无限时间和资源,怎么把我的 2000 行 C 引擎变成 vLLM?」 答案就在 10.10 节的演进路径里:三级台阶,每一级都清晰可拆解。 这就是写一遍手写引擎的最大价值:你不再把 vLLM 当黑盒,而是知道它每一步在做什么、为什么这么做

MIT Licensed