资讯详情

纯C++手搓DeepSeek推理引擎:两千行代码实现CPU量化推理

📅 2026/10/31 13:10:14 | 华诺云谱 👁 阅读
纯C++手搓DeepSeek推理引擎:两千行代码实现CPU量化推理
1. 为什么我要用纯 C 手搓一个 DeepSeek 推理引擎1.1 从一次“显存焦虑”说起事情得从我把 DeepSeek 模型往本地搬的那天说起。手头一台老工作站显卡显存就那么点跑个量化版模型勉强能塞进去但稍微把上下文拉长一点或者 batch 开大一点直接 OOM 给你看。那段时间我翻了不少部署方案vLLM、各种推理框架都试过GPU 路线确实香可一旦硬件受限或者你压根就想在纯 CPU 环境里跑比如某些边缘盒子、老服务器、甚至就是想省电能选的路子其实不多。市面上的 CPU 推理方案不是没有llama.cpp 就是标杆但它的代码量摆在那想彻底吃透、想按自己的需求裁剪学习成本不低。于是我就冒出一个念头DeepSeek 这类模型的结构其实没那么玄乎我能不能用两千行左右的纯 C 代码从零把一个能跑通的 CPU 推理引擎写出来性能还别太丢人这个项目就是干这个的。它不依赖任何深度学习框架不需要 CUDA纯 C 加 OpenMP 做并行把 DeepSeek 的权重加载、量化反量化、矩阵乘法、注意力计算、KV Cache 管理这一整套流程全部手写实现。跑通之后实测下来单机 CPU 上的推理速度能接近一些主流实现的水平虽然谈不上超越但对于一个两千行的教学级引擎来说我觉得已经够意思了。这篇文章我会把整个项目的设计思路、核心代码逻辑、量化方案选型、OpenMP 并行技巧、以及我踩过的那些坑全部摊开讲清楚。适合谁看如果你会一点 C对 Transformer 结构有基本概念想搞明白“推理引擎到底在算什么”或者你正打算自己动手写一个轻量推理器那这篇应该对你有用。哪怕你只是想找个能跑的 DeepSeek 本地推理方案里面的量化参数和并行配置也能直接抄。1.2 两千行到底能装下什么先给个预期管理。两千行代码不可能把 DeepSeek 全系列所有变体都支持得面面俱到。我这个引擎的核心目标是支持 DeepSeek 系列的主流稠密结构和 MoE 结构的基础推理支持 INT8/INT4 量化权重支持 KV Cache支持多线程 CPU 并行。它不是一个生产级的推理服务没有连续批处理、没有 PagedAttention、没有复杂的调度器但它是一个结构完整、逻辑透明、可以逐行读懂的推理内核。我特意把代码控制在这个量级是因为超过三千行之后阅读者很容易迷失在工程细节里反而看不清推理的本质。两千行刚好能让你在一个周末读完并且有精力去改它、调它、拿它做实验。提示这个项目的定位是“教学 轻量实用”不要拿它去对标工业级推理框架的吞吐量。它的价值在于透明和可改造。2. 整体架构设计与方案选型2.1 推理引擎的最小闭环一个能跑通 DeepSeek 的推理引擎不管代码多少必须包含这几个模块权重加载与解析、量化反量化、线性层矩阵乘法、注意力机制、前馈网络、KV Cache、采样输出。我把它们组织成了一条清晰的流水线从模型文件读进来到吐出 token中间每一步都是显式调用没有隐藏的魔法。整体数据流是这样的输入 token id 经过 embedding 查表变成向量然后进入 N 层 Transformer Block。每个 Block 里先做 RMSNorm再走自注意力QKV 投影、RoPE 旋转位置编码、注意力打分、KV Cache 读写接着残差连接再来一次 RMSNorm 和前馈网络对于 MoE 层则是路由到专家最后残差。所有层走完过最终的 RMSNorm 和输出投影得到 logits采样出下一个 token。这个流程和主流实现是一致的区别只在于我用最朴素的 C 数组和循环把它写出来没有用任何张量库。这样做的好处是每一行计算你都能对应到数学公式调试的时候心里有底。2.2 为什么选纯 C 而不是别的选纯 C 有几个很实际的考虑。第一零依赖。我不想让读者为了跑这个引擎去装一堆 Python 包或者编译复杂的第三方库一个 g 加 OpenMP 就能编干净利落。第二性能可控。C 让我能精确控制内存布局和循环顺序这对 CPU 推理的性能影响巨大用 Python 调库你根本不知道底层发生了什么。第三可移植。编译出来就是一个可执行文件扔到任何有 CPU 的机器上都能跑这对边缘部署场景很友好。当然代价也有就是所有轮子都得自己造矩阵乘法、内存对齐、线程调度全得手写。但正是这个过程让我把推理的每个细节都摸透了。2.3 量化方案的选择逻辑DeepSeek 的原始权重是 FP16 或 BF16 的直接拿 CPU 跑内存带宽和算力都吃不消。量化是必须的。我最终选了INT8 为主、INT4 为辅的方案分组量化group-wise quantizationgroup size 取 64 或 128。为什么是分组量化而不是全局量化因为全局量化用一个 scale 覆盖整个权重矩阵精度损失太大尤其是 DeepSeek 这种权重分布跨度大的模型。分组量化把权重按列切成小块每块独立算 scale 和 zero point精度能拉回来不少。group size 选 64 是我实测下来的平衡点再小元数据开销上去了再大精度又掉。INT4 我保留作为可选项主要用在显存/内存特别紧张的场景。INT4 的精度损失比 INT8 明显但在很多对话任务上还能接受。反量化的时候我用的是(q - zero_point) * scale这个标准公式整数运算转浮点再参与矩阵乘法。注意量化权重的存储布局一定要和你的矩阵乘法内核匹配否则反量化会变成性能瓶颈。我一开始没注意这点反量化开销占了总时间的 30%后来调整了内存排布才降下来。3. 核心模块的代码级拆解3.1 权重加载从文件到内存的每一步权重加载看起来简单其实坑不少。DeepSeek 的权重文件通常是 safetensors 格式我需要自己解析这个格式。safetensors 的结构是前 8 个字节是 header 长度接着是 JSON header描述每个张量的名字、dtype、shape 和偏移量后面才是真正的数据。我的做法是先读 header把每个张量的元信息存到一个 map 里然后按需读取。这里有个关键点不要一次性把所有张量读进内存DeepSeek 即使量化后也有好几个 G全读进来内存直接爆。我的策略是分层加载用到哪层读哪层配合 KV Cache 的内存管理。读取的时候还要注意字节序和对齐。safetensors 是小端序数据段是按 8 字节对齐的。我写了一个read_tensor函数根据 dtype 分派到不同的读取逻辑INT8 直接按字节读FP16 需要转成 FP32 再存或者保留 FP16 在计算时转换。// 简化的张量读取逻辑 struct TensorInfo { std::string dtype; std::vectorsize_t shape; size_t offset; }; void load_tensor(const char* data, const TensorInfo info, void* out) { if (info.dtype I8) { memcpy(out, data info.offset, numel(info.shape)); } else if (info.dtype F16) { // FP16 转 FP32 const uint16_t* src reinterpret_castconst uint16_t*(data info.offset); float* dst static_castfloat*(out); for (size_t i 0; i numel(info.shape); i) { dst[i] fp16_to_fp32(src[i]); } } }这段代码看着朴素但它是整个引擎的地基。我踩过的坑是一开始没处理对齐某些张量读出来是错位的导致输出全是乱码。后来严格按 8 字节对齐读问题就没了。3.2 量化与反量化精度和速度的博弈量化这块我单独抽了一个模块。核心是两个函数quantize和dequantize。量化在模型转换阶段做一次反量化在推理时每次矩阵乘法前做。分组量化的实现是这样的把权重矩阵按行或按列取决于你的矩阵乘法布局分成若干组每组算一个 scale 和 zero point。scale 用(max - min) / (2^bits - 1)算zero point 用round(-min / scale)算。存储的时候量化值用 int8 或 int4 存scale 和 zero point 用 FP32 存。// 分组量化 void quantize_group(const float* src, int8_t* dst, float* scales, int32_t* zero_points, int group_size, int numel) { for (int g 0; g numel / group_size; g) { float min_val FLT_MAX, max_val -FLT_MAX; for (int i 0; i group_size; i) { float v src[g * group_size i]; min_val std::min(min_val, v); max_val std::max(max_val, v); } float scale (max_val - min_val) / 255.0f; int32_t zp static_castint32_t(roundf(-min_val / scale)); scales[g] scale; zero_points[g] zp; for (int i 0; i group_size; i) { float v src[g * group_size i]; int32_t q static_castint32_t(roundf(v / scale)) zp; dst[g * group_size i] static_castint8_t(std::clamp(q, 0, 255) - 128); } } }反量化就是逆操作(q 128 - zp) * scale。这里有个性能技巧反量化可以和矩阵乘法融合不必先反量化整个矩阵再乘而是在乘法的内层循环里边反量化边累加。这样能省一次内存往返实测能快 15% 到 20%。INT4 的处理稍微麻烦点因为一个字节要存两个值。我用的是高低位拆分读的时候先取字节再根据位置取高四位或低四位然后反量化。INT4 的 scale 和 zero point 计算方式和 INT8 一样只是范围变成 0 到 15。3.3 矩阵乘法CPU 推理的性能命门矩阵乘法是推理里最耗时的部分占了总计算量的 80% 以上。CPU 上做矩阵乘法核心就三件事循环顺序、内存对齐、并行化。循环顺序我采用的是i-k-j而不是直觉上的i-j-k。为什么因为i-k-j能让内层循环连续访问 B 矩阵的行缓存命中率高。i-j-k会导致 B 矩阵的列访问跨步大缓存频繁失效。这个优化看起来小实测能差出两三倍。// i-k-j 循环顺序的矩阵乘法 void matmul(const float* A, const float* B, float* C, int M, int N, int K) { #pragma omp parallel for for (int i 0; i M; i) { for (int k 0; k K; k) { float a A[i * K k]; for (int j 0; j N; j) { C[i * N j] a * B[k * N j]; } } } }内存对齐我用的是aligned_alloc按 64 字节对齐配合 SIMD 指令能进一步加速。不过为了代码简洁我没在两千行里手写 AVX而是依赖编译器自动向量化。开-O3 -marchnative之后g 能自动把内层循环向量化效果还不错。OpenMP 的并行粒度也值得说。我一开始在最外层i上并行但发现线程负载不均因为不同行的计算量可能不一样。后来改成按块并行把i切成若干块每块分给一个线程负载就均衡多了。线程数我一般设成物理核心数超线程反而会拖慢因为矩阵乘法是计算密集型超线程的资源竞争得不偿失。3.4 注意力机制与 KV Cache注意力这块DeepSeek 用的是多头注意力加 RoPE 旋转位置编码。RoPE 的实现关键是预计算 cos 和 sin 表然后对 Q 和 K 的每一对维度做旋转。我预计算了一个[max_seq_len, head_dim/2]的表推理时直接查避免重复算三角函数。KV Cache 是自回归推理的必备。每生成一个 token只需要算当前 token 的 Q然后和 Cache 里所有的 K、V 做注意力。Cache 的结构我设计成[num_layers, 2, num_heads, max_seq_len, head_dim]K 和 V 分开存。内存分配用malloc一次到位避免频繁申请释放。// KV Cache 写入 void write_kv_cache(float* cache, const float* k, const float* v, int layer, int head, int pos, int head_dim) { int offset ((layer * 2) * num_heads head) * max_seq_len * head_dim; memcpy(cache offset pos * head_dim, k, head_dim * sizeof(float)); memcpy(cache offset max_seq_len * head_dim pos * head_dim, v, head_dim * sizeof(float)); }注意力的打分我用的是标准的softmax(QK^T / sqrt(d))softmax 里减最大值防溢出这个细节不能省否则长序列下很容易出 NaN。我一开始偷懒没减结果跑到几百个 token 就开始输出乱码排查了半天才发现是数值溢出。提示KV Cache 的大小是num_layers * 2 * num_heads * max_seq_len * head_dim * sizeof(float)DeepSeek 这种层数多的模型max_seq_len 设大了内存占用很可观建议根据实际需求设别一上来就拉满。3.5 MoE 层的路由逻辑DeepSeek 的部分版本用了 MoE混合专家结构前馈网络不是单一 MLP而是一组专家加一个路由器。路由器算出门控分数选 top-k 个专家把 token 分发给它们最后加权求和。我的实现里路由器就是一个线性层加 softmax然后取 top-k。这里的关键是专家计算的并行化。不同 token 可能路由到不同专家如果串行算效率很低。我的做法是先按专家分组把路由到同一个专家的 token 聚在一起然后批量做矩阵乘法。这样每个专家只需要加载一次权重缓存友好。// MoE 路由的简化逻辑 std::vectorint route(const float* gate_logits, int num_experts, int top_k) { std::vectorstd::pairfloat, int scores; for (int i 0; i num_experts; i) { scores.push_back({gate_logits[i], i}); } std::partial_sort(scores.begin(), scores.begin() top_k, scores.end(), std::greater()); std::vectorint selected; for (int i 0; i top_k; i) { selected.push_back(scores[i].second); } return selected; }MoE 的坑在于负载均衡。如果所有 token 都路由到同一个专家那并行就退化成串行了。实际模型训练时会有负载均衡损失推理时一般不会太极端但我在代码里还是加了个简单的检查如果某个专家被选中的次数超过阈值就打个日志提醒。4. 实操过程与性能调优记录4.1 从零编译到跑通第一个 token编译命令很简单g -O3 -marchnative -fopenmp -o deepseek_cpu main.cpp -lstdc-O3开最高优化-marchnative让编译器针对当前 CPU 生成 SIMD 指令-fopenmp启用多线程。这三个参数缺一不可尤其是-marchnative不开的话自动向量化基本没效果。跑通第一个 token 的过程并不顺利。第一次运行直接段错误用 gdb 跟进去发现是权重加载时偏移算错了。修好之后能跑但输出是乱码检查发现是 RoPE 的 cos/sin 表索引越界。再修输出正常了但速度慢得离谱一个 token 要好几秒。用perf一分析发现 90% 的时间耗在反量化上因为我在每次矩阵乘法前都把整个权重矩阵反量化了一遍。改成融合反量化之后速度直接提升了三倍多。这个优化是性价比最高的强烈建议一开始就这么写。4.2 性能对比与参数调优我在一台 16 核的服务器上做了对比测试模型用的是 DeepSeek 的 7B 量化版INT8 量化group size 64上下文长度 512。结果如下配置项单 token 耗时相对速度单线程无优化850ms1.0xOpenMP 16 线程120ms7.1xOpenMP 融合反量化75ms11.3xOpenMP 融合反量化 i-k-j48ms17.7x上述 INT4 量化35ms24.3x可以看到每一步优化都有明显收益。OpenMP 并行带来的提升最大因为矩阵乘法天然适合并行。融合反量化次之省掉了大量内存往返。循环顺序优化再次之主要收益在缓存命中率。INT4 量化最后锦上添花但精度会掉一些需要权衡。线程数不是越多越好。我测过 8、16、32 线程16 线程物理核心数是最优的32 线程因为超线程竞争反而慢了 10% 左右。所以线程数设成物理核心数是个稳妥的选择。4.3 内存占用的控制DeepSeek 7B 的 INT8 量化权重约 7GB加上 KV Cache 和中间激活总内存占用在 10GB 左右。如果内存紧张可以进一步用 INT4权重降到 3.5GB 左右。KV Cache 的大小和 max_seq_len 成正比如果只做短对话把 max_seq_len 设成 1024 就够能省不少内存。我还做了一个内存池把中间激活的缓冲区复用起来避免每层都重新分配。这个优化对性能影响不大但对内存碎片控制有帮助长时间运行更稳定。5. 常见问题与排查技巧实录5.1 输出乱码或 NaN 的排查路径输出乱码是新手最容易遇到的问题原因通常有几个权重加载偏移错误、RoPE 索引越界、softmax 数值溢出、量化 scale 算错。我的排查顺序是先检查权重加载打印几个已知张量的前几个值和参考实现对一下再检查 RoPE 表确认索引范围然后检查 softmax 有没有减最大值最后检查量化参数看 scale 是不是零或者异常大。NaN 一般是数值溢出导致的最常见的就是 softmax 没减最大值或者反量化时 scale 太小导致除零。加个断言检查 scale 是否为零能提前发现很多问题。5.2 性能不达预期的常见原因如果速度比预期慢很多先看编译参数-O3 -marchnative -fopenmp有没有全开。再看线程数是不是设成了 1 或者设得太大。然后用perf top看热点函数如果反量化占比高说明没做融合如果矩阵乘法占比高但速度还是慢可能是循环顺序不对缓存没命中。还有一个隐蔽的坑内存对齐。如果权重矩阵没有按 64 字节对齐SIMD 指令会退化成标量速度差好几倍。用aligned_alloc分配内存并且确保所有张量的起始地址都是对齐的。5.3 常见问题速查表问题现象可能原因解决方法段错误权重偏移算错、数组越界用 gdb 定位检查 safetensors header 解析输出乱码RoPE 索引越界、量化参数错误打印中间值对比参考实现输出 NaNsoftmax 溢出、scale 为零softmax 减最大值检查 scale速度极慢未开优化、反量化未融合开 -O3 -marchnative融合反量化多线程无加速线程数不对、负载不均设物理核心数按块并行内存爆掉KV Cache 太大、权重全加载减小 max_seq_len分层加载提示调试推理引擎时准备一个极小的测试用例比如 2 层、隐藏维度 64 的迷你模型能极大加快排查速度。大模型上跑一次要几十秒小模型上毫秒级就能验证逻辑。6. 这套引擎还能怎么扩展两千行跑通 DeepSeek 只是个起点。如果你想继续深挖有几个方向可以走。一是加 SIMD 手写内核把 AVX2 或 AVX-512 用上矩阵乘法还能再快一截。二是支持更激进的量化比如 INT4 加分组量化加混合精度进一步压内存。三是加一个简单的 HTTP 服务层把它变成一个能对外提供接口的推理服务。四是支持流式输出边算边吐 token体验会好很多。我自己在实际操作中的体会是写推理引擎最大的收获不是性能数字而是对模型结构的理解变得非常具体。以前看论文里的公式觉得抽象自己手写一遍之后每个矩阵的形状、每次内存的读写、每个数值的范围全都清清楚楚。这种理解是调库调不出来的。最后分享一个小技巧如果你也想动手写别一上来就冲着 DeepSeek 全量模型去先用一个极小的 Transformer比如 2 层、4 头、隐藏维度 128把整条链路跑通确认每个模块的输出都对再逐步放大。这样出问题的时候你能快速定位到是哪个模块的锅而不是在一堆张量里大海捞针。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑