尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

C++重写CNN推理引擎:从35ms到3ms的CPU性能优化实战

发布时间:2026/9/28 20:59:33

资讯中心
01
ARTICLE

C++重写CNN推理引擎:从35ms到3ms的CPU性能优化实战

C++重写CNN推理引擎:从35ms到3ms的CPU性能优化实战
这个系列写到第十一篇前面的基础语法、内存模型、对象机制聊了不少这次该碰点真正硬核的东西了。上周我把一个训练好的卷积模型接到公司的图像质检模块里用Python跑一次推理大概要35毫秒。听起来不算慢但产线上一天要处理几万张图这35毫秒卡在流水线上就成了实实在在的瓶颈。我第一反应是换一台带更强GPU的机器可真到落地的时候发现推理是跑在CPU上的图片从采集端到算法端本来就经过了好几层IPC再送去GPU来回拷贝反而更慢。于是我下了个决心用C给这个固定结构的模型写了一个深度定制的推理引擎把单次推理压到了3毫秒以内。这篇就把整个过程记录下来包括内存布局怎么调、AVX2怎么用、INT8量化怎么落地、多线程怎么编排以及我在中间踩过的几个坑。1. 为什么我要用C重写推理模块35毫秒到3毫秒的起点先说清楚背景。模型结构是一个轻量级CNN主干是四层卷积加两层全连接输入是224×224的灰度图。训练阶段用PythonPipeline毫无问题但线上推理要求的是稳定的低延迟和可控的内存占用。Python侧的推理慢的原因相当一部分不在计算本身而在数据流通每个Batch要拷贝到不同框架的Tensor里、算子内部有一次次临时分配、Python解释器的GIL又在多线程时有额外开销。我做过一次粗略的profiling纯计算时间只有11毫秒左右剩下的24毫秒都消耗在了数据搬运、内存分配和框架调度上。这给了我一个很直接的判断对于固定结构、固定输入的模型与其用通用深度学习框架不如针对它写一个专用的推理引擎。模型结构不会变编译期就能确定每一层的张量形状所有中间Buffer在初始化阶段一次性分配完毕整体推理过程不允许出现任何隐式内存分配。这个思路让整个项目从起跑线上就比通用框架领先。1.1 手写推理引擎的适用边界不是所有场景都适合自己写推理引擎。如果模型在快速迭代今天加一个算子明天改一个结构手写引擎会变成无底洞。反过来如果模型结构已经冻结线上对延迟和内存有硬性要求而你恰好对模型结构足够熟悉手写一个专用推理模块就是性价比极高的选择。我这个项目就属于后者模型结构半年没动过唯一变化的只是权重数值。这套方案的收益也很清晰。推理过程变成了一次确定性的函数调用输入灰度图指针输出分类结果中间没有任何隐式分配、没有运行时类型检查、没有动态形状推导。配合编译器的优化选项代码可以压得很狠。当然代价是要自己实现卷积、池化、全连接、激活函数、Softmax这些基础算子而且每一步都要考虑数据排布和指令集优化。好在C做这些事是本职写起来效率不低。1.2 技术选型LibTorch还是手写动工之前我把LibTorch和ONNX Runtime都评估了一遍。LibTorch的问题是即使在CPU模式下内部调度依然带着一层的抽象而且C API对内存的所有权管理比较繁琐ONNX Runtime虽然优化得不错但它针对的是通用模型加载和初始化这类固定结构的模型时很多校验和动态规划开销完全可以省掉。我还测过一次真实对比同一批图片LibTorch CPU模式平均9.8毫秒ONNX Runtime平均8.4毫秒而手写的第一版朴素C实现大概11毫秒其实并没有优势。但关键在潜力。手写引擎可以从11毫秒优化到3毫秒以内而通用框架能做的优化空间已经基本清零。而且在我这个小场景里推理线程需要跟现有C业务代码深度耦合手势写引擎还能精确控制线程模型避免和业务线程池互相干扰。所以最终结论是手写但只针对这一个模型、这一种输入尺寸不做通用化设计。1.3 本次项目的总体设计整个推理引擎的架构我分成了三层。底层是一组基础算子包含卷积、池化、全连接、ReLU、Softmax全部以自由函数形式提供中间层是一个静态计算图描述模型结构在编译期定义层与层之间的张量尺寸由constexpr常量推导最上层是推理入口输入一个原始图像指针依次执行各层的Forward函数最终返回分类向量。这个设计里没有类继承、没有虚函数、没有多态调度每个算子都是直接操作裸指针或引用。内存管理统一由一块预分配的大缓冲区负责每一层的输出张量都指向这块缓冲区里预先计算好的偏移位置。后面的所有优化都建立在这个基础上后续我做的事情本质上是把每一层的时间往下压。2. 内存布局是性能的第一道门槛NCHW、NHWC与32字节对齐很多做Python出身的朋友会忽略一个问题深度学习框架里最常用的NCHW和NHWC两种数据排布并不是仅仅改一下维度顺序那么简单。对C这类需要手动管理内存的语言来说数据排布直接决定了CPU缓存命中率和向量化指令的加载效率。我在第一版实现里沿用了训练时的NCHW布局就是把所有通道的位置放在Height和Width之前。这个布局在卷积的im2col阶段很顺因为可以把每个卷积窗口展平成连续的行向量。但推理阶段有个不同点我对单张图做逐层处理中间张量的空间局部性比训练时重要得多。NCHW下访问某个像素点时需要跨过整个通道平面的距离才能找到同一位置的其他通道值这会让许多逐元素操作比如ReLU、缩放的缓存效率变差。NHWC则是把同一个空间位置的所有通道值连续排列访问一个像素就能把接下来的几个通道一起带进缓存缓存行利用率更高。经过实测在这个模型里把中间张量从NCHW改成NHWC每个算子大约能有10%到20%的收益。因为后续的SIMD优化需要一次加载连续内存中的多个float内存连续性是前置条件。2.1 数据排布如何在底层影响算子的速度举个例子一个3×3卷积对应9个权重系数但每个输出像素需要读取输入中9个相邻像素的所有通道值。在NHWC下比如C16意味着每读入一个像素就能在连续地址里拿到它的16个通道值CPU缓存线通常64字节能支撑的恰好是16个float。这意味着一个缓存行可以被这16个通道完整消耗掉几乎没有浪费。而在NCHW下16个通道值分布在不同内存区域每次访问一个通道都要触发一次缓存行加载时间自然翻倍。分类任务的全连接层更明显。全连接本质是一个大矩阵乘法如果中间向量在内存里是连续的向量化加载和存储都会非常高效。我在第一版把卷积输出从NCHW转成NHWC之后单纯的内存整理代码就消失了大半算子的逻辑也简单了许多。现在回头看数据排布和算法优化是互相成就的关系SIMD加载要求内存连续内存连续反过来简化了算子实现。2.2 一个完整的对齐内存分配器AVX2时代有个铁律想用对齐加载指令内存必须满足32字节对齐。C标准库提供的std::vector只保证16字节对齐这在AVX2场景下不够用。我在项目里写了一个简单的对齐分配器直接封装aligned_alloc。templatetypename T, size_t Alignment 32 struct AlignedAllocator { using value_type T; T* allocate(size_t n) { void* ptr nullptr; if (posix_memalign(ptr, Alignment, n * sizeof(T)) ! 0) { throw std::bad_alloc(); } return static_castT*(ptr); } void deallocate(T* ptr, size_t) noexcept { std::free(ptr); } }; templatesize_t Alignment 32 using AlignedFloatBuffer std::vectorfloat, AlignedAllocatorfloat, Alignment;这个分配器配合std::vector用起来非常顺手所有中间张量都声明成AlignedFloatBuffer就能保证每个元素的起始地址都是32字节对齐的。也许有人觉得对齐不重要但在AVX2里用_mm256_load_ps和_mm256_loadu_ps的延迟差距会相当可观不只是对齐的合法性对齐还意味着一次加载不会跨越两个缓存行省掉了加载器内部的边界处理。这个细节在单次推理里可能只省几纳秒但当推理管线被重复执行几十万次积少成多。2.3 im2col和矩阵乘的字节序陷阱卷积层的实现我用了经典的im2col。核心思路是把输入张量按照卷积窗口展开成一个大矩阵每个窗口对应一行然后跟权重矩阵做矩阵乘法。这个转换的代价是会产生若干倍的中间数据膨胀但对缓存友好的连续访问来说收益远大于开销。配合NHWC布局im2col的输出天然就是按行连续排列的喂给矩阵乘非常顺畅。这里有一个坑。NHWC经过im2col之后K维是kernel_h * kernel_w * input_channels顺序是h、w、c而不是c、h、w。权重矩阵的排布必须严格跟这个顺序对齐。我第一次转换的时候把权重按NCHW直接展平结果模型精度完全崩了。排查了半天才发现是权重通道顺序对不上。解决的办法是写个小脚本在模型导出阶段就把权重从NCHW重排成NHWC再转成二进制文件之后加载时只做一次反序列化不做任何运行时重排。这个教训让我明白数据排布不是只关乎性能还关乎正确性。3. 全连接层加速从标量循环到AVX2/FMA模型最后一个阶段是两层全连接第一层从512维映射到256维第二层从256维映射到10维分类输出。量算下来不大但却是推理链路上比较耗时的一段因为im2col之后的卷积输出展平后正好是512维向量全连接层要做的是一个2048大小的矩阵乘加虽然不算大但胜在调用频率高。我用了一个很朴素的C实现先跑通验证然后在同一份代码上迭代替换为SIMD版本对比效果非常明显。3.1 朴素实现到底慢在哪里第一版全连接层是这样写的void fully_connected_naive(const float* input, const float* weight, const float* bias, float* output, int input_dim, int output_dim) { for (int o 0; o output_dim; o) { float acc bias[o]; for (int i 0; i input_dim; i) { acc input[i] * weight[o * input_dim i]; } output[o] acc; } }逻辑很直白但性能远非最优。首先内层循环是一个串行依赖链编译器虽然可以做指令流水线但整条链上每次乘法都依赖上一步的累加值。其次weight[o * input_dim i]的访问是行优先的input[i]也是顺序连续的从内存访问看不算差但完全没有利用CPU的向量寄存器宽度。现代CPU的AVX2单元一次能处理8个float的乘加标量循环等于每次只用了寄存器的八分之一宽度这是最明显的差距。3.2 用intrinsic写第一版AVX2矩阵乘AVX2版本的核心思路是让输出同时处理8个分量也就是一次算8个不同的output[o]。这样内层循环每次可以加载一个input[i]广播到向量寄存器然后同时对8行权重做乘加吞吐量一下就上去了。#include immintrin.h void fully_connected_avx2(const float* input, const float* weight, const float* bias, float* output, int input_dim, int output_dim) { for (int o 0; o output_dim; o 8) { __m256 acc _mm256_loadu_ps(bias[o]); for (int i 0; i input_dim; i) { __m256 input_val _mm256_broadcast_ss(input[i]); __m256 weight_val _mm256_loadu_ps(weight[o * input_dim i]); acc _mm256_fmadd_ps(input_val, weight_val, acc); } _mm256_storeu_ps(output[o], acc); } }这个版本假设output_dim是8的倍数我在模型里把全连接层的输出维度设成了256和10前者正好能整除8后者需要补到16才能保证向量化不越界这个细节在后来的代码里用pad解决。实测比标量版本快了大约2.8倍。_mm256_fmadd_ps是FMA指令一次完成乘加两步CPU里的fused multiply-add单元能在同一拍内完成操作这个收益是实打实的。3.3 继续深挖循环分块与内存复用到这一步也许有人就满足了但我用perf看了一下发现主要瓶颈变成了L1缓存命中率。矩阵乘访问模式里权重矩阵每一行都要被扫描一遍如果input_dim很大权重的一整行加载到缓存之后下一次又需要扫描另一行数据复用率很低。标准的解法是循环分块把内层循环拆成小块让每个小块在寄存器或一级缓存里被充分复用后再切换。这里我摘一段核心分块逻辑目标是把权重矩阵按8行×32列切块这样权重块的体积正好是1KB左右能稳定放在L1缓存里void fully_connected_avx2_blocked(const float* input, const float* weight, const float* bias, float* output, int input_dim, int output_dim) { constexpr int BLOCK_I 32; for (int o 0; o output_dim; o 8) { __m256 acc _mm256_loadu_ps(bias[o]); for (int i 0; i input_dim; i BLOCK_I) { int i_end std::min(i BLOCK_I, input_dim); for (int k i; k i_end; k) { __m256 input_val _mm256_broadcast_ss(input[k]); __m256 weight_val _mm256_loadu_ps(weight[o * input_dim k]); acc _mm256_fmadd_ps(input_val, weight_val, acc); } } _mm256_storeu_ps(output[o], acc); } }这个版本在L1缓存命中率上比无分块版本提升了大约11%整层推理时间进一步缩短。到这里全连接层的优化告一段落收益排序大概是标量改SIMD约2.8倍大于循环分块约1.1倍大于纯靠编译优化约1.05倍所以SIMD化是绝对的主菜。4. INT8量化模型体积和服务成本一起降全连接层优化完卷积层又成了新的热点。卷积运算量本来应该比全连接大得多但im2col之后可以复用矩阵乘的SIMD优化平均单层速度已经不错。但我还想压得更狠因为CPU推理的另一个大威胁是内存带宽和功耗。训练好的模型权重是float32单个权重占4字节如果能把权重和中间激活都压成int8资源和计算密度都能提升。我决定给这个模型做一层对称量化把神经网络里的关键计算降到INT8。4.1 量化为什么能降低延迟直觉上INT8的数据宽度只有float的1/4SIMD一次能处理的元素数量也从8个float变成32个int8计算密度翻了4倍。再加上INT8乘法在CPU上通常是单周期指令功耗和热量也更低。但真正的收益不只是算术单元而是内存带宽。一个典型的卷积权重矩阵可能就是几十万字节支持的input又是实时图像如果能全部压缩到int8CPU加载这批数据的次数就减少到原来的四分之一延迟自然下降。前提是模型对精度损失的容忍度足够。我这边模型的任务是17类工业零件的分类类别之间特征差异大少量量化噪声并不影响最终结果。这类宽松任务特别适合INT8而如果模型本身就是区分猫狗这种极细粒度的任务精度回退往往更显著。4.2 对称量化的公式与实现对称量化是最简单的方案它假设数值分布以0为中心整个量化的核心就两个参数scale和zero point。对对称量化来说zero point固定为0公式变成scale max(abs(weight)) / 127 q round(weight / scale) dequant: weight ≈ q * scale我写了一个偏置不变的量化流程。先扫描权重统计最大绝对值计算scale然后把float权重转成int8数组同时在另一个数组里恢复一个浮点scale供推理时反量化。INT8卷积的推理过程是输入先经过quantize转成int8用INT8矩阵乘得到int32累加结果最后乘上输入scale和权重scale再转回float。这中间有个非常重要的经验bias保持float不变在反量化阶段相加避免bias在量化过程中损失过多精度。std::vectorint8_t quantize_weight(const std::vectorfloat weights, float scale) { float max_abs 0.0f; for (float w : weights) { max_abs std::max(max_abs, std::abs(w)); } scale max_abs / 127.0f; std::vectorint8_t quantized(weights.size()); for (size_t i 0; i weights.size(); i) { int32_t q static_castint32_t(std::lround(weights[i] / scale)); q std::max(-128, std::min(127, q)); quantized[i] static_castint8_t(q); } return quantized; }这个scale计算用在每个Conv层和FC层上。中间激活的scale则来自实际跑一批校准数据得到的统计值。整个模型的量化不会一次到位而是要逐层做每层量化后跑一遍校准集统计激活分布再继续下一层。操作用了大概200张验证图片耗时十几分钟基本可以接受。4.3 INT8精度损失的实测与应对量化完跑了验证集原始浮点模型分类准确率是98.4%INT8量化后降到97.6%掉了0.8个百分点。对工业场景来说完全可接受但要观察几个细节首先某个特定的类别比如表面划痕轻微的产品准确率下降了2%这类样本的特征区间偏小量化步长把细微差异磨平了其次尾部极端的激活值影响了scale计算导致整体量化步长偏大细粒度特征被进一步压缩。应对办法有两个方向。一个是更精细的逐通道量化每个输出通道单独计算scale而不是整个权重张量只用一个scale另一个是混合精度保留敏感层的激活为float其余层用INT8。我最后采用了逐通道量化精度恢复到98.1%代价是scale数组变多但推理时查表一次开销可忽略。只能说量化不是无脑操作需要结合模型特点做调整。4.4 大胆取舍过多大的模型才值得量化同样做一次量化小模型可能收益有限大模型收益明显。我试过一个两层的小MLP原始推理1.1毫秒量化后0.8毫秒省的时间不够弥补开发成本。而四层卷积模型原始3.2毫秒量化后1.6毫秒几乎省了一半时间这时候量化就很值得。量化尽管也有工程复杂度但它带来的性能和体积收益是任何优化手段都难以替代的。如果模型推理时间本身已经低于1毫秒我的建议是先优化其他方面比如内存分配锁、线程切换。5. 多线程推理流水线把batch切到四个核上单次推理就算优化到1毫秒但产线上一批图像往往成组成组地到达。一次处理一张图和一次处理四张图如果架构允许并行平均单张耗时能再降一截。推理的多线程优化并不复杂难的是正确地切分数据和设计同步点。我的方案是数据并行把batch里的图片分给多个线程每个线程处理一个小batch最后汇总结果。这样比层间流水线更简单、更稳妥。5.1 分批并行遇到的内存模型问题并行跑Batch时第一版我直接在推理入口里起了多个std::thread每次创建线程都有明显开销。在批量循环里每张图都开线程结果比单线程还慢。原因很直接线程创建和销毁本身要几十微秒而单张推理当时只有几毫秒线程调度的开销比例高得离谱。我后来改成了线程池方案线程常驻任务提交和结果回收只走条件变量唤醒开销降到了微秒级。另一个容易忽略的坑是每线程的临时缓冲区。推理中间需要存储每层结果的缓冲区如果所有线程共享同一块那数据互相覆盖结果必错。解决办法是用thread_local或者每个线程持有独立的Buffer实例。我的做法是把推理上下文设计成一个结构体内部包含所有中间张量每个线程初始化一个自己的上下文互不干扰。5.2 一个够用程度的线程池线程池的实现不复杂核心是把任务队列和一组工作线程绑定。我参考了很多开源实现最后精简成一个不到一百行的版本class ThreadPool { public: explicit ThreadPool(size_t num_threads) : stop_(false) { for (size_t i 0; i num_threads; i) { workers_.emplace_back([this]() { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(queue_mutex_); condition_.wait(lock, [this]() { return stop_ || !tasks_.empty(); }); if (stop_ tasks_.empty()) return; task std::move(tasks_.front()); tasks_.pop(); } task(); } }); } } templatetypename Func void submit(Func func) { { std::unique_lockstd::mutex lock(queue_mutex_); tasks_.emplace(std::forwardFunc(func)); } condition_.notify_one(); } private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex queue_mutex_; std::condition_variable condition_; bool stop_; };使用的时候非常简单把batch拆成N片每片交给一个任务主线程等待所有任务完成。如果场景比较极端比如网络IO和推理同时发生可以在任务里加上不同优先级但这个推理模块内部不需要这么复杂。线程池本身不是难点难点是别把池的锁竞争搞成新瓶颈。如果任务调度太频繁队列锁会成为热点这种情况最好让每个线程持有独立的任务队列。5.3 串行依赖层与流水线的取舍刚开始我想过要不要在单张图片内部实现层间流水线也就是让卷积算子和全连接算子在不同线程上并行执行。连了一个简单测试后发现收益极低因为层与层之间有严格的依赖——上一层算完才能开始下一层这等于所有的并行只能在单层内部找。单层卷积内部的并行当然可以做但很快发现实现复杂度上升效果却不如直接把不同图片分给不同线程因为不同图片之间完全没有依赖切分粒度极粗且负载均衡好。最终方案是在入口做batch拆分线程数按CPU核数动态决定。在四核机器上batch4时几乎能跑到3.6倍的加速比剩下的0.4倍损失在任务分配和结果合并上。这已经是工程投入产出比很高的阶段再往上优化就要碰SIMD的深度调优或者汇编级调整边际递减明显。6. 实测数据与几个让我记忆深刻的坑所有优化做完之后我在一台低功耗工业主机上做了完整的基准测试。测试时固定输入图像尺寸连续跑500次取P50和P99延迟。下面是最终数据优化档位单batch平均延迟相对Python基线加速Python NumPy浮点35.2ms1.0xC 标量浮点12.1ms2.9xC AVX2浮点4.2ms8.4xC AVX2 INT81.6ms22xC AVX2 INT8 4线程batch40.9ms/张39x最终配置下P99延迟也从原来的30毫秒以上降到了2.1毫秒线上体验非常明显。这套引擎的整个代码量只有两千多行比起引一个通用框架维护成本也不算高。性能提升的背后是整个软件栈的配合确定性的数据排布、AVX2指令集、紧凑的INT8权重、池化后的多线程调度缺一不可。6.1 坑一忘了对齐性能直接腰斩有一次我把一个中间向量用普通std::vectorfloat声明而不是对齐分配器跑出来的推理时间瞬间从1.6毫秒跳到3.4毫秒。查了代码发现问题出在_mm256_loadu_ps和_mm256_load_ps的混用上。loadu虽然是未对齐版本能处理任意地址但编译器在自动向量化时如果发现地址不确定会保守生成多余指令来应对可能的越界导致效率下降。我当初随手用loadu写了不少代码后来统一替换成对齐版本性能立刻回归。从此以后我给自己立了一个规矩凡是向量化代码所有加载和存储都严格使用对齐版本数据源必配对齐分配器。6.2 坑二AVX-512在老CPU上的兼容问题项目中期我把部分算子改成了AVX-512 intrinsic在本地开发机上跑得飞快单次推理直接到了1.1毫秒。可是部署到产线工控机后程序直接报非法指令错误。原因很明确AVX-512不是所有X86 CPU都支持工控机用的是较老型号。最后只能编译两套二进制一套带AVX-512一套只带AVX2然后根据__builtin_cpu_supports在启动时动态选择。这个教训价值极高任何指令集优化都必须考虑目标环境intrinsic写起来爽部署时不一定爽。6.3 坑三线程池开满反而导致吞吐下降最初我把线程数设为硬件逻辑核心数在超线程机器上出现一种奇怪现象——4个物理核心开8个线程吞吐反而比开4个线程低了8%。原因是逻辑线程共享物理核心的执行单元同时抢L1/L2缓存对计算密集型任务完全没有帮助。后来统一把线程数设成物理核心数效果立竿见影。测量物理核心数的简单方法是读/proc/cpuinfo里的cpu cores字段而不是总计数器的processor个数。我在整个过程中感触最深的一点是深度学习推理的优化从来不是某一个技术点的突进而是内存布局、指令集、量化、并行的整体配合。以前总觉得C做AI模型是件苦力活现在回头看越是接近硬件底层的优化越能看清一套推理系统真正的性能边界在哪里。如果你也在做类似的事情希望这篇记录能帮你少踩几个坑至少在内存对齐和多线程决策上我的教训已经替你验证过一条更平坦的路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。