资讯详情

资讯详情

C++推理必备:HWC转CHW内存重排与性能优化实战

做C推理接口的同学基本都经历过这么一幕图片用常用的视觉处理库读进来数据在内存里排得好好的结果要喂给模型时模型输入要求的却是一块“通道在最外层”连续排列的内存。最开始我也没当回事直接把张量的维度形状改了一下结果模型的输出离谱到怀疑人生。查来查去问题就出在HWC转CHW这一步它根本不是换个shape而是要把内存里的数据重新排一遍。这篇文章不打算讲太多理论重点说清楚HWC和CHW的内存布局到底哪不一样C里有哪几种写法可以完成转换各自性能和踩坑点是什么最后给一个可以直接抄进项目的封装。无论你是刚开始接触C推理还是已经在集成某个推理框架但一直没有细看预处理部分这篇应该都能派上用场。1. 为什么C推理接口里HWC转CHW这道工序躲不掉1.1 数据从相机到模型的最后一公里摄像头采集、图片解码、常用视觉库读图这三个源头产出的数据绝大多数都是HWC排布。以最常见的三通道图为例一张 H2、W2 的图在内存里就是这样的顺序第一个像素的B、G、R然后第二个像素的B、G、R接着第三、第四个像素每个像素的通道值挤在一起连续存放。这种布局就是HWC也就是Height、Width、Channel高度和宽度优先通道是最后面的维度。但很多深度学习推理框架的输入接口默认要求的却是CHW也就是Channel、Height、Width。一段CHW内存应该是所有像素的第一个通道全部排完再排第二个通道最后排第三个通道。为什么框架喜欢这种布局一方面是很多模型导出和训练框架沿用了NCHW这种约定另一方面CHW在按通道做归一化、按通道剪枝、按通道做卷积计算时数据可以按通道整体切分便于把某一整块内存直接当成一个维度来处理。所以模型输入越来越统一成[1, 3, H, W]这种形状而外部图像数据却是[H, W, 3]。这两套约定的差异就成了从图像到模型之间的“最后一公里”。你辛辛苦苦把图像解码、resize、转成浮点最后却可能栽在这个看起来微不足道的维度转换上。1.2 转换的本质是内存重排而不是维度整形我见过不少新手在C里做HWC转CHW时第一反应是“把张量的形状从[H,W,C]改成[C,H,W]”。如果你的张量库支持零拷贝的view操作改shape只是改了一些元数据底层数据一个字节都没动。但真的需要内存里按CHW排好时改shape完全没用因为目标布局要求读出来的物理顺序完全不同。可以这么类比HWC像是一摞写满字的纸条每张纸条上是一行像素的通道CHW则是把所有纸条的光滑面朝一个方向重新装订。你要的不是换个标签而是把所有纸条抽出来重新插一遍。这件事在没有大量内存拷贝的情况下是做不到的——所以HWC转CHW一定是一次实打实的内存重排不是“看问题的角度变了”就能糊弄过去的。这个认知很重要。一旦你确定要自己写转换循环思路就变成了“怎么把这个重排做得更快、更稳”而不是“为什么我改了shape之后数据还是乱的”。2. HWC和CHW到底差在哪先拿一组小数据把内存画明白2.1 一个2x2x3的例子先不管复杂的公式我们用一张2x2的图像、3个通道来看。假设四个像素的RGB值分别是像素(0,0): R1 G2 B3 像素(0,1): R4 G5 B6 像素(1,0): R7 G8 B9 像素(1,1): R10 G11 B12HWC布局下内存从头到尾是这样的1 2 3 | 4 5 6 | 7 8 9 | 10 11 12也就是第0个像素的三个通道、第1个像素的三个通道、第2个像素的三个通道、第3个像素的三个通道。CHW布局下内存要求变成1 4 7 10 | 2 5 8 11 | 3 6 9 12也就是所有R通道值排在一起然后所有G通道值排在一起最后所有B通道值排在一起。这一眼就能看出来底层字节的顺序完全变了。所以每次向模型输入数据时你如果直接把HWC的指针强转成CHW模型看到的第一块数据是“1 2 3 4”它会把2当成第二个像素的R3当成第一个像素的G这完全是错乱的推理结果当然接近随机。2.2 索引公式和“看到内存”的能力在C里写转换最好把两个布局的索引公式直接记下来以后排查问题会快很多。假设一张图是H行W列C通道数据在连续内存中那么HWC布局下任意坐标(h, w, c)的偏移是offset (h * W w) * C cCHW布局下相同坐标的偏移是offset c * (H * W) h * W w这两个公式就是转换循环里唯一的真理。你只需要从源数据中按照HWC偏移取出一个值再按照CHW偏移放进目标数组就完成了一次搬运。后面优化版循环里我一再提到“目标跳步”、“源跳步”本质就是上面两个公式里到底哪个维度在循环最里层。看清了这一点代码怎么变你心里都有底。3. C里几种常见的HWC转CHW实现从“能跑”到“跑得快”这节直接给你能落地的代码。前提都是源数据是连续内存目标缓冲区已经按C * H * W申请好。为了讨论方便先把数据假设成float后面会再给融合uint8转float的版本。3.1 三层循环的朴素写法最直白的写法就是照着公式遍历c、h、w三个维度一个值一个值拷// HWC-CHW 朴素三层循环 void hwc_to_chw_naive(const float* src, float* dst, int H, int W, int C) { int HW H * W; for (int c 0; c C; c) { for (int h 0; h H; h) { for (int w 0; w W; w) { dst[c * HW h * W w] src[(h * W w) * C c]; } } } }这个写法最容易理解但性能通常不是最好的。问题出在源地址的访问模式内层循环w递增时src[(h * W w) * C c]的地址是每隔C个值才取一个。当C3时相当于每三个float里挑一个另外两个跳过这对CPU缓存极其不友好。如果你只跑一次小图这个版本没什么问题。但到了视频流或者高分辨率图像上这种跳着读的代价会明显放大。把它当教学版本没问题直接上生产则建议继续往下看。3.2 改进按源内存顺序遍历目标跳步反而能接受换个思路让源数据的访问完全连续就能获得更好的缓存命中率。具体做法是把循环最内层变成通道c外层按线性内存顺序i遍历每个像素// HWC-CHW 按源连续内存遍历 void hwc_to_chw_optimized(const float* src, float* dst, int H, int W, int C) { int HW H * W; for (int i 0; i HW; i) { const float* p src i * C; for (int c 0; c C; c) { dst[c * HW i] p[c]; } } }内层循环里源地址是连续的三个float读取很规整。目标地址写进去时c0写到通道0的那一大块偏移c1写到通道1的那一大块偏移c2写到通道2那一大块偏移。这三个目标位置彼此隔得很远看起来像是跳来跳去。实测下来这种“源连续、目标跳步”的方案通常比“目标连续、源跳步”更快。因为读的频次和写的频次一样但源数据的连续访问让预取器能正常工作目标端的写入则会被CPU的写缓冲一定程度掩盖。如果C比较大比如C16或者C32这种写法可能不如分块方案但C是3或4时它已经足够稳。3.3 复用视觉库的通道分离再加内存拷贝如果你在用常用的视觉处理库读图那么还有一条更省心的路线直接用库自带的通道分离操作把HWC拆成若干个单通道矩阵再把每个单通道矩阵连续拷贝到CHW目标区。#include opencv2/opencv.hpp #include vector #include cstring void hwc_to_chw_via_split(const cv::Mat src_u8, float* dst, int H, int W, int C, const float* alpha, const float* beta) { std::vectorcv::Mat channels; cv::split(src_u8, channels); // 拆成C个单通道Mat for (int c 0; c C; c) { cv::Mat tmp; // convertTo完成 uint8-float 和 alpha/beta 的乘加 channels[c].convertTo(tmp, CV_32FC1, alpha[c], beta[c]); std::memcpy(dst c * H * W, tmp.data, H * W * sizeof(float)); } }这里的cv::split在库内部是经过优化的内存操作拆通道这一步很快。随后每个通道用convertTo转成浮点并完成归一化最后memcpy把连续数据搬进目标缓冲区。整段代码可读性好也不容易出错。但代价是多了一次中间矩阵转换和一次拷贝性能上不如下面这种一次遍历的融合版。如果推理时对预处理耗时敏感建议以融合版为准。3.4 融合归一化的终极版本生产环境里HWC转CHW往往还伴随着另外两件事把uint8像素值转成float以及做减均值、除标准差之类的归一化。这三件事完全可以合成一次遍历省掉中间缓冲也省掉多轮cache开销。先换算一下参数。假设模型训练时对数据的预处理是x (uint8_value / 255 - mean) / std这里的mean和std都是0~1范围。把这段公式展开可以得到一次线性变换x uint8_value * alpha beta其中alpha[c] 1.0 / (255.0 * std[c]); beta[c] -mean[c] / std[c];如果mean和std本身是0~255范围那alpha[c] 1.0 / std[c]beta[c] -mean[c] / std[c]。代码就可以写成// 一次遍历完成 HWC-CHW uint8-float 归一化 void hwc_to_chw_fused(const uint8_t* src, float* dst, int H, int W, int C, const float* alpha, const float* beta) { int HW H * W; for (int i 0; i HW; i) { const uint8_t* p src i * C; for (int c 0; c C; c) { dst[c * HW i] static_castfloat(p[c]) * alpha[c] beta[c]; } } }这个函数只有一次完整的内存遍历不需要临时的单通道Mat也没有额外的memcpy。对于一张1920x1080的三通道图它要处理的元素量是192010803一次遍历和三次遍历的差距相当可观。我个人的习惯是只要不是纯学习用途一律直接用这个融合版。实现方案内存访问特点主要开销适用场景三层循环源跳步明显缓存命中差教学、验证源连续遍历源连续目标通道间跳步写方向分散大部分CPU推理splitmemcpy两轮遍历中间临时矩阵快速接入、可读性优先融合归一化一次遍历无临时内存生产环境首选4. 工程实战中的五个坑每一个我都真实踩过4.1 ROI子图导致内存不连续视觉库读完整张图时Mat通常是一整块连续内存。但如果你用ROI截取了图像的一个区域得到的Mat并不是独立复制出来的数据而是指向原图某个位置的视图。这个视图每行数据在原图里是连续的但行与行之间会跳过原图中不属于ROI的部分行间距和宽度不一致。这时候如果你直接拿mat.data按HWC假设去遍历读到的会是间隔区域里的垃圾数据结果就是图像内容错位、有斜纹乱码但程序还不报错很难排查。解决办法是先在预处理入口统一判断一次if (!mat.isContinuous()) { mat mat.clone(); }ROI尺寸不大时clone一次的成本可以接受。千万不要抱着侥幸心理跳过这一步线上偶发错乱很多时候就是这么来的。4.2 BGR和RGB顺序张冠李戴视觉库读进来的三通道图默认通道顺序通常是BGR而非RGB也就是第0个通道是蓝色第2个通道是红色。但很多模型训练时用的是RGB顺序。如果你不处理通道顺序直接做HWC转CHW模型看到的R通道实际是BB通道实际是R颜色信息完全错乱。处理方式有两种。一种是在转换循环里直接把通道顺序换掉比如最后一个通道对应源的第0个通道for (int i 0; i HW; i) { const uint8_t* p src i * C; dst[0 * HW i] static_castfloat(p[2]) * alpha[0] beta[0]; // R dst[1 * HW i] static_castfloat(p[1]) * alpha[1] beta[1]; // G dst[2 * HW i] static_castfloat(p[0]) * alpha[2] beta[2]; // B }另一种是在resize之前就把Mat从BGR转成RGB。我个人更推荐在转换循环里做因为少一次整图遍历也少一个中间Mat。要特别注意的是alpha和beta必须跟着通道的实际含义走别在顺序交换时把mean和std也按错误通道配上去。4.3 灰度图和四通道的特殊情况灰度图只有单通道HWC布局和CHW布局在物理上没有任何区别直接拷贝即可。但它容易误导人的地方在于有些代码按三通道处理完灰度图后内存里前三分之一的元素是原数据后三分之二是空的模型输入尺寸对不上直接崩溃。四通道RGBA图也很常见特别是摄像头上来的数据或者桌面采集。转换时需要跳过Alpha通道或者在转换循环里同时完成RGBA到RGB的抽取。如果直接按i * 4的顺序拷贝到三通道目标等于把Alpha当成有效数据送进了模型颜色会偏模型结果也会受影响。我的建议是预处理函数开头显式检查通道数不是预期通道数就直接返回错误不要默默硬转。4.4 字节对齐与内存分配很多推理后端的输入要求内存地址按一定字节数对齐比如64字节或128字节这样它可以用SIMD指令批量读取。视觉库Mat分配的内存地址通常能满足一般对齐但当你自己用new float[]或malloc分配输出缓冲区时地址对齐就不一定那么严格了。一旦地址不满足后端要求表现可能是启动时直接断言失败也可能是跑到某个特定batch时才崩非常难定位。稳妥做法是转换前分配对齐内存float* buffer static_castfloat*(aligned_alloc(64, total_size * sizeof(float)));用完后记得free(buffer)。如果你的代码里已经有自定义内存池也要确认池子返回的地址对齐等级够不够。4.5 归一化参数到底按什么顺序喂训练代码里的预处理顺序决定了你线上推理的mean和std怎么填。常见的两种写法是x (image / 255.0 - mean) / std和x (image - mean * 255.0) / (std * 255.0)第一种mean/std是0~1范围第二种是0~255范围。它们数学上等价但如果你只记住了训练代码里的mean值却搞错了mean是0~1还是0~255结果会差很多。我在自己的项目里就把这个换算写死在注释里同时把alpha和beta的公式列在封装函数旁边避免一个月后回来看代码时自己都忘了当初按哪种范围填的。5. 性能优化还能再进一步缓存、并行与算子选择5.1 为什么会慢随机跳地址与cache missHWC转CHW是个纯粹的内存搬运问题性能瓶颈只可能在访存模式上。前面提到过的“源连续、目标跳步”已经比“源跳步、目标连续”好很多但在超大图上目标端三个通道相距很远每写一个值就要切换一次cache line这个切换成本会被放大。一个更缓解的思路是分块处理把图像按行切成若干个小块每块单独执行转换。这样在任意一小段时间里源数据块和目标数据块都能同时待在缓存里cache的复用率更高。实际操作时块大小一般取8行到32行之间具体数值跟CPU缓存大小有关可以在自己的机器上简单跑几轮挑一个。5.2 并行拆分通道因为目标缓冲区不同通道之间完全不重叠多线程并行时几乎不需要处理竞争问题。最简单的方式是使用OpenMP让每个线程负责不同的行区间void hwc_to_chw_parallel(const uint8_t* src, float* dst, int H, int W, int C, const float* alpha, const float* beta) { const int HW H * W; const int BLOCK 8; #pragma omp parallel for schedule(static) for (int h0 0; h0 H; h0 BLOCK) { int h1 std::min(H, h0 BLOCK); for (int h h0; h h1; h) { for (int w 0; w W; w) { const uint8_t* p src (h * W w) * C; for (int c 0; c C; c) { dst[c * HW h * W w] static_castfloat(p[c]) * alpha[c] beta[c]; } } } } }这里每个线程处理的是不同的行区间写目标区时不会和其他线程冲突。BLOCK取8是为了让每个线程一次处理一小段连续行这样源数据在缓存里更容易复用。实测下来四核以上的机器用这个版本转换耗时能明显下降。没有OpenMP的环境下也可以用手动线程池按行区间切任务思路完全一样。5.3 要不要用后端自带的Permute算子有些推理框架在模型输入处可以加一个Transpose或者Permute算子把HWC转成CHW放到推理引擎里去完成。看起来省了C侧代码实际却不一定划算。第一Permute算子本质也是内存重排它在推理引擎内部执行不会凭空消失这轮拷贝。第二后端调度这个算子的开销可能会比你的融合函数更高尤其当你的融合版已经把归一化一起做了而后端Permute只处理布局转换归一化还得额外算子去做。第三后端Permute有时还涉及设备端和主机端的内存拷贝比如GPU推理时你得先HWC上传到显存再Permute数据路径更长。我并不是说永远别用后端算子而是建议先benchmark。你把两种方式分别跑一百张图统计平均耗时再做决定。就我自己的经验来说在CPU推理场景下手写融合函数通常更快而如果模型本身已经支持HWC输入那就干脆别转直接改后端输入布局配置可能还能省下整段开销。6. 一个可以直接抄的C转换函数封装6.1 完整代码把前面的融合版本整理成一个完整的预处理函数输入是视觉库Mat输出是一段CHW顺序的浮点缓冲区。mean和std默认按0~1范围传入。#include opencv2/opencv.hpp #include algorithm // 将一帧BGR图resize后转成CHW连续float数据并完成归一化 // mean/std 为0~1范围例如 ImageNet常用 mean[0.485,0.456,0.406] void preprocess_for_inference(const cv::Mat bgr_img, int target_w, int target_h, float* output, const float mean[3], const float stddev[3]) { cv::Mat resized; cv::resize(bgr_img, resized, cv::Size(target_w, target_h)); if (!resized.isContinuous()) { resized resized.clone(); } const int C 3; const int HW target_w * target_h; // 把 mean/std 换算成一次乘加: dst pixel * alpha beta float alpha[C], beta[C]; for (int c 0; c C; c) { alpha[c] 1.0f / (255.0f * stddev[c]); beta[c] -mean[c] / stddev[c]; } const uint8_t* src resized.data; for (int i 0; i HW; i) { const uint8_t* p src i * C; for (int c 0; c C; c) { output[c * HW i] static_castfloat(p[c]) * alpha[c] beta[c]; } } }这段代码默认输入是BGR三通道图像通道顺序没有额外调整。如果你的模型要RGB就把p[0]和p[2]的映射换一下。6.2 集成时需要确认的三件事第一确认模型输入张量的shape。有的后端输入是[1, C, H, W]有的后端只接受不带batch维度的[C, H, W]这会影响你的输出缓冲区大小和第一级索引方式。第二确认预处理参数究竟来自训练时的哪一段代码是除以255之后再减均值还是直接减0~255均值这个我在前面的坑里已经强调过。第三确认后端对输入内存的对齐和连续要求必要时候换成对齐分配。这三件事在项目启动时花十分钟确认完后面能省下大量排查问题的时间。不要等到模型推理结果偶尔不对了才回头排查那时候真的很难判断是网络问题、权重问题还是预处理问题。6.3 最后分享一个实际体会我最近排查一个C线上服务的推理抖动问题现象是同一张图有时候结果正常有时候错乱而且错乱得很有规律像是每隔几行就出现一条斜的干扰带。一开始怀疑模型本身不稳定后来把输入图像直接dump出来对比才发现中间某次从ROI截取图片后没有做连续性检查取数据指针时把行间距啃进去了。问题从数据进入预处理函数就存在HWC转CHW只是把这个脏数据放大了一圈而已。从那以后我把预处理全部收口到一个公共函数里不管是ROI、截图还是整帧图入口统一做resize、连续性检查、HWC转CHW和归一化。转换这条路由刚开始的“随手写个循环”变成了团队里所有人共用的基础组件后面再也没出现过这类诡异的布局错乱。别看这个操作只有几十行它确实是C推理链条里最容易被低估、也最容易埋雷的一环。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →