纯 C++ 手写 LLM 推理引擎:分页 KV 缓存、连续批、多卡张量并行全部内置
一、为什么要在 C++ 里从零手写一个 LLM 推理引擎?
如果你玩过 LLM 部署,大概率经历过这样的痛苦:用 Python 调 API 很快,但一到生产环境就要面对 PyTorch 动辄几个 G 的内存开销、GPU 利用率不到 30% 的尴尬,以及"换个模型就要重装一套依赖"的部署噩梦。
于是出现了一批"反框架"项目——不依赖 PyTorch、不依赖 Transformers、不依赖 vLLM,纯 C++ 手写推理路径。其中有一个项目尤其值得关注:它支持 Llama 3.2、Qwen 2.5、Qwen 3、Mistral 等主流模型,实现了分页 KV Cache、连续批处理、Flash Attention、多 GPU 张量并行等工业级优化,同时还提供了一个 OpenAI 兼容的 HTTP 服务器和 Web UI。
这个项目的设计哲学很明确:把大模型推理的"黑盒"打开,让每个算子、每块内存、每次 GPU 通信都透明可控。 本文就带你从源码层面拆解它的完整实现。
二、行业技术知识点速查
在深入代码之前,先快速过一遍这个引擎涉及的核心技术概念。
2.1 BPE 分词器
BPE(Byte Pair Encoding)是目前大模型最主流的分词算法。它的核心思想很简单:
- 从字符级词汇表开始
- 统计文本中相邻 token 对的频率
- 把频率最高的 pair 合并成一个新 token
- 重复步骤 2-3 直到达到目标词汇表大小
这个项目实现的 BPE 分词器灵感来自 OpenAI 的 tiktoken,关键优化点在于零堆分配——所有内存操作在编译期或初始化期完成,推理时不产生任何 heap allocation,这对低延迟推理至关重要。
2.2 KV Cache 与分页管理
Transformer 解码阶段有个性能瓶颈:每生成一个新 token,都需要重新计算之前所有 token 的 Key 和 Value 矩阵。KV Cache 的做法是把历史 K/V 缓存起来,新 token 只需与缓存中的 K/V 做注意力计算。
但传统连续内存分配有个问题:不同请求的序列长度不同,容易产生内存碎片。这个项目采用了 Paged KV Cache 策略——把 KV 缓存切成固定大小的"页"(page),用类似操作系统的分页机制来管理,自动扩容,彻底解决碎片化问题。
2.3 Flash Attention
传统 Attention 的计算复杂度是 O(n²),因为需要把完整的 QK^T 矩阵加载到 GPU 显存中做 softmax。Flash Attention 的核心优化是 tiling + recomputation:
- 把矩阵切成小块(tile),每块 fit 进 L1 缓存
- 先算 softmax 的 online 版本(不需要存中间矩阵)
- 反向传播时重新计算中间结果(用时间换空间)
这个项目通过 TinyFA 库实现 Flash Attention,在长序列场景下可以显著降低显存占用并提升速度。
2.4 连续批处理(Continuous Batching)
传统批处理有一个问题:一批请求中,如果某个请求先生成完了,它占用的计算资源就浪费了,要等整批结束才能处理新请求。连续批处理的做法是:
- 每个请求独立跟踪自己的生成进度
- 一个请求生成完毕后,立即从队列中取出新请求填补空缺
- 预填充(prefill)阶段也支持分块(chunked),避免长 prompt 阻塞短请求
2.5 张量并行(Tensor Parallelism)
当模型太大单卡放不下时,可以把权重矩阵沿某个维度切分到多张 GPU 上。以注意力层为例,Q/K/V 三个投影矩阵分别放在不同的 GPU 上,每卡算自己那部分,最后用 NCCL 做 all-reduce 合并结果。
三、架构设计思路
3.1 项目目录结构
整个项目的源码组织得非常清晰,按功能域分成了几个独立的目录:
src/
├── CMakeLists.txt # 构建配置
├── binding/ # 语言绑定(Python/Rust等)
├── distributed/ # 分布式推理(NCCL张量并行)
├── engine/ # 推理引擎核心
│ ├── GPTEngine.cpp/h # GPT推理引擎主类
│ ├── PagedKVCache.cpp/h # 分页KV缓存
│ ├── Scheduler.cpp/h # 调度器(连续批处理)
│ ├── Sampler.cpp/h # 采样器
│ ├── ForwardContext.cpp/h # 前向传播上下文
│ └── CUDAGraphRunner.cpp/h # CUDA Graph执行器
├── huggingface/ # HF模型权重加载
├── kernel/ # CUDA算子
│ ├── AttentionOps.cu/h # 注意力算子
│ ├── Dispatch.h # 类型分发器
│ ├── EmbeddingOps.cu/h # 词嵌入算子
│ ├── GemvOps.cu/h # 矩阵向量乘法
│ ├── RopeOps.cu/h # RoPE位置编码
│ └── SamplerOps.cu/h # 采样CUDA算子
├── layer/ # 网络层
│ ├── Activation.h # 激活函数
│ ├── Attention.h # 注意力层
│ ├── DecoderLayer.h # Decoder层
│ ├── GatedMLP.h # 门控MLP
│ ├── Linear.h # 线性层
│ └── RoPE.h # RoPE层封装
├── model/ # 模型定义
├── tokenizer/ # 分词器
│ ├── Tokenizer.cpp/h # 分词器主类
│ ├── BPE.cpp/h # BPE核心
│ ├── Base.cpp/h # 分词器基类
│ ├── ByteFallback.cpp/h # Byte Fallback
│ ├── ByteLevel.cpp/h # ByteLevel预处理
│ ├── ChatTemplate.cpp/h # Chat模板
│ ├── Fuse.cpp/h # 分词融合
│ ├── Metaspace.cpp/h # 元空间处理
│ ├── Regex.cpp/h # 正则表达式
│ ├── Replace.cpp/h # 字符替换
│ ├── Split.cpp/h # 分割
│ ├── Strip.cpp/h # 去除空白
│ ├── TemplateProcessing.cpp/h# 模板处理
│ └── UnicodeNorm.cpp/h # Unicode标准化
└── util/ # 工具函数
If you need the complete source code, please add the WeChat number (c17865354792)
3.2 数据流向总览
用户文本输入
│
▼
┌─────────────┐
│ Tokenizer │ ← BPE + ByteLevel + Metaspace + Regex + Replace + Split + Strip
└──────┬──────┘
│ token IDs
▼
┌─────────────┐
│ GPTEngine │ ← 调度器(Scheduler) + 分页KV缓存(PagedKVCache)
│ │ 前向传播(ForwardContext) + CUDA Graph
└──────┬──────┘
│ logits
▼
┌─────────────┐
│ Sampler │ ← top-p / top-k / temperature / min-p
└──────┬──────┘
│ new token ID
▼
┌─────────────┐
│ Decoder │ ← DecoderLayer × N
│ Layers │ [Attention(RoPE+FlashAttention) + GatedMLP(SwiGLU) + Linear]
└──────┬──────┘
│
▼
输出文本(通过Tokenizer decode)
3.3 依赖组件说明
| 依赖库 | 用途 |
|---|---|
| TinyTorch | 张量运算(矩阵乘法、归一化等) |
| TinyFA | Flash Attention 实现 |
| RapidJSON | JSON 解析(配置文件/API请求) |
| pcre2 | 正则表达式(分词器预处理) |
| utf8proc | Unicode 标准化处理 |
| ankerl::unordered_dense | 高性能哈希表(词表查找) |
| moodycamel::ConcurrentQueue | 无锁并发队列(调度器任务队列) |
| cpp-httplib | 轻量级 HTTP 服务器 |
四、核心代码实现原理
4.1 分词器管道:从文本到 Token ID
分词器是这个引擎的第一个关卡。它的实现采用了**处理管道(Pipeline)**模式,每个预处理步骤都是一个独立的模块:
原始文本 → UnicodeNorm(NFKC标准化) → Replace(字符替换) → Split(分割) →
Strip(去空白) → ByteLevel(字节级编码) → BPE(合并) → Metaspace(元空间) → Token IDs
核心文件 Tokenizer.cpp 负责协调整个管道。BPE 合并逻辑在 BPE.cpp 中实现,使用了 ankerl::unordered_dense 哈希表来存储合并规则,查找速度远超标准 std::unordered_map。
特别值得关注的是 ChatTemplate.cpp(约 50KB 代码量最大的文件),它实现了一个类 Jinja2 的模板引擎,支持各种模型的对话格式(LLaMA、Qwen、Mistral 等都有各自的 chat template)。这意味着你不需要手动拼接 system/user/assistant 消息,引擎会自动处理。
4.2 推理引擎核心:GPTEngine 与 Scheduler
GPTEngine 是整个推理的总控类。它的主要职责包括:
- 加载模型权重(从 HuggingFace safetensors 格式)
- 管理 KV Cache 的生命周期
- 协调前向传播的各个阶段
- 处理多请求的调度与批处理
Scheduler(约 27KB 代码)是最复杂的模块之一,实现了连续批处理的核心逻辑:
请求队列 → 预填充阶段(chunked prefill) → 解码阶段(逐token生成)
│ │
▼ ▼
新请求进入队列 完成请求移出队列
空闲slot → 从队列取新请求预填充 请求完成→释放KV Cache页
关键设计点:
- Chunked Prefill:长 prompt 不会一次性全部预填充,而是分成多个 chunk 逐步处理,避免阻塞短请求
- Auto-sizing KV Cache:分页缓存自动扩容,不需要用户手动指定最大序列长度
- CUDA Graph:对解码阶段(形状固定的推理)捕获 CUDA Graph,消除 CPU-GPU 同步开销
4.3 分页 KV Cache:PagedKVCache
分页 KV Cache 的思路借鉴了 vLLM 的设计。核心数据结构如下:
KV Cache 物理存储 = 连续的 GPU 内存块(预分配)
│
├── Page 0: [KV_block_0, KV_block_1, ..., KV_block_N]
├── Page 1: [KV_block_{N+1}, ..., KV_block_{2N}]
└── ...
每个请求的 KV 状态 = 一组 Page 的索引列表(类似进程的地址空间)
PagedKVCache.h 定义了页面管理接口,PagedKVCache.cpp 实现了页面的分配、释放和复用。当序列长度超过当前分配的页面数时,自动申请新的物理页面并追加到序列中。
4.4 CUDA 算子层:kernel/ 目录
kernel/ 目录包含了所有 GPU 算子的 CUDA 实现:
AttentionOps.cu — 注意力算子,集成 Flash Attention:
- 实现 scaled dot-product attention:Attention(Q,K,V) = softmax(QK^T / √d_k)V
- 通过 TinyFA 库调用优化的 Flash Attention kernel
- 支持 GQA(Grouped Query Attention),即多个 query head 共享一个 key/value head
RopeOps.cu — RoPE 旋转位置编码:
- 对 Q 和 K 向量施加旋转矩阵,注入位置信息
- 公式:RoPE(pos, head) = R(pos) × W,其中 R 是旋转矩阵
GemvOps.cu — 矩阵向量乘法:
- 用于 Decoder 层中的线性投影(Q/K/V/W/O 的矩阵乘法)
- 针对 decode 阶段(batch size = 1)做了专门优化
SamplerOps.cu — 采样算子:
- 在 GPU 上直接执行 top-p、temperature scaling、multinomial sampling
- 避免 logits 从 GPU 拷贝到 CPU 再采样的开销
Dispatch.h — 类型分发器:
- 编译期根据数据类型(FP32/FP16/BF16)分发到不同的 kernel 实例
- 用模板元编程实现零开销的类型抽象
4.5 网络层实现:layer/ 目录
DecoderLayer.h — 每一层 Decoder 的结构:
输入 → LayerNorm → MultiHeadAttention(RoPE + FlashAttention) → Dropout → 残差连接 →
LayerNorm → GatedMLP(SwiGLU) → Dropout → 残差连接 → 输出
GatedMLP.h — 门控 MLP(SwiGLU 激活):
- 现代 LLM(Llama、Qwen)普遍使用 SwiGLU 替代传统的 ReLU/GELU
- 结构:MLP(x) = (Linear₁(x) ⊙ SiLU(Linear₂(x))) × Linear₃(x)
- 其中 ⊙ 是逐元素乘法,SiLU(x) = x × sigmoid(x)
Attention.h — 注意力层封装:
- 管理 Q/K/V/O 四个投影矩阵
- 调用 kernel/ 中的 AttentionOps 执行实际计算
- 支持 GQA:当 num_query_heads > num_kv_heads 时,K/V 做 repeat 广播
4.6 多 GPU 张量并行:distributed/ 目录
张量并行的核心思路是把大矩阵沿行或列切分,多卡并行计算:
模型层权重 W [shape: (out_dim, in_dim)] 沿列切分为 W₀, W₁, ..., W_{N-1}
│
├── GPU 0: 只持有 W₀,计算 out_part₀ = X @ W₀
├── GPU 1: 只持有 W₁,计算 out_part₁ = X @ W₁
├── ...
└── GPU N-1: 只持有 W_{N-1},计算 out_part_{N-1} = X @ W_{N-1}
最后 all-reduce 合并: output = out_part₀ + out_part₁ + ... + out_part_{N-1}
初始化方式支持两种:
- env://(默认):通过环境变量 RANK、WORLD_SIZE、MASTER_ADDR、MASTER_PORT 初始化 NCCL 通信
- tcp://host:port:直接指定 TCP 地址进行 rendezvous
五、环境配置与运行测试教程
5.1 基础环境要求
- CMake ≥ 3.10
- C++17 兼容编译器(GCC 7+ / Clang 5+ / MSVC 2017+)
- CUDA Toolkit 11.0+(GPU 推理必需)
- NCCL(多 GPU 张量并行必需)
5.2 编译步骤
# 1. 克隆仓库(注意 --recurse-submodules 获取子模块依赖)
git clone --recurse-submodules 代码托管平台上的项目仓库地址
cd 项目根目录
# 2. 创建构建目录
mkdir build
cd build
# 3. 配置构建(Release 模式)
cmake -B . -DCMAKE_BUILD_TYPE=Release
# 4. 编译
cmake --build . --config Release
5.3 下载模型文件
支持从 HuggingFace 下载模型:
# Llama 3.2
git clone HuggingFace 上的 Llama 3.2 1B 模型仓库
# Qwen 2.5
git clone HuggingFace 上的 Qwen2.5 0.5B 模型仓库
git clone HuggingFace 上的 Qwen2.5 3B 模型仓库
# Qwen 3
git clone HuggingFace 上的 Qwen3 1.7B 模型仓库
# Mistral
git clone HuggingFace 上的 Mistral 7B v0.3 模型仓库
5.4 推理测试
cd examples/inference/bin
./推理示例程序 --model /path/to/model \
--device cuda \
--dtype bf16 \
--max-tokens 32 \
--temperature 0.8 \
--top-p 0.9 \
--input "The future of AI is"
参数说明:
| 参数 | 默认值 | 说明 |
|---|---|---|
| –model | (必填) | HuggingFace 模型目录路径 |
| –device | cuda | 设备类型(cpu/cuda) |
| –dtype | bf16 | 数据精度(fp32/fp16/bf16) |
| –max-tokens | 32 | 最大生成 token 数 |
| –temperature | 0.8 | 采样温度(越高越随机) |
| –top-p | 0.9 | Top-p 核采样阈值 |
| –input | “The future of AI is” | 输入提示文本 |
| –max-graph-batch | 64 | CUDA Graph 最大 batch size |
| –tensor-parallel | 1 | 张量并行 GPU 数量 |
| –tp-init | env:// | 并行初始化方法 |
5.5 启动 HTTP 服务器(OpenAI 兼容 API)
cd server/bin
./GPT --model /path/to/model --port 8080
启动后可以通过浏览器访问内置 Web UI,也可以通过 API 调用:
| 端点 | 方法 | 说明 |
|---|---|---|
| /v1/models | GET | 列出可用模型 |
| /v1/completions | POST | 文本补全 |
| /v1/chat/completions | POST | 对话补全(支持 SSE 流式) |
服务器额外参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
| –host | 0.0.0.0 | 服务器监听地址 |
| –max-tokens | 4096 | 每请求最大生成 token 数 |
| –min-p | 0.0 | Min-p 采样阈值 |
| –chat-template | auto | 自定义对话模板(Jinja2 字符串或文件路径) |
| –web-dir | auto | Web UI 目录路径 |
| –max-batch-tokens | 8192 | 每批最大 token 数 |
| –prefill-chunk-size | 512 | 预填充每步最大 token 数 |
5.6 多 GPU 张量并行推理
以 2 张 GPU 为例:
# 终端 1:Rank 0(同时担任 HTTP 服务器)
RANK=0 WORLD_SIZE=2 MASTER_ADDR=127.0.0.1 MASTER_PORT=29500 \
./GPT --model /path/to/model --tensor-parallel 2
# 终端 2:Rank 1(纯工作进程)
RANK=1 WORLD_SIZE=2 MASTER_ADDR=127.0.0.1 MASTER_PORT=29500 \
./GPT --model /path/to/model --tensor-parallel 2
注意:张量并行要求 --device cuda 且模型不能使用 tied word embeddings(即输入嵌入和输出嵌入权重不共享)。
5.7 Python 绑定
项目还提供了 Python 绑定,可以直接在 Python 中使用:
# pip install .
import tinygpt
enc = tinygpt.Tokenizer()
enc.init_with_config("tokenizer.json", "tokenizer_config.json")
ids = enc.encode("This is a test")
六、五大落地用途
6.1 学习研究
这是该项目最大的价值所在。整个推理管线——从分词、权重加载、前向传播到采样生成——全部用 C++ 手写,没有依赖任何深度学习框架。对于想深入理解 LLM 推理底层原理的开发者来说,这是一份极好的"解剖教材"。
6.2 算子优化实验
由于所有 CUDA kernel 都是手写代码,你可以直接修改 kernel/ 目录下的 .cu 文件进行性能调优。比如替换 GemvOps 中的矩阵乘法实现、调整 Flash Attention 的 tile size、或者尝试新的量化方案。
6.3 生产环境部署
纯 C++ 实现意味着没有 Python 运行时依赖,编译出来的二进制文件可以直接部署到生产服务器。配合 HTTP 服务器和 OpenAI 兼容 API,可以快速搭建私有化 LLM 服务。
6.4 多卡推理扩展
内置的 NCCL 张量并行支持多 GPU 推理,对于 7B 以上规模的模型,单卡显存可能不够。通过简单的环境变量配置即可扩展到多卡,适合拥有多 GPU 的实验室或中小型企业。
6.5 边缘设备适配
虽然目前主要面向 GPU,但项目也支持 CPU 推理模式(--device cpu)。结合模型量化技术,未来可以部署到资源受限的边缘设备上。
七、总结与展望
这个项目展示了一个完整的、工业级质量的 C++ LLM 推理引擎应该长什么样:
- 分层清晰:从 tokenizer → engine → kernel → layer,每层职责单一,易于理解和修改
- 优化到位:分页 KV Cache、连续批处理、Flash Attention、CUDA Graph、多精度支持——这些都是现代推理框架的标配
- 工程完善:CMake 构建、多语言绑定、HTTP 服务器、Web UI、多 GPU 并行——开箱即用
未来可能的优化方向包括:INT4/INT8 量化推理、Speculative Decoding(投机解码)、更细粒度的流水线并行、以及向 WebAssembly 等更多平台的移植。
对于任何想要深入理解大模型推理底层实现、或者需要在生产环境中部署轻量级 LLM 服务的开发者来说,这个项目都值得花时间去仔细研读源码。
更多推荐

所有评论(0)