一、这玩意儿到底是干嘛的?
说白了,这是一个用纯C++实现的BERT模型家族CPU推理引擎。它能干的事情很明确:你丢给它一段文本,它在CPU上跑完BERT的完整前向传播,输出分类概率或者句子嵌入向量。整个过程不依赖PyTorch、不依赖CUDA、不依赖Python运行时,就一个二进制文件,双击就能跑。
它支持的模型家族包括:
BERT:原始双向编码器,12层768维,做分类或嵌入任务
DistilBERT:BERT的蒸馏版,6层768维,体积砍半、速度翻倍
RoBERTa:优化训练策略后的BERT变体,效果更稳
分类模型:在BERT基础上接个分类头,做情感分析、主题分类等
seq2seq模型:序列到序列(翻译、摘要等,开发中)
最狠的是量化支持。模型权重可以存成F32(32位全精度)、F16(16位半精度)、Q4_0/Q4_1(4位量化)。一个bert-base-uncased全精度要417MB,压到F16只要一半,4-bit量化后体积更小,推理速度还更快。
而且它是跨平台的。x86有AVX2/FMA加速,ARM有NEON加速,Apple Silicon也能跑。ggml底层自动检测你的CPU指令集,该用的SIMD优化一个不落。
二、为什么要搞这个?
现在做BERT推理,主流方案是调HuggingFace的Transformers库,背后用PyTorch。这套方案的问题是:
第一,太重。 PyTorch本身就要几百MB,再加上模型权重,部署到服务器或者边缘设备上,资源占用很大。
第二,依赖链条长。 Python环境、PyTorch、CUDA(如果用GPU)、各种pip包,部署时出一个问题就要排查半天。
第三,Python的GIL限制。 多线程并发时,Python的全局解释器锁(GIL)会让多核CPU的优势发挥不出来。
第四,启动慢。 Python解释器+PyTorch的初始化开销,在serverless或者短连接场景下很致命。
所以这个项目的思路很直接:把PyTorch训练好的模型权重导出来,用C++和ggml重新实现推理逻辑,直接跑在CPU上,零Python依赖。
三、核心设计思路
3.1 基于ggml:不重复造轮子
ggml是一个专门为端侧推理设计的张量计算库,和llama.cpp是同一个底层。它提供:
多种数据类型:F32、F16、Q4_0、Q4_1(4-bit量化)
自动SIMD优化:x86(AVX/AVX2/FMA)、ARM(NEON)、Apple Accelerate
多线程并行:矩阵乘法自动拆分到多个CPU核心
计算图(Computation Graph):描述前向传播的计算流程,自动调度执行
内存池管理:减少动态内存分配的开销
这个项目没有自己写矩阵乘法、Softmax、LayerNorm,而是直接调用ggml的API。这样可以把精力集中在模型结构的正确还原和tokenizer的实现上。
3.2 模型文件格式:自描述的二进制文件
模型从HuggingFace下载后,需要用一个Python脚本转换成ggml专用的二进制格式。这个格式的结构设计得很巧妙:
文件头(4 bytes)
魔数:0x67676d6c,就是"ggml"四个字符的ASCII码。C++加载时先读这个,对不上直接报错,防止加载错文件。
超参数区(40 bytes)
10个int32参数:词表大小、最大序列长度、中间层维度、注意力头数、隐藏层层数、pad_token_id、嵌入维度、标签数、beam数、量化类型
C++端根据这些参数动态分配内存、创建张量,不需要硬编码模型结构
词表区(变长)
对每个token:先写长度(4 bytes),再写token字符串(len bytes)
重复n_vocab次,构建token→id的映射字典
张量权重区(变长,重复N次)
每个张量的元数据:维度数(4B)、名字长度(4B)、数据类型(4B)、shape(4B×维度数)、名字(name_len B)
然后是实际数据:F32直接写float数组,F16写half数组,Q4_0/Q4_1按块压缩存储
这种自描述格式的好处是:C++端完全不需要知道模型结构,根据文件内容动态创建张量、建立名字到指针的映射。换一个新的BERT变体,只要转换脚本把权重名字映射对,C++代码不用改。
3.3 面向对象的模型结构
代码用C++的面向对象方式把BERT拆成了清晰的层次:
BertEmbedding(嵌入层)
word_embeddings:把token ID转成向量
position_embeddings:给每个位置加位置编码
token_type_embeddings:区分句子A和句子B(单句任务可省略)
LayerNorm:归一化 + 缩放 + 偏移
BertTransformer(编码器层)× 12每一层里面有两大部分:
Q/K/V线性变换:输入分别过三个权重矩阵
reshape + permute:把768维拆成12个头×64维,调整维度顺序
scale:除以√d_head,防止点积值过大
KQ = K × Q:算注意力分数矩阵
Softmax:把分数转成概率
KQV = V × KQ:用注意力概率加权Value
reshape回来:合并12个头,恢复768维
输出投影 + 残差连接 + LayerNorm
先扩维:768 → 3072(intermediate_size)
GELU激活
再压回:3072 → 768
残差连接 + LayerNorm
分类头(Classifier Head)
Pooler:取[CLS]token的输出,过一个线性层+Tanh
Classifier:768 → n_labels(比如2分类就是2维)
Softmax:输出概率分布
3.4 Tokenizer:WordPiece分词
文本不能直接喂给模型,要先切成token。这里用的是和HuggingFace完全兼容的WordPiece分词器,实现分三层:
BasicTokenizer(基础预处理)
可选的NFD归一化(去掉重音符号)
转小写
用utf8proc库做Unicode字符分类
按空格、标点、中文字符切分
WordpieceTokenizer(子词切分)
对每个单词,从长到短匹配词表
如果单词不在词表里,拆成子词,前面加##标记
FullTokenizer(完整流程)
先调BasicTokenizer做预处理
再调WordpieceTokenizer做子词切分
自动加[CLS](开头)和[SEP](结尾)
注意:tokenizer对CJK(中日韩)字符做了特殊处理,每个中文字符单独成一个token。这对中文任务很重要。
3.5 两种推理模式
模式一:命令行工具(bert-main)
加载模型 → 输入文本 → tokenize → 推理 → 打印分类概率
适合调试、测试、批量处理
模式二:REST服务(bert-rest)
基于oatpp框架提供HTTP API
客户端POST JSON请求 → 服务端推理 → 返回JSON响应
适合集成到Web应用、微服务架构
四、代码实现原理拆解
4.1 模型加载(bertencoder_load_from_file)
这是整个推理流程的第一步,也是最复杂的一步:
打开文件:以二进制模式读取ggml模型文件
验证魔数:读4字节,必须是0x67676d6c
读超参数:10个int32,打印出来供调试
读词表:循环n_vocab次,每次读长度+字符串,构建token_to_id和id_to_token映射
创建tokenizer:用读到的词表初始化FullTokenizer
确定权重类型:根据f16字段,选择GGML_TYPE_F32/F16/Q4_0/Q4_1
计算内存需求:遍历所有张量,按类型和尺寸计算总内存,打印出来(比如"ggml ctx size = 417.73 MB")
创建ggml上下文:申请一块大内存池,后续所有张量都在这个池里分配
创建张量:按模型结构创建每个权重张量,同时建立名字 → 张量指针的映射表(std::map)
读权重数据:循环读取每个张量的元数据和实际数据,校验shape和size,写入对应的ggml_tensor
计算推理内存:用4个token做一次"预热"推理,算出每个token需要多少内存,后续批量推理时据此分配缓冲区
加载完成后会打印:
model size = 417.65 MB / num tensors = 201mem_per_token 0 KB, mem_per_input 0 MB4.2 前向推理(eval流程)
推理的核心在forward函数里,用ggml的计算图来描述整个前向过程:
Embedding层
// token ID → 查word_embeddings表inpL = ggml_get_rows(ctx0, word_embeddings, token_layer);// 加位置编码inpL = ggml_add(ctx0, ggml_get_rows(ctx0, position_embeddings, positions), inpL);// 加token type(如果有)inpL = ggml_add(ctx0, inpL, token_type_0);// LayerNorminpL = ggml_norm(ctx0, inpL, norm_eps);inpL = ggml_mul(ctx0, inpL, ln_e_w);inpL = ggml_add(ctx0, inpL, ln_e_b);Transformer层(循环12次)
// 1. Self-AttentionQcur = ggml_add(ctx0, ggml_mul_mat(ctx0, q_w, cur), q_b); // Q线性变换Qcur = ggml_reshape_3d(ctx0, qLin, d_head, n_head, N); // 拆成12个头Qcur = ggml_scale(ctx0, Qcur, 1.0f/sqrt(d_head)); // 缩放Q = ggml_permute(ctx0, Qcur, 0, 2, 1, 3); // 调整维度// K和V同理...KQ = ggml_mul_mat(ctx0, K, Q); // 注意力分数KQ = ggml_soft_max(ctx0, KQ); // SoftmaxKQV = ggml_mul_mat(ctx0, V, KQ); // 加权ValueKQV = ggml_permute(ctx0, KQV, 0, 2, 1, 3); // 恢复维度cur = ggml_reshape_2d(ctx0, ggml_cont(ctx0, KQV), n_embd, N); // 合并头// 2. 残差 + LayerNormcur = ggml_add(ctx0, ggml_mul_mat(ctx0, o_w, cur), o_b); // 输出投影cur = ggml_add(ctx0, cur, inpL); // 残差连接cur = ggml_norm(ctx0, cur, norm_eps);cur = ggml_add(ctx0, ggml_mul(ctx0, cur, ln_att_w), ln_att_b);// 3. FeedForwardcur = ggml_add(ctx0, ggml_mul_mat(ctx0, ff_i_w, cur), ff_i_b); // 扩维cur = ggml_gelu(ctx0, cur); // GELUcur = ggml_add(ctx0, ggml_mul_mat(ctx0, ff_o_w, cur), ff_o_b); // 压维cur = ggml_add(ctx0, att_output, cur); // 残差cur = ggml_norm(ctx0, cur, norm_eps);cur = ggml_add(ctx0, ggml_mul(ctx0, cur, ln_out_w), ln_out_b);分类头
// 取[CLS]位置(第一个token)的输出cur = ggml_view_2d(ctx0, inpL, n_embd, 1, ...);// Poolercur = ggml_add(ctx0, ggml_mul_mat(ctx0, pool_w, cur), pool_b);cur = ggml_tanh(ctx0, cur);// Classifiercur = ggml_add(ctx0, ggml_mul_mat(ctx0, cls_w, cur), cls_b);// Softmaxcur = ggml_soft_max(ctx0, cur);执行计算图
ggml_build_forward_expand(&gf, output);ggml_graph_compute_with_ctx(ctx0, &gf, n_threads);ggml会自动把计算图拆成多个节点,按依赖关系调度,多线程并行执行。
4.3 批量推理
代码支持两种批量模式:
真批量(n_batch_size == n_inputs)
把所有输入的token矩阵拼在一起
一次前向传播算出所有结果
效率高,但要求所有输入长度相同(或者padding到相同长度)
伪批量(n_batch_size < n_inputs)
把输入分成若干组,每组n_batch_size个
每组分别调用推理
兼容不同长度的输入,但效率略低
目前实现的是伪批量,真批量还在开发中。
4.4 内存管理
模型内存:加载时一次性分配,ggml_context管理,模型生命周期内不释放
推理内存:每次推理时动态分配ggml_context,推理完立即释放(ggml_free)
初始缓冲区大小:512MB(MEM_SIZE = 512 * 1024 * 1024)
根据"预热"时算出的mem_per_token,动态调整实际使用的缓冲区大小
批量推理时,缓冲区大小 = mem_per_input × batch_size,上限1GB
最大batch:默认4(MAX_BATCH_N = 4),超出会分批处理
4.5 C API 设计
项目暴露了一套C风格的API,方便被其他语言调用:
// 加载模型BertBaseCtx* bertencoder_load_from_file(const char* fname);// 单条文本分类void bert_encode_classify(BertBaseCtx* ctx, int32_t n_threads, const char* text, float* labels);// 批量文本分类void bert_encode_batch_classify(BertBaseCtx* ctx, int32_t n_threads, int32_t n_batch_size, int32_t n_inputs, const char** texts, float** labels);// 释放模型void bert_free(BertBaseCtx* ctx);这套API可以被Go、Python(ctypes)、Rust等语言直接调用,实现跨语言集成。
4.6 REST服务(基于oatpp)
REST服务的架构:
BertReq(请求DTO)
class BertReq : public oatpp::DTO { DTO_FIELD(String, input, "input"); // 请求体里的"input"字段};ResultDto(响应DTO)
class ResultDto : public oatpp::DTO { DTO_FIELD(String, result); // 分类概率数组的JSON字符串};BertClsController(控制器)
ENDPOINT("POST", "classify", classify, BODY_DTO(Object<bertreq>, req)) { // 1. 从JSON请求体里提取input文本 // 2. 调用bert_encode_classify做推理 // 3. 把概率数组转成JSON字符串 // 4. 包装成ResultDto返回}Server启动流程
// 1. 加载模型// 2. 创建HTTP路由// 3. 注册Controller// 4. 创建连接处理器和连接提供者// 5. 启动服务器,监听指定端口oatpp::network::Server server(connectionProvider, connectionHandler);server.run();五、DistilBERT支持
除了标准BERT,项目还完整支持了DistilBERT。DistilBERT是BERT的蒸馏版,结构上有几个关键区别:
没有token_type_embeddings
DistilBERT不支持句子对任务(比如NSP),所以去掉了token_type_embeddings
嵌入层只有word_embeddings + position_embeddings + LayerNorm
没有pooler
标准BERT在[CLS]token后接一个Pooler(线性层+Tanh)
DistilBERT直接去[CLS]的输出,或者取所有token的平均
有pre_classifier
DistilBERT的分类头结构是:[CLS] → pre_classifier(768→768,ReLU)→ classifier(768→n_labels)
比BERT多了一个中间层
代码里通过DistilBertEmbedding、DistilBertTransformer、DistilBertClassifierModel三个类完整实现了这些差异。加载函数是distilbert_load_from_file,和BERT的加载函数并行存在。
六、相关领域知识点速览
知识点 | 一句话解释 |
| BERT | 双向Transformer编码器,预训练+微调范式,NLP里程碑 |
| DistilBERT | BERT的知识蒸馏版,6层替代12层,保留97%能力 |
| RoBERTa | 优化训练策略的BERT,去掉NSP任务,更大batch更长训练 |
| Transformer编码器 | 自注意力+前馈网络,层层提炼语义表示 |
| Self-Attention | 每个token都"看看"其他所有token,根据相关性加权 |
| Multi-Head Attention | 12个头并行计算不同子空间的注意力,捕捉多角度关系 |
| GELU激活函数 | 比ReLU更平滑的激活,Transformer标配 |
| LayerNorm | 对每层输出做归一化,稳定训练、加速收敛 |
| 残差连接(Residual) | 绕过子层直接相加,缓解梯度消失,支持深层网络 |
| WordPiece分词 | 把单词拆成子词单元,处理未登录词(OOV) |
| ggml | 轻量级张量计算库,支持量化和多线程,llama.cpp同款 |
| F16半精度 | 16位浮点数,体积减半,精度损失极小 |
| Q4_0/Q4_1量化 | 4位整数量化,每64个权重共享缩放因子,体积砍到1/6 |
| oatpp | 现代C++ Web框架,支持REST API、JSON、路由 |
| SIMD(AVX2/NEON) | 单指令多数据,一条指令同时算多个数,大幅加速矩阵运算 |
七、这玩意儿能用来干啥?
文本分类服务:情感分析、垃圾邮件检测、新闻主题分类,部署成REST API供前端调用
语义搜索后端:把文档编码成向量,用户查询时做相似度匹配
NLP微服务:在Kubernetes集群里跑轻量级NLP推理Pod,资源占用远低于PyTorch
边缘设备部署:ARM设备、树莓派、嵌入式系统上跑BERT,做本地文本处理
多语言支持:基于多语言BERT(mBERT)做跨语言分类
知识蒸馏验证:验证蒸馏后的模型在C++端的推理效果是否和PyTorch一致
八、怎么跑起来?环境配置与运行测试手把手教
8.1 环境要求
编译环境:
CMake ≥ 3.12
C++20 编译器(GCC 9+ 或 Clang 10+)
Python 3(用于模型转换)
外部依赖库:
utf8proc:Unicode字符处理库,tokenizer需要
oatpp:C++ Web框架,编译REST服务需要
Python依赖:
pip3 install numpy transformers huggingface_hub8.2 安装外部库
安装utf8proc(Ubuntu/Debian):
sudo apt-get install libutf8proc-dev安装oatpp(需要手动编译):
git clone https://github.com/oatpp/oatpp.gitcd oatppmkdir build && cd buildcmake .. -DOATPP_BUILD_TESTS=OFFmake -j$(nproc)sudo make install设置oatpp环境变量(根据实际安装路径调整):
export LIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LIBRARY_PATHexport LD_LIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LD_LIBRARY_PATHexport CPLUS_INCLUDE_PATH=/usr/local/include/oatpp-1.3.0/oatpp:$CPLUS_INCLUDE_PATH8.3 拉代码和子模块
git clone <仓库地址>cd <项目目录>git submodule update --init --recursiveggml是子模块,必须初始化,否则编译不过。
8.4 下载和转换模型
方式一:直接下载预转换的模型
pip3 install -r requirements.txtpython3 models/download-ggml.py download bert-base-uncased f32方式二:从HuggingFace转换自己的模型
cd models# 克隆一个模型(示例)git clone https://huggingface.co/yilong2001/bert_cls_example# 转换(生成F32和F16两个版本)sh run_conversions.sh bert-base-uncased转换脚本bert-classify-to-ggml.py会:
读取PyTorch的state_dict
把权重转成numpy数组
按ggml格式写入二进制文件
F16版本会把2D的weight矩阵转成float16
8.5 编译
编译动态库(给其他语言调用):
mkdir buildcd buildcmake .. -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Releasemake -j$(nproc)cd ..编译静态库 + 示例程序(命令行工具 + REST服务):
mkdir buildcd buildcmake .. -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Releasemake -j$(nproc)cd ..CMake会自动检测CPU架构,开启AVX2/FMA等加速指令。支持的编译选项包括:
选项 | 说明 |
| BERTS_AVX | 启用AVX指令(默认ON) |
| BERTS_AVX2 | 启用AVX2指令(默认ON) |
| BERTS_FMA | 启用FMA指令(默认ON) |
| BERTS_NATIVE | 启用-march=native(针对本机CPU优化) |
| BERTS_LTO | 启用链接时优化 |
| BERTS_OPENBLAS | 使用OpenBLAS加速 |
| BERTS_STATIC | 静态链接 |
8.6 运行命令行推理
./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin -p "This movie is absolutely fantastic!"输出示例:
bertencoder_load_from_file: loading model from 'models/bert-base-uncased/ggml-model-f32.bin' - please wait ...bertencoder_load_from_file: n_vocab = 30522bertencoder_load_from_file: max_position_embeddings = 512bertencoder_load_from_file: intermediate_size = 3072bertencoder_load_from_file: num_attention_heads = 12bertencoder_load_from_file: num_hidden_layers = 12bertencoder_load_from_file: n_embd = 768bertencoder_load_from_file: f16 = 0bertencoder_load_from_file: ggml ctx size = 417.73 MBbertencoder_load_from_file: ......................... donebertencoder_load_from_file: model size = 417.65 MB / num tensors = 201main: number of tokens in prompt = 7[0.0234, 0.9766]main: load time = 156.61 msmain: eval time = 32.76 ms / 4.68 ms per tokenmain: total time = 189.38 ms结果解读:[0.0234, 0.9766]表示负面情感概率2.34%,正面情感概率97.66%。
8.7 启动REST服务
./build/bin/bert-rest -m models/bert-base-uncased/ggml-model-f32.bin --port 8090输出:
bertencoder_load_from_file: loading model from 'models/bert-base-uncased/ggml-model-f32.bin' - please wait ......I |2023-11-05 00:05:29 1699113929846361| MyApp:Server running on port 8090测试API:
curl -X POST http://localhost:8090/classify \ -H "Content-Type: application/json" \ -d '{"input": "This movie is absolutely fantastic!"}'响应:
{"result": "[0.0234,0.9766]"}8.8 批量推理测试
./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin \ -t 6 # 使用6个线程批量推理的C API:
const char* texts[] = { "This is great!", "This is terrible.", "I don't know what to think."};float* labels[3];float label_data[3][2]; // 3条文本,每条2个分类概率for (int i = 0; i < 3; i++) { labels = label_data;}bert_encode_batch_classify(ctx, 6, 2, 3, texts, labels);// labels[0] = [0.02, 0.98] // 正面// labels[1] = [0.95, 0.05] // 负面// labels[2] = [0.45, 0.55] // 中性8.9 性能调优
选择量化格式:
# F32:最准,最慢,最大(~417MB)./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f32.bin# F16:几乎一样准,快一点,小一半(~209MB)./build/bin/bert-main -m models/bert-base-uncased/ggml-model-f16.bin# Q4_0:体积砍到1/6,速度最快,精度略有损失(~70MB)./build/bin/bert-main -m models/bert-base-uncased/ggml-model-q4_0.bin调整线程数:
./build/bin/bert-main -m model.bin -t 8 # 用8个线程启用OpenBLAS(如果安装了):
cmake .. -DBERTS_OPENBLAS=ON8.10 自己写代码调用
C++调用:
#include "bertbase.h"#include "bertencoder.h"int main() { // 加载模型 auto* ctx = bert::bertencoder_load_from_file("model.bin"); // 单条推理 float labels[2]; bert::bert_encode_classify(ctx, 4, "Hello world", labels); printf("Prob: %.4f, %.4f\n", labels[0], labels[1]); bert::bert_free(ctx); return 0;}Go调用(通过CGO):
// cgo LDFLAGS: -L. -lbertencoder -lggml// include "bertbase.h"import "C"func classify(text string) []float32 { ctx := C.bertencoder_load_from_file(C.CString("model.bin")) labels := make([]C.float, 2) C.bert_encode_classify(ctx, 4, C.CString(text), &labels[0]) // ...}If you need the complete source code, please add the WeChat number (c17865354792)
九、总结
这个项目虽然代码量不大,但把"在CPU上跑BERT家族模型"这件事做得非常扎实:
基于ggml:复用成熟的张量计算和量化基础设施,自动SIMD优化,跨平台
自描述模型格式:从HuggingFace无缝转换,C++端零硬编码,换模型不用改代码
完整BERT实现:嵌入层、12层Transformer、分类头,每一层都用ggml计算图精确还原
DistilBERT支持:完整实现蒸馏版结构差异,体积减半速度翻倍
两种使用模式:命令行工具适合调试测试,REST服务适合生产部署
C API + 跨语言:可以被Go、Python、Rust等语言调用,集成到任意技术栈
量化支持:F32/F16/Q4_0/Q4_1四种格式,在精度和速度之间灵活取舍
如果你正在做一个需要BERT推理的项目,又不想拖着一个几百MB的PyTorch环境,这就是一个很好的参考方案。纯C++、零Python依赖、CPU就能跑、还能部署成REST服务——在很多场景下,这就足够了。
Welcome to follow WeChat official account【程序猿编码】
---
</bertreq>
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!