在学习 SIMD 的时候,我拿一个非常基础、也非常适合向量化的操作做了次测试:图像按位取反,也就是 bitwise_not。这个操作本身并不复杂,本质上就是把每个字节和 0xFF 做一次异或,因此很适合作为 SIMD 入门练手题。一方面它逻辑简单,容易手写;另一方面它的数据规模通常较大,性能差异也比较容易测出来。
这次我一共对比了三种实现方式:一种是最普通的逐字节循环,一种是自己手写的 AVX2 版本,最后一种则是直接调用 OpenCV 自带的 cv::bitwise_not。测试下来结果很直观:普通循环明显最慢,而手写 AVX2 和 OpenCV 内置版本都非常快,且两者差距并不大,最终 OpenCV 还略快一点。这件事本身很有意思,因为它既说明 SIMD 优化确实有效,也说明成熟库在基础算子上的优化通常已经相当扎实。
先看这次 benchmark 的结果:
Benchmark Time CPU Iterations
------------------------------------------------------------
BM_avxbitwise_not 23286 ns 21854 ns 30403
BM_cvbitwise_not 21947 ns 20600 ns 33277
BM_bitwise_not 313471 ns 295377 ns 2560
从结果上看,手写 AVX2 的版本大约在 2.3 万 ns 左右,OpenCV 自带版本大约在 2.2 万 ns 左右,而普通标量循环则到了 31 万 ns 以上。也就是说,单纯从这次测试来看,普通循环比前两者慢了十几倍,而 OpenCV 和 AVX2 则处于同一量级。这个结果其实很符合预期,因为按位取反这类操作的特点就是"计算很简单,数据很多",它的瓶颈通常不在复杂运算上,而在于吞吐能力和内存访问效率上。
一、图像按位取反到底在做什么
图像反色操作本身没有太多玄学。对于 8 位图像来说,每个通道的值都是一个 uchar,取值范围是 0~255。如果把它与 0xFF 做异或,就会得到对应的反色结果。
dst = src ^ 0xFF;
比如原值是 0,结果就是 255;原值是 255,结果就是 0;如果原值是 100,结果就是 155。对于三通道 BGR 图像来说,本质上也只是把这件事对每个通道的每个字节做一遍而已。
正因为这个操作完全没有复杂的邻域依赖,也没有额外的分支判断,所以它天然适合做 SIMD。你可以把它理解成一种极其标准的"批量读入一段数据,做一次统一运算,再批量写回"的问题。
二、最基础的普通循环版本
先看最普通的实现,也就是完全不使用 SIMD,只用双层循环按字节处理整张图。
static void bitwise_not(const Mat &src, Mat &dst)
{
dst.create(src.size(), src.type());
int byteCols = src.cols * src.channels();
int i = 0;
for (; i < src.rows; i++)
{
const auto src_ptr = src.ptr<uchar>(i);
auto dst_ptr = dst.ptr<uchar>(i);
int j = 0;
for (; j < byteCols; j ++)
{
dst_ptr[j] = src_ptr[j] ^ 0xFF;
}
}
}
这段代码的思路非常直接。先创建与源图同尺寸同类型的输出图像,然后逐行取指针,再在每一行里逐字节遍历。因为这里用的是 src.cols * src.channels(),所以对于彩色图来说,实际处理的是每一行的总字节数,而不是像素数。这样写的好处是逻辑非常直观,而且不需要关心图像是单通道还是三通道,只要底层是 uchar 数据就能处理。
但这种实现的问题也非常明显。它一次只处理 1 个字节,而现代 CPU 的向量寄存器一次能处理几十个字节,所以这种写法本质上没有利用到处理器的并行能力。虽然编译器有时也会尝试自动优化,但对这种手写逐元素循环来说,最终性能通常还是远不如显式向量化版本。这也是为什么它在 benchmark 里直接落到了 30 多万 ns。
三、手写 AVX2 版本
接下来是手写的 AVX2 版本。这个版本的核心目标很简单:一次不要只处理 1 个字节,而是一次处理 32 个字节。
static void bitwise_not_avx(const Mat &src, Mat &dst)
{
dst.create(src.size(), src.type());
const __m256i vxor = _mm256_set1_epi8(0xFF);
int byteCols = src.cols * src.channels(), step = 32;
int i = 0;
for (i = 0; i < src.rows; i++)
{
const auto src_ptr = src.ptr<uchar>(i);
auto dst_ptr = dst.ptr<uchar>(i);
int j = 0;
for (; j <= byteCols - step; j += step)
{
auto vx = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(src_ptr + j));
auto vy = _mm256_xor_si256(vx, vxor);
_mm256_storeu_si256(reinterpret_cast<__m256i *>(dst_ptr + j), vy);
}
for (; j < byteCols; j++)
{
dst_ptr[j] = src_ptr[j] ^ 0xFF;
}
}
}
这段代码里最关键的是这一句:
const __m256i vxor = _mm256_set1_epi8(0xFF);
它会生成一个 256 位的向量寄存器,也就是 32 个字节,每个字节都是 0xFF。后面在循环里,每次先从源图里读取 32 字节数据:
auto vx = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(src_ptr + j));
再和 vxor 做一次异或:
auto vy = _mm256_xor_si256(vx, vxor);
最后把结果一次写回:
_mm256_storeu_si256(reinterpret_cast<__m256i *>(dst_ptr + j), vy);
这样,一个循环迭代就完成了 32 个字节的反色,而不是 1 个字节。剩下不足 32 字节的部分,再交给后面的尾处理循环来完成。这个"主循环 + 尾处理"的结构,是几乎所有手写 SIMD 代码都会出现的基本模式。大块数据交给向量化,剩下零头用标量补齐,既保证性能,也保证正确性。
四、Benchmark 测试代码
完整测试代码如下,这里我保留了你当前的函数命名和测试结构。
#include <opencv2/opencv.hpp>
#include <benchmark/benchmark.h>
#include <array>
#include <stdexcept>
#include <iostream>
#include <immintrin.h>
using namespace cv;
using namespace std;
static void bitwise_not(const Mat &src, Mat &dst)
{
dst.create(src.size(), src.type());
int byteCols = src.cols * src.channels();
int i = 0;
for (; i < src.rows; i++)
{
const auto src_ptr = src.ptr<uchar>(i);
auto dst_ptr = dst.ptr<uchar>(i);
int j = 0;
for (; j < byteCols; j ++)
{
dst_ptr[j] = src_ptr[j] ^ 0xFF;
}
}
}
static void bitwise_not_avx(const Mat &src, Mat &dst)
{
dst.create(src.size(), src.type());
const __m256i vxor = _mm256_set1_epi8(0xFF);
int byteCols = src.cols * src.channels(), step = 32;
int i = 0;
for (i = 0; i < src.rows; i++)
{
const auto src_ptr = src.ptr<uchar>(i);
auto dst_ptr = dst.ptr<uchar>(i);
int j = 0;
for (; j <= byteCols - step; j += step)
{
auto vx = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(src_ptr + j));
auto vy = _mm256_xor_si256(vx, vxor);
_mm256_storeu_si256(reinterpret_cast<__m256i *>(dst_ptr + j), vy);
}
for (; j < byteCols; j++)
{
dst_ptr[j] = src_ptr[j] ^ 0xFF;
}
}
}
static void BM_cvbitwise_not(benchmark::State &state)
{
auto src = imread("img.png");
cv::Mat cv_dst;
for (auto _ : state)
{
cv::bitwise_not(src, cv_dst);
benchmark::DoNotOptimize(cv_dst.data);
benchmark::ClobberMemory();
}
}
static void BM_avxbitwise_not(benchmark::State &state)
{
auto src = imread("img.png");
cv::Mat cv_dst;
for (auto _ : state)
{
::bitwise_not_avx(src, cv_dst);
benchmark::DoNotOptimize(cv_dst.data);
benchmark::ClobberMemory();
}
}
static void BM_bitwise_not(benchmark::State &state)
{
auto src = imread("img.png");
cv::Mat cv_dst;
for (auto _ : state)
{
::bitwise_not(src, cv_dst);
benchmark::DoNotOptimize(cv_dst.data);
benchmark::ClobberMemory();
}
}
BENCHMARK(BM_avxbitwise_not);
BENCHMARK(BM_cvbitwise_not);
BENCHMARK(BM_bitwise_not);
BENCHMARK_MAIN();
这个 benchmark 的写法整体没什么问题。图片读取放在测试循环之外,这一点是对的,因为如果把 imread 放进 for (auto _ : state) 里,测出来的就不再是算法性能,而是"算法 + 磁盘 IO"的混合结果了。DoNotOptimize 和 ClobberMemory 也保留了,这能尽量避免编译器把整个过程优化掉。
五、结果该怎么理解
这次测试最直观的结论当然是:普通循环非常慢,手写 AVX2 和 OpenCV 都很快。但如果只停留在这个层面,其实还不够。更值得看的,是为什么 OpenCV 还能略快一点。
先说普通循环。它慢其实很好理解,因为它一次只处理一个字节,在这种完全适合并行批处理的场景下,等于主动放弃了 CPU 的向量寄存器能力。你可以把它理解成让一个本来能一次搬 32 块砖的人,每次只搬 1 块,那自然效率会低很多。
而 AVX2 之所以快,是因为它一次处理 32 个字节。对于 bitwise_not 这样几乎没有任何复杂逻辑的操作来说,SIMD 带来的收益非常直接。它不是通过某种高深的数学变换变快的,而是通过"同样的指令,一次干更多活"变快的。这类优化的核心从来都不是"算法复杂度降低",而是"单位时间吞吐量提高"。
至于为什么 OpenCV 会比手写 AVX2 还略快一点,其实也不难理解。像 bitwise_not 这种基础算子,OpenCV 作为成熟图像库,内部大概率已经做了长期优化。它可能会根据平台进行指令分发,也可能在连续内存、对齐方式、边界处理等方面做了更稳妥的实现。换句话说,自己手写一个基础 AVX2 版本,能接近 OpenCV,其实已经说明方向是对的;而 OpenCV 略快一点,更多只是说明成熟库在细节上做得更深。
六、这段代码本身还能往哪里继续优化
虽然这次测试已经能说明问题了,但从"继续练手"的角度看,这段代码其实还有优化空间。最明显的一点就是,你现在是逐行处理的,也就是说每一行都会重新进入一层循环,重新计算边界。如果图像本身是连续存储的,那么完全可以把整张图视作一个大的连续数组,一次性处理到底,这样能少掉一层行循环带来的开销。
例如思路上可以写成这样:
if (src.isContinuous() && dst.isContinuous())
{
byteCols *= src.rows;
rows = 1;
}
这样后面只需要跑一层大循环,就不用每一行都重复做同样的事。当然,这个优化不会像"标量变 AVX2"那样带来数量级变化,但在这种本身已经很轻量的操作里,细节优化有时也会有一点收益。
另外一个值得补的点是输入类型检查。你这里默认使用 uchar 指针访问数据,因此本质上是针对 CV_8U 类型图像来写的。如果后面想把它写得更规范一些,可以加一条 CV_Assert(src.depth() == CV_8U);,防止误传其他类型图像。
七、从这个例子里真正应该学到什么
我觉得这个测试最有意思的地方,并不是"我手写 AVX2 到底有没有赢 OpenCV",而是它把 SIMD 这件事的本质展示得非常清楚。很多时候刚开始学向量化,会觉得那些 intrinsic 很抽象,好像全是寄存器和指令的细节;但一旦放到这种简单问题上,你就能很容易看出它到底在做什么。
普通循环是"读 1 个字节,异或,写 1 个字节"。AVX2 是"读 32 个字节,一起异或,一起写回"。差别其实就这么朴素。SIMD 并不神秘,它只是把原本一个个做的相同操作,打包成一批一起做。bitwise_not 恰好是最适合入门理解这个思想的例子之一。
同时,这个例子也提醒我一点:不要轻易低估成熟库。很多基础算子之所以看起来简单,只是因为接口简单,底层实现往往一点都不简单。真正想超过这些库,通常需要更多对齐、连续内存、预取、循环展开、平台分发等层面的细节优化,而不是"只要我用了 intrinsic 就一定会赢"。
八、小结
这次对 bitwise_not 的测试,结果其实很有代表性。普通循环版本最直观,也最容易手写,但性能最差;手写 AVX2 可以把性能大幅拉上来,说明 SIMD 在这种逐字节独立操作上非常有效;而 OpenCV 自带版本依然略快,说明成熟库在基础路径上的优化往往已经非常完整。
如果只用一句话来总结这次实验,那就是:对于图像反色这种计算简单、数据量大的操作,SIMD 能显著提升性能;而手写 SIMD 的意义,不只是跑得更快,更重要的是让自己真正理解"为什么会更快"。