bitwise_not`性能测试:手写普通循环、AVX2 与 OpenCV 内置实现对比

在学习 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"的混合结果了。DoNotOptimizeClobberMemory 也保留了,这能尽量避免编译器把整个过程优化掉。


五、结果该怎么理解

这次测试最直观的结论当然是:普通循环非常慢,手写 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 的意义,不只是跑得更快,更重要的是让自己真正理解"为什么会更快"。

相关推荐
INDEMIND1 小时前
牵手海尔、TCL,INDEMIND家用具身陪伴机器人AI平台加速家庭AI陪伴落地
人工智能·机器人·陪伴机器人
wheelmouse77881 小时前
AI 时代的 Git 进阶术:如何优雅地让多个 Agent 并行开发
人工智能·git·ai编程
ZPC82101 小时前
PPO (Proximal Policy Optimization) 算法模块详细拆解
人工智能·pytorch·算法·机器人
仙女修炼史2 小时前
FCOS: Fully Convolutional One-Stage Object Detection
人工智能·目标检测·目标跟踪
大傻^2 小时前
Spring AI Alibaba 多模态开发:集成视觉理解与视频分析能力
人工智能·spring·音视频·springai·springaialibaba·混合检索
前端摸鱼匠2 小时前
面试题3:自注意力机制(Self-Attention)的计算流程是什么?
人工智能·ai·面试·职场和发展
出门吃三碗饭2 小时前
CARLA: 如何在 CARLA 中回放自动驾驶场景
人工智能·机器学习·自动驾驶
Axis tech2 小时前
第二届人形机器人半程马拉松即将于4月开赛,对比去年技术进步有哪些?
人工智能·机器人
志栋智能2 小时前
超自动化巡检,如何成为业务稳定的“压舱石”?
大数据·运维·网络·人工智能·自动化