一、这玩意儿到底是干嘛的?
说白了,这是一个用C++17从零写的、CPU优先的大语言模型(LLM)推理引擎。它的工作模式很直接:你给它一个GGUF格式的模型文件(比如Llama-3.2-1B-Instruct),再给它一段提示词,它就在你的CPU上逐token生成回复文本。整个过程不需要GPU、不需要CUDA、不需要PyTorch,甚至不需要Python运行时——就一个二进制文件,双击就能跑。
它支持的模型架构是Llama家族,包括Llama 2/3.x系列。支持的量化格式覆盖了主流选择:F32(全精度)、F16(半精度)、Q8_0、Q4_0、Q4_1、Q5_0、Q5_1,以及更高效的K-quants(Q4_K、Q5_K、Q6_K)。其中Q4_K_M是HuggingFace上最常见的量化格式,这个项目能直接加载和运行。
除了基本的文本生成,它还实现了三个硬核扩展:
- 三级SIMD调度:运行时自动检测CPU支持的指令集,在Scalar(纯标量)、AVX2、AVX-512之间动态选择最优路径
- SuperSpec多草稿推测解码:用多个小模型同时"猜"未来token,大模型一次验证,减少解码步数
- Initial-Key Cache选择性KV缓存:长上下文时只保留最重要的KV,防止内存爆炸
二、为什么要从零手搓一个CPU推理引擎?
现在跑大模型,主流方案是调HuggingFace的Transformers库,背后用PyTorch+CUDA在GPU上跑。这套方案的问题是:
第一,GPU不是人人都有。一台带RTX 4090的台式机要两三万,而一台普通的Windows笔记本几乎人人都有。如果能在CPU上跑起来,门槛就低太多了。
第二,部署太重。PyTorch本身几百MB,CUDA驱动几个GB,再加上Python环境,部署到服务器或者边缘设备上,资源占用和依赖链条都很长。
第三,Python的GIL限制。全局解释器锁让多线程并发成了摆设,CPU多核优势发挥不出来。
第四,启动慢。Python解释器+PyTorch的初始化开销,在serverless或者短连接场景下很头疼。
所以这个项目的思路很直接:用C++17重新实现Transformer推理的每一层,直接读GGUF权重文件,在CPU上跑完整的前向传播。体积小、依赖少、启动快、跨平台。
三、核心架构设计思路
3.1 整体链路:从文件到文本
整个推理流程可以概括为五个阶段:
GGUF文件 → 加固加载器 → Tokenizer → Transformer推理 → 采样器 → 输出文本GGUF文件是llama.cpp社区定义的二进制格式,把PyTorch训练好的模型权重、tokenizer配置、模型超参数全部打包在一个文件里。
加固加载器是这个项目的一大亮点。它遵循"不信任任何输入"的安全原则:内存映射文件、边界检查、算术溢出检测、 capped counts、fail-closed验证。即使有人给你一个恶意构造的GGUF文件,加载器也会优雅地拒绝,而不是崩溃或者越界读写。
Tokenizer实现了byte-level BPE分词,直接从GGUF里读取tokenizer配置,支持Llama-3的chat template(自动注入<|begin_of_text|>、<|start_header_id|>等特殊token)。
Transformer推理实现了Llama-family的完整架构:RMSNorm、RoPE位置编码、GQA分组查询注意力、SwiGLU前馈网络、残差连接。
采样器支持多种策略:贪心采样、温度采样、top-k、top-p、min-p,全部带种子控制的RNG。
3.2 面向阶段的设计:Phase 0~9
这个项目按照"推理引擎圣经"的九个阶段逐步构建:
| 阶段 | 内容 |
|---|---|
| Phase 0 | 项目骨架、构建系统、单元测试框架 |
| Phase 1 | 张量基础:形状、步长、内存布局 |
| Phase 2 | 矩阵运算:点积、矩阵乘法、SIMD加速 |
| Phase 3 | 激活函数:ReLU、GELU、Softmax |
| Phase 4 | 归一化:LayerNorm、RMSNorm |
| Phase 5 | 注意力机制:Self-Attention、RoPE、GQA |
| Phase 6 | 完整Transformer层:Attention + FFN + 残差 |
| Phase 7 | 模型加载:GGUF v3 reader、内存映射 |
| Phase 8 | 文本生成:KV Cache、Prefill/Decode循环、采样 |
| Phase 9 | 生产化:CLI、Chat模式、性能调优 |
这种渐进式开发的好处是:每一阶段都有独立的单元测试验证,确保基础扎实后再往上盖楼。
四、代码实现原理拆解
4.1 加固加载器:不信任任何输入
加载GGUF文件时,代码做了层层防护:
魔数验证
文件开头4字节必须是0x67676d6c("ggml"的ASCII)。不对就直接返回错误,防止加载任意文件。
超参数读取
依次读取10个int32参数:词表大小、最大位置编码、中间层维度、注意力头数、隐藏层层数、pad_token_id、嵌入维度、标签数、beam数、量化类型。每个读取都有边界检查。
词表加载
循环读取n_vocab个token。每个token先读长度(4字节),再读字符串。长度异常(负数或超大值)直接报错。
张量加载
每个张量的元数据包括:维度数、名字长度、数据类型、shape数组、名字字符串。代码会校验:
- 维度数在合理范围内(1~4)
- 名字长度不超过上限
- 数据类型是支持的格式之一(F32/F16/Q4_0/Q4_1/Q5_0/Q5_1/Q8_0/Q4_K/Q5_K/Q6_K)
- 计算出的数据大小和文件剩余大小匹配
内存映射
使用操作系统提供的内存映射(memory-mapped file),而不是把整个文件读到堆内存。这样大模型文件(几个GB)不会撑爆RAM,操作系统会自动按需分页加载。
4.2 Tokenizer:Byte-Level BPE
Tokenizer实现了完整的三层架构:
BasicTokenizer
- 可选的NFD Unicode归一化
- 全部转小写
- 用正则表达式按Llama-3的pre-tokenizer规则切分(数字、字母、标点、CJK字符分别处理)
WordpieceTokenizer
- 对每个切分后的片段,从长到短匹配词表
- 如果匹配不上,就拆成子词,前面加
##标记 - 比如"unhappiness"可能切成"un", “##happiness”
FullTokenizer
- 组合Basic + Wordpiece
- 自动在开头加
[CLS](或<|begin_of_text|>),结尾加[SEP](或<|eot_id|>) - 支持Chat模式下的Llama-3模板注入
关键细节:特殊token(如<|eot_id|>)是通过token ID直接注入的,而不是经过BPE编码。这确保了停止条件能精确匹配。
4.3 Transformer推理:Prefill + Decode
推理分为两个阶段,这是生成式模型的标准做法:
Prefill阶段(并行处理)
把用户的prompt一次性tokenize,所有token一起过Transformer。这个阶段可以充分利用矩阵乘法的并行性,计算效率高。同时,每一层的Key和Value矩阵被写入KV Cache。
Decode阶段(逐个生成)
从Prefill的最后一个token开始,每次只处理1个新token:
- 查Embedding表得到输入向量
- 过RMSNorm
- 算Q/K/V。K和V只算新token的,历史的K/V从Cache里读
- 做Attention:Q × K_cacheᵀ → Softmax → × V_cache
- 过FFN(SwiGLU)
- 残差连接
- 重复N层
- 最后的线性投影(tied embeddings时复用token embedding权重)
- Softmax得到下一个token的概率分布
- 采样器选一个token
- 如果选到
<|eot_id|>就停止,否则把新token加入序列,继续Decode
KV Cache的关键作用:没有KV Cache的话,每次生成新token都要重新计算所有历史token的K和V,复杂度是O(n²)。有了KV Cache,复杂度降到O(n),长文本生成才实用。
4.4 注意力机制:GQA + RoPE
Grouped-Query Attention(GQA)
标准Multi-Head Attention里,Q/K/V各有num_heads个头。GQA让K和V共享更少的头(比如Q有32个头,K/V只有8个),每个K/V头服务4个Q头。这样KV Cache的内存占用减少到1/4,推理速度更快,而质量损失很小。
RoPE(Rotary Position Embedding)
传统的位置编码是直接把位置向量加到token embedding上。RoPE的做法更巧妙:它把Q和K向量拆成两半,用旋转矩阵按位置角度旋转,然后把两半拼回去。这样位置信息就"揉"进了注意力分数的计算里,而且天然支持外推(比训练时更长的序列)。
这个项目用的是half-split RoPE(不是interleaved),和HuggingFace的原始布局一致。加载时会把llama.cpp风格的permuted Q/K权重un-permute回来,确保和参考实现逐层对齐。
4.5 SwiGLU前馈网络
Llama-family的FFN不是简单的"线性→激活→线性",而是SwiGLU:
FFN(x) = (Swish(xW₁) ⊗ xW₂) W₃其中:
W₁和W₂是两个并行的线性投影,把维度从n_embd扩到intermediate_sizeSwish是SiLU激活函数:x * sigmoid(x)⊗是逐元素乘法(门控机制)W₃把维度压回n_embd
SwiGLU比标准FFN表达能力更强,是Llama系列效果好的关键之一。
4.6 采样器:从概率到token
模型输出的是词汇表上每个token的概率。采样器决定选哪个:
贪心采样(Greedy)
永远选概率最大的token。确定性强,但输出可能比较死板。
温度采样(Temperature)
把概率分布除以一个温度参数T:
- T→0:趋近贪心
- T=1:保持原分布
- T>1:分布更平坦,输出更随机
Top-K
只保留概率最高的K个token,其他的概率清零后重新归一化。
Top-P(Nucleus Sampling)
按概率从大到小累加,直到累加和≥P,只保留这些token。比Top-K更自适应。
Min-P
设置一个最小概率阈值,只保留概率≥threshold的token。防止低概率的离谱token被选到。
所有采样器都支持种子控制的RNG,同样的种子产生同样的输出,方便复现和调试。
五、硬核特性详解
If you need the complete source code, please add the WeChat number (c17865354792)
5.1 三级SIMD调度
矩阵乘法是推理的瓶颈,而SIMD指令可以一次算多个数。这个项目实现了三级运行时调度:
Level 1:Scalar(标量)
最基础的C++实现,纯循环,不依赖任何特殊指令。所有CPU都能跑。
Level 2:AVX2 + FMA
检测CPU是否支持AVX2和FMA指令集。如果支持,就用256位宽的SIMD寄存器,一次算8个float。点积运算用FMA(乘加融合)指令,减少延迟。
Level 3:AVX-512
如果CPU支持AVX-512,就用512位宽寄存器,一次算16个float。进一步提升吞吐量。
运行时检测
程序启动时调用CPUID指令检测CPU能力,然后设置一个函数指针。后续所有矩阵运算都通过这个指针调用,零运行时开销。
位一致性保证
无论用几个线程、哪种SIMD级别,输出结果完全一致。这是通过确定性的线程调度实现的——每个输出行固定分配给特定线程,没有race condition。
5.2 SuperSpec多草稿推测解码
推测解码(Speculative Decoding)是加速LLM推理的前沿技术。核心思想是:用小模型快速生成草稿token,大模型一次验证多个,减少大模型的调用次数。
这个项目的SuperSpec实现包含:
多草稿器(Multi-Drafter)
同时加载多个草稿模型(比如一个Q8量化、一个Q4量化),各自独立生成草稿序列。每个草稿器有自己的KV Cache。
批量验证(Batch Verifier)
目标模型(大模型)一次前向传播,验证所有草稿token。这里用的是数组结构的批量验证,不是token树。
全局最优接受策略
在所有草稿器的预测中,选择最长被接受的前缀。比如草稿器1猜对了3个token,草稿器2猜对了5个,那就接受5个。
Adaptive-K
动态调整每个草稿器生成的token数量K。如果接受率高,就增加K;如果接受率低,就减少K。用"气泡控制器"算法自动调节。
KV Cache回滚
如果验证到某个token被拒绝,就把所有草稿器的KV Cache回滚到上一个被接受的位置,从那里重新开始。
正确性保证
在贪心验证模式下,SuperSpec的输出和目标模型单独跑的结果完全一致。推测解码只改变速度,不改变结果。
5.3 Initial-Key Cache选择性KV缓存
长上下文时,KV Cache的内存占用随序列长度线性增长。这个项目实现了选择性KV缓存:
三类token的KV被保留
- Initial(注意力汇聚点):序列开头的几个token,注意力分数通常很高,是"注意力汇聚"现象的核心
- Recent(近期上下文):最近N个token,保证局部连贯性
- Top-K Important:中间token中,按"Score Sum"(最近几层注意力分数的滚动和)排序,保留最重要的K个
驱逐策略
当KV Cache超过预算时,把不属于上述三类的token的KV驱逐掉,并做内存压缩。
正确性保证
如果预算大于等于实际序列长度,就退化成全量KV Cache,输出和参考实现完全一致。
使用方式
llm--modelmodel.gguf--prompt"..."--n2000--kv-budget256--kv-recent1285.4 量化格式详解
| 格式 | 每权重位数 | 说明 |
|---|---|---|
| F32 | 32 | 全精度,无损失,体积最大 |
| F16 | 16 | 半精度,几乎无损失,体积减半 |
| Q8_0 | 8 | 每块256个权重共享一个缩放因子,质量接近无损 |
| Q4_0 | 4 | 每块32个权重共享一个缩放因子,体积砍到1/8 |
| Q4_1 | 4 | 比Q4_0多一个偏移量,精度略好 |
| Q4_K | 4 | K-quants,每子块有自己的scale+min,更精细 |
| Q4_K_M | 4 | K-quants混合模式,大部分用Q4_K,敏感层用F16/Q5_K |
| Q5_K | 5 | K-quants,比Q4_K更精细 |
| Q6_K | 6 | K-quants,接近Q8_0的质量 |
K-quants的优势
传统Q4_0/Q4_1是整个矩阵共享一个缩放因子,而K-quants把矩阵分成更小的子块,每个子块有自己的scale和min。这样对权重分布不均匀的情况(比如有些层权重范围很大,有些层很小)处理得更精细,同样4-bit下质量明显更好。
六、相关领域知识点速览
| 知识点 | 一句话解释 |
|---|---|
| Transformer | 用自注意力机制捕捉序列中任意位置关系的神经网络架构 |
| Llama架构 | Meta开源的Decoder-only Transformer,RMSNorm+RoPE+GQA+SwiGLU |
| GGUF格式 | llama.cpp社区定义的二进制模型格式,自描述、支持量化 |
| KV Cache | 缓存历史token的Key和Value,避免重复计算,加速Decode |
| Prefill/Decode | Prefill并行处理prompt,Decode逐个生成token |
| RoPE | 旋转位置编码,通过旋转Q/K向量注入位置信息 |
| GQA | 分组查询注意力,K/V共享更少的头,减少KV Cache内存 |
| SwiGLU | 门控前馈网络,SiLU激活+逐元素乘法,表达能力更强 |
| RMSNorm | 均方根归一化,比LayerNorm更简单高效 |
| BPE分词 | Byte-Pair Encoding,从字符对开始逐步合并,构建子词词表 |
| SIMD(AVX2/AVX-512) | 单指令多数据,一条指令同时处理多个数据元素 |
| FMA | 乘加融合指令,一条指令完成a×b+c,减少延迟 |
| 推测解码 | 小模型快速草稿+大模型批量验证,减少大模型调用次数 |
| 量化(Quantization) | 把32位浮点权重压缩到更低精度,减少体积和内存带宽 |
| K-quants | 每子块独立scale+min的量化策略,4-bit下质量更优 |
| 困惑度(Perplexity) | 衡量语言模型预测能力的指标,越低越好 |
| CPUID | x86指令,查询CPU支持的特性(指令集、缓存大小等) |
| 内存映射(mmap) | 把文件直接映射到虚拟地址空间,按需加载,省内存 |
七、怎么跑起来?环境配置与运行测试手把手教
Python依赖(仅构建时):
pipinstallnumpy7.1 生成参考模型和测试数据
项目自带了一套tiny参考模型(随机权重,用于验证引擎正确性),需要通过Python脚本生成:
python tools\export_weights.py python tools\dump_reference.py python tools\export_tokenizer.py python tools\export_quantized.py python tools\export_gguf.py python tools\export_q6k_test.py这些脚本会:
export_weights.py:生成tiny模型的随机权重dump_reference.py:用numpy实现参考前向传播,保存每层输出export_tokenizer.py:导出tokenizer词表export_quantized.py:生成Q8_0和Q4_0量化版本export_gguf.py:生成GGUF格式的模型文件export_q6k_test.py:生成Q6_K测试数据
7.2 编译
Release版本(推荐):
tools\build.cmd Release这个脚本会自动调用vcvars64.bat设置MSVC环境,然后用CMake+Ninja编译。
带AddressSanitizer的Debug版本(用于内存错误检测):
tools\build.cmd Debug ASAN编译完成后,可执行文件在:
- Release:
build\Release\llm.exe - Debug ASAN:
build\Debug-asan\llm.exe
7.3 运行单元测试
build\Release\unit_tests.exe项目包含100个单元测试,全部通过且AddressSanitizer下无内存错误。测试覆盖:
- 张量运算(形状、步长、索引)
- 矩阵乘法(标量、AVX2、AVX-512路径)
- 激活函数(ReLU、GELU、Softmax)
- 归一化(RMSNorm逐层对比numpy参考)
- 注意力机制(RoPE、GQA)
- 完整Transformer层(cosine similarity > 0.999 vs numpy参考)
- GGUF加载器(边界检查、错误处理)
- 采样器(确定性输出验证)
7.4 运行基本推理
用tiny模型测试(随机权重,输出是乱码,仅用于验证引擎):
build\Release\llm.exe--modelmodels\tiny.lem--prompt"Hello"--n32用tiny GGUF模型:
build\Release\llm.exe--modelmodels\tiny.f32.gguf--prompt"Hello"--n32输出说明:
- 生成的文本输出到stdout
- 统计信息、警告输出到stderr
- 所以
llm.exe ... > out.txt会把纯文本保存到文件
7.5 运行真实模型
第一步:下载模型
从HuggingFace下载一个Llama-family的GGUF模型。推荐:
bartowski/Llama-3.2-1B-Instruct-GGUF选择Q4_K_M、Q8_0或Q4_0格式。Q4_K_M是性价比最高的选择。
第二步:运行推理
build\Release\llm.exe--model.\Llama-3.2-1B-Instruct-Q4_0.gguf--prompt"Explain what a KV cache is."--n128Chat模式(推荐用于对话):
build\Release\llm.exe--model.\Llama-3.2-1B-Instruct-Q4_0.gguf--chat--prompt"What is the capital of France?"--n64--chat会自动:
- 用Llama-3的chat template包装prompt
- 注入
<|begin_of_text|>、<|start_header_id|>system等特殊token - 在
<|eot_id|>处停止生成
自定义系统提示:
build\Release\llm.exe--modelmodel.gguf--chat--system"You are terse."--prompt"..."--n647.6 运行SuperSpec推测解码
build\Release\llm.exe--modelmodels\tiny.lem ^--specmodels\tiny.q8.lem--specmodels\tiny.q4.lem ^--prompt"Hello"--n32--spec参数可以多次使用,指定多个草稿模型。验证时输出和单独跑目标模型完全一致。
7.7 运行Initial-Key Cache
build\Release\llm.exe--modelmodel.gguf--prompt"..."--n2000--kv-budget256--kv-recent128--kv-budget设置KV Cache最大槽位数,--kv-recent设置保留的近期token数。
7.8 计算困惑度
build\Release\llm.exe--modelmodel.gguf--perplexitywiki.test.raw这会计算给定文本文件的分块next-token困惑度,用于客观比较不同量化格式的质量。
7.9 调整线程数
build\Release\llm.exe--modelmodel.gguf--prompt"..."-t8-t指定推理线程数,默认用所有可用核心。
7.10 CLI完整参数参考
| 参数 | 说明 |
|---|---|
--model PATH | 模型文件路径(.gguf或.lem) |
--prompt TEXT | 输入提示词 |
--n N | 最大生成token数 |
--chat | 启用Chat模式(Llama-3模板) |
--system TEXT | 系统提示词(Chat模式) |
-t N | 推理线程数 |
--spec PATH | 草稿模型路径(可多次使用) |
--kv-budget N | KV Cache预算 |
--kv-recent N | 保留的近期token数 |
--perplexity FILE | 计算困惑度 |
--temp FLOAT | 采样温度 |
--top-k N | Top-K采样 |
--top-p FLOAT | Top-P采样 |
--min-p FLOAT | Min-P采样 |
--seed N | RNG种子 |
八、性能数据与基准测试
8.1 量化质量对比(Llama-3.2-1B-Instruct)
在wikitext-2-raw测试集的前8KB上测量(1,855个评分token,512-token窗口,12线程,AVX2):
| 格式 | 文件大小 | 困惑度 | nats/token |
|---|---|---|---|
| Q4_0 | 773 MB | 19.90 | 2.991 |
| Q4_K_M | 808 MB | 18.62 | 2.924 |
| Q8_0 | 1.32 GB | 17.86 | 2.883 |
解读:Q4_K_M只比Q4_0多4.5%的体积,却填补了约63%的质量差距(从Q4_0到Q8_0的gap)。这是K-quants per-sub-block scale+min编码的实测收益。
8.2 吞吐量(tiny模型基准)
在dim=64、2层、vocab=288的tiny模型上,12线程,AVX2:
| 模式 | tok/s(约) |
|---|---|
| 贪心采样 | ~4000 |
| GGUF F32 | ~4000 |
| SuperSpec(2草稿器) | ~700 |
注意:tiny模型的数字主要反映per-token overhead,不代表真实模型的吞吐量。真实1B量化模型在笔记本CPU上通常是单到双位数的tok/s,这是正常的。
8.3 逐层验证
每一层的输出都和numpy参考实现做cosine similarity对比:
- 全精度(F32):cosine > 0.999
- Q8_0量化:cosine > 0.999
- Q4_0量化:cosine > 0.95
贪心采样模式下,完整输出的token序列和参考实现完全一致。
九、总结
这个项目虽然是从零开始的手写实现,但把CPU上跑大语言模型的核心机制都覆盖到了:
- 完整的Llama-family架构:RMSNorm、RoPE、GQA、SwiGLU、残差连接,每一层都用numpy参考验证
- 加固的GGUF加载器:不信任任何输入,边界检查、算术校验、fail-closed
- 多级量化支持:从F32到Q4_K_M,覆盖主流格式,K-quants质量实测优秀
- 三级SIMD调度:运行时CPUID检测,自动选择Scalar/AVX2/AVX-512,位一致性保证
- SuperSpec推测解码:多草稿器+批量验证+Adaptive-K,正确性严格保证
- Initial-Key Cache:选择性KV保留,长上下文内存可控
- 完整的生产化:CLI工具、Chat模式、困惑度评估、100个单元测试、AddressSanitizer清洁
如果你正在研究LLM推理的底层原理,或者需要一个轻量级的CPU推理方案部署到边缘设备,这就是一个很好的参考实现。纯C++17、零Python依赖、CPU就能跑Llama 3——在很多场景下,这就足够了。