一、这玩意儿到底是干嘛的?
说白了,这是一个用纯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
每一层里面有两大部分:
自注意力(Self-Attention)
- Q/K/V线性变换:输入分别过三个权重矩阵
- reshape + permute:把768维拆成12个头×64维,调整维度顺序
- scale:除以√d_head,防止点积值过大
- KQ = K × Q:算注意力分数矩阵
- Softmax:把分数转成概率
- KQV = V × KQ:用注意力概率加权Value
- reshape回来:合并12个头,恢复768维
- 输出投影 + 残差连接 + LayerNorm
前馈网络(Feed-Forward)
- 先扩维: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(子词切分)
- 对每个单词,从长到短匹配词表
- 如果单词不在词表里,拆成子词,前面加
##标记 - 比如"unhappiness" → “un”, “##happiness”
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 = 201 mem_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(constchar*fname);// 单条文本分类voidbert_encode_classify(BertBaseCtx*ctx,int32_tn_threads,constchar*text,float*labels);// 批量文本分类voidbert_encode_batch_classify(BertBaseCtx*ctx,int32_tn_threads,int32_tn_batch_size,int32_tn_inputs,constchar**texts,float**labels);// 释放模型voidbert_free(BertBaseCtx*ctx);这套API可以被Go、Python(ctypes)、Rust等语言直接调用,实现跨语言集成。
4.6 REST服务(基于oatpp)
REST服务的架构:
BertReq(请求DTO)
classBertReq:publicoatpp::DTO{DTO_FIELD(String,input,"input");// 请求体里的"input"字段};ResultDto(响应DTO)
classResultDto:publicoatpp::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::Serverserver(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一致
八、怎么跑起来?环境配置与运行测试手把手教
If you need the complete source code, please add the WeChat number (c17865354792)
8.1 环境要求
编译环境:
- CMake ≥ 3.12
- C++20 编译器(GCC 9+ 或 Clang 10+)
- Python 3(用于模型转换)
外部依赖库:
- utf8proc:Unicode字符处理库,tokenizer需要
- oatpp:C++ Web框架,编译REST服务需要
Python依赖:
pip3installnumpy transformers huggingface_hub8.2 安装外部库
安装utf8proc(Ubuntu/Debian):
sudoapt-getinstalllibutf8proc-dev安装oatpp(需要手动编译):
gitclone https://github.com/oatpp/oatpp.gitcdoatppmkdirbuild&&cdbuild cmake..-DOATPP_BUILD_TESTS=OFFmake-j$(nproc)sudomakeinstall设置oatpp环境变量(根据实际安装路径调整):
exportLIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LIBRARY_PATHexportLD_LIBRARY_PATH=/usr/local/lib/oatpp-1.3.0:$LD_LIBRARY_PATHexportCPLUS_INCLUDE_PATH=/usr/local/include/oatpp-1.3.0/oatpp:$CPLUS_INCLUDE_PATH8.3 拉代码和子模块
gitclone<仓库地址>cd<项目目录>gitsubmodule update--init--recursiveggml是子模块,必须初始化,否则编译不过。
8.4 下载和转换模型
方式一:直接下载预转换的模型
pip3install-rrequirements.txt python3 models/download-ggml.py download bert-base-uncased f32方式二:从HuggingFace转换自己的模型
cdmodels# 克隆一个模型(示例)gitclone https://huggingface.co/yilong2001/bert_cls_example# 转换(生成F32和F16两个版本)shrun_conversions.sh bert-base-uncased转换脚本bert-classify-to-ggml.py会:
- 读取PyTorch的state_dict
- 把权重转成numpy数组
- 按ggml格式写入二进制文件
- F16版本会把2D的weight矩阵转成float16
8.5 编译
编译动态库(给其他语言调用):
mkdirbuildcdbuild cmake..-DBUILD_SHARED_LIBS=ON-DCMAKE_BUILD_TYPE=Releasemake-j$(nproc)cd..编译静态库 + 示例程序(命令行工具 + REST服务):
mkdirbuildcdbuild cmake..-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-mmodels/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 = 30522 bertencoder_load_from_file: max_position_embeddings = 512 bertencoder_load_from_file: intermediate_size = 3072 bertencoder_load_from_file: num_attention_heads = 12 bertencoder_load_from_file: num_hidden_layers = 12 bertencoder_load_from_file: n_embd = 768 bertencoder_load_from_file: f16 = 0 bertencoder_load_from_file: ggml ctx size = 417.73 MB bertencoder_load_from_file: ......................... done bertencoder_load_from_file: model size = 417.65 MB / num tensors = 201 main: number of tokens in prompt = 7 [0.0234, 0.9766] main: load time = 156.61 ms main: eval time = 32.76 ms / 4.68 ms per token main: total time = 189.38 ms结果解读:[0.0234, 0.9766]表示负面情感概率2.34%,正面情感概率97.66%。
8.7 启动REST服务
./build/bin/bert-rest-mmodels/bert-base-uncased/ggml-model-f32.bin--port8090输出:
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-XPOST 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-mmodels/bert-base-uncased/ggml-model-f32.bin\-t6# 使用6个线程批量推理的C API:
constchar*texts[]={"This is great!","This is terrible.","I don't know what to think."};float*labels[3];floatlabel_data[3][2];// 3条文本,每条2个分类概率for(inti=0;i<3;i++){labels[i]=label_data[i];}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-mmodels/bert-base-uncased/ggml-model-f32.bin# F16:几乎一样准,快一点,小一半(~209MB)./build/bin/bert-main-mmodels/bert-base-uncased/ggml-model-f16.bin# Q4_0:体积砍到1/6,速度最快,精度略有损失(~70MB)./build/bin/bert-main-mmodels/bert-base-uncased/ggml-model-q4_0.bin调整线程数:
./build/bin/bert-main-mmodel.bin-t8# 用8个线程启用OpenBLAS(如果安装了):
cmake..-DBERTS_OPENBLAS=ON8.10 自己写代码调用
C++调用:
#include"bertbase.h"#include"bertencoder.h"intmain(){// 加载模型auto*ctx=bert::bertencoder_load_from_file("model.bin");// 单条推理floatlabels[2];bert::bert_encode_classify(ctx,4,"Hello world",labels);printf("Prob: %.4f, %.4f\n",labels[0],labels[1]);bert::bert_free(ctx);return0;}Go调用(通过CGO):
// #cgo LDFLAGS: -L. -lbertencoder -lggml// #include "bertbase.h"import"C"funcclassify(textstring)[]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])// ...}九、总结
这个项目虽然代码量不大,但把"在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服务——在很多场景下,这就足够了。