第 10 章 生态对比与性能优化
第 10 章 | 上一章:第 9 章 调试与验证方法论 | 下一章:附录 A 术语总表
本章是整本书的「收尾章」。前面 9 章我们一砖一瓦盖起了自己的引擎, 这章把它放到整个推理引擎生态里:和 llama.cpp / vLLM / SGLang 比, 看清楚自己站在哪里、工业引擎强在哪、差距从哪来。
本章整合自项目的
COMPARISON.md(965 行生态对比)和BOOK.md第十章。
目录
- 一张图看懂四种实现
- 技术栈分层对比
- 为什么不能「一个引擎跑所有模型」
- 算得快 vs 调度好
- 为什么用 GPU 不用 CPU
- 性能差距从哪来
- 大模型装不下怎么办:5 种加载方案
- vLLM 的依赖清单解读
- 各引擎适用场景
- 从手写引擎到工业引擎的演进路径
- 具体差什么:本项目 vs vLLM vs SGLang
- 核心洞察
- 一句话总结
一张图看懂四种实现
先看全局。同样一个 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.cpp | vLLM |
|---|---|---|---|
| 定位 | 学习原理 | 个人 / 本地部署 | 生产级服务 |
| 语言 | C99 | C++17 | Python + 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* |
| 硬件需求 | 任何 CPU | CPU 或 GPU | NVIDIA 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 + 专用内核
这是最直觉的差异。同一个矩阵乘法:
// 你的引擎 (CPU, 单线程, 标量循环)
for (int i = 0; i < d; i++) {
for (int j = 0; j < n; j++) {
val += wrow[j] * x[j];
}
}
// 结果: 896×896 矩阵乘法 ≈ 几十微秒# 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(你的引擎用的方式):
// 预分配固定大小的数组
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 量化 | ×4 | llama.cpp 的算子层 |
| SIMD(NEON / AVX) | ×4 | llama.cpp 的算子层 |
| GPU 并行(CPU→GPU) | ×7 | 硬件切换 |
| FlashAttention | ×2 | CUDA 内核优化 |
| Continuous Batching(并发) | ×12 | vLLM 调度层 |
| 合计 | ×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万) 就能跑 405B5 种方法对照:
| 方法 | 原理 | 省几倍 | 代价 |
|---|---|---|---|
| 量化 | 少用位数存权重 | ×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-python | FlashAttention 等注意力内核 | net.c 的 attention 循环 |
nvidia-cudnn-frontend | NVIDIA 深度学习算子 | net.c 的 rmsnorm/silu |
nvidia-cutlass-dsl | 矩阵乘法模板库 | net.c 的 matmul |
quack-kernels | 量化内核 | (你没有量化) |
humming-kernels | 推理专用内核 | - |
模型 / 分词
| 依赖 | 作用 | 你引擎的对应物 |
|---|---|---|
transformers>=5.5.3 | 模型定义 / 权重加载 | safetensors.c |
tokenizers>=0.21.1 | BPE 分词 | tokenizer.c |
safetensors>=0.6.2 | 权重文件解析 | safetensors.c(你手写的) |
sentencepiece | 另一种分词器 | - |
tiktoken | OpenAI 的分词器 | - |
服务 / API
| 依赖 | 作用 | 你引擎的对应物 |
|---|---|---|
fastapi[standard] | HTTP API 框架 | ❌(你用 CLI) |
starlette | ASGI 框架 | - |
openai>=2.0.0 | OpenAI 兼容客户端 | - |
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 接口 |
| 数据中心高并发 | vLLM | PagedAttention + batching 碾压 |
| 嵌入式 / 边缘 | llama.cpp / 本项目精简版 | 资源受限环境 |
| 研究实验 | transformers(PyTorch) | 灵活,易改模型结构 |
| 极致性能 | TensorRT-LLM | NVIDIA 官方,比 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:位置编码靠旋转角度区分位置:
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 成为可能
光有模型支持 + 硬件够大还不够,还需要推理引擎做工程优化:
| 技术 | 做什么 | 谁用 |
|---|---|---|
| PagedAttention | KV Cache 分页管理,按需分配,不预分配整个 seq_len | vLLM / SGLang |
| KV Cache 量化 | KV Cache 也量化(fp16→int4),内存缩 4-8 倍 | vLLM / SGLang |
| Flash Attention | Attention 分块计算,内存从 O(n²) 降到 O(n) | 所有 GPU 引擎 |
| Ring Attention | 超长 attention 跨多卡分布 | Gemini / Llama 3 |
| 滑动窗口 | 只关注最近 N 个 token,远处不管 | Mistral |
| 记忆压缩 | 早期 token 压缩成摘要 | Gemini 1.5 |
本项目的 seq_len
本项目用 seq_len = 2048(run.c 里硬编码)。改成 32768 只需改一行:
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)VvLLM 的真正创新在调度层:
- 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 当黑盒,而是知道它每一步在做什么、为什么这么做。