ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型推理底层揭秘:C++17实现Transformer每一层的前向传播

大模型推理底层揭秘:C++17实现Transformer每一层的前向传播

一、这玩意儿到底是干嘛的?

说白了,这是一个用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:

  1. 查Embedding表得到输入向量
  2. 过RMSNorm
  3. 算Q/K/V。K和V只算新token的,历史的K/V从Cache里读
  4. 做Attention:Q × K_cacheᵀ → Softmax → × V_cache
  5. 过FFN(SwiGLU)
  6. 残差连接
  7. 重复N层
  8. 最后的线性投影(tied embeddings时复用token embedding权重)
  9. Softmax得到下一个token的概率分布
  10. 采样器选一个token
  11. 如果选到<|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_size
  • Swish是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被保留

  1. Initial(注意力汇聚点):序列开头的几个token,注意力分数通常很高,是"注意力汇聚"现象的核心
  2. Recent(近期上下文):最近N个token,保证局部连贯性
  3. Top-K Important:中间token中,按"Score Sum"(最近几层注意力分数的滚动和)排序,保留最重要的K个

驱逐策略
当KV Cache超过预算时,把不属于上述三类的token的KV驱逐掉,并做内存压缩。

正确性保证
如果预算大于等于实际序列长度,就退化成全量KV Cache,输出和参考实现完全一致。

使用方式

llm--modelmodel.gguf--prompt"..."--n2000--kv-budget256--kv-recent128

5.4 量化格式详解

格式每权重位数说明
F3232全精度,无损失,体积最大
F1616半精度,几乎无损失,体积减半
Q8_08每块256个权重共享一个缩放因子,质量接近无损
Q4_04每块32个权重共享一个缩放因子,体积砍到1/8
Q4_14比Q4_0多一个偏移量,精度略好
Q4_K4K-quants,每子块有自己的scale+min,更精细
Q4_K_M4K-quants混合模式,大部分用Q4_K,敏感层用F16/Q5_K
Q5_K5K-quants,比Q4_K更精细
Q6_K6K-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/DecodePrefill并行处理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)衡量语言模型预测能力的指标,越低越好
CPUIDx86指令,查询CPU支持的特性(指令集、缓存大小等)
内存映射(mmap)把文件直接映射到虚拟地址空间,按需加载,省内存

七、怎么跑起来?环境配置与运行测试手把手教

Python依赖(仅构建时):

pipinstallnumpy

7.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."--n128

Chat模式(推荐用于对话):

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"..."--n64

7.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 NKV Cache预算
--kv-recent N保留的近期token数
--perplexity FILE计算困惑度
--temp FLOAT采样温度
--top-k NTop-K采样
--top-p FLOATTop-P采样
--min-p FLOATMin-P采样
--seed NRNG种子

八、性能数据与基准测试

8.1 量化质量对比(Llama-3.2-1B-Instruct)

在wikitext-2-raw测试集的前8KB上测量(1,855个评分token,512-token窗口,12线程,AVX2):

格式文件大小困惑度nats/token
Q4_0773 MB19.902.991
Q4_K_M808 MB18.622.924
Q8_01.32 GB17.862.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——在很多场景下,这就足够了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进