news 2026/10/24 2:07:45

甩掉PyTorch,纯C++把BERT家族模型跑在CPU上:从模型转换到REST推理服务全链路实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
甩掉PyTorch,纯C++把BERT家族模型跑在CPU上:从模型转换到REST推理服务全链路实战

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

说白了,这是一个用纯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
每一层里面有两大部分:

  1. 自注意力(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
  2. 前馈网络(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)

这是整个推理流程的第一步,也是最复杂的一步:

  1. 打开文件:以二进制模式读取ggml模型文件
  2. 验证魔数:读4字节,必须是0x67676d6c
  3. 读超参数:10个int32,打印出来供调试
  4. 读词表:循环n_vocab次,每次读长度+字符串,构建token_to_id和id_to_token映射
  5. 创建tokenizer:用读到的词表初始化FullTokenizer
  6. 确定权重类型:根据f16字段,选择GGML_TYPE_F32/F16/Q4_0/Q4_1
  7. 计算内存需求:遍历所有张量,按类型和尺寸计算总内存,打印出来(比如"ggml ctx size = 417.73 MB")
  8. 创建ggml上下文:申请一块大内存池,后续所有张量都在这个池里分配
  9. 创建张量:按模型结构创建每个权重张量,同时建立名字 → 张量指针的映射表(std::map)
  10. 读权重数据:循环读取每个张量的元数据和实际数据,校验shape和size,写入对应的ggml_tensor
  11. 计算推理内存:用4个token做一次"预热"推理,算出每个token需要多少内存,后续批量推理时据此分配缓冲区

加载完成后会打印:

model size = 417.65 MB / num tensors = 201 mem_per_token 0 KB, mem_per_input 0 MB

4.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里程碑
DistilBERTBERT的知识蒸馏版,6层替代12层,保留97%能力
RoBERTa优化训练策略的BERT,去掉NSP任务,更大batch更长训练
Transformer编码器自注意力+前馈网络,层层提炼语义表示
Self-Attention每个token都"看看"其他所有token,根据相关性加权
Multi-Head Attention12个头并行计算不同子空间的注意力,捕捉多角度关系
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_hub

8.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_PATH

8.3 拉代码和子模块

gitclone<仓库地址>cd<项目目录>gitsubmodule update--init--recursive

ggml是子模块,必须初始化,否则编译不过。

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会:

  1. 读取PyTorch的state_dict
  2. 把权重转成numpy数组
  3. 按ggml格式写入二进制文件
  4. 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=ON

8.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服务——在很多场景下,这就足够了。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:01:53

CSS Grid 入门:二维布局时代的降维打击

在面对极其复杂的网页骨架时&#xff0c;Flexbox 会显得有些捉襟见肘。想象一下&#xff0c;你要构建一个包含页眉、页脚、侧边栏和主内容区的复杂仪表盘&#xff0c;如果只用 Flexbox&#xff0c;你可能需要写无数个嵌套的容器。今天&#xff0c;我们将迎来 CSS 布局史上的终极…

作者头像 李华
网站建设 2026/10/24 2:00:45

把自定义规则送进 Wazuh-Rules 的新手贡献实战手册

把自定义规则送进 Wazuh-Rules 的新手贡献实战手册 【免费下载链接】Wazuh-Rules Advanced Wazuh Rules for more accurate threat detection. Feel free to implement within your own Wazuh environment, contribute, or fork! 项目地址: https://gitcode.com/GitHub_Trend…

作者头像 李华
网站建设 2026/10/24 1:57:53

2005-2023年 各省区域创新数据 xlsx+dta

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:56:58

鸿蒙 PC 底层开发技术详解(三):鸿蒙 PC 上的工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华