在图像处理中,阈值二值化是一个非常基础但又非常经典的操作。它的逻辑并不复杂:对图像中的每个像素做一次判断,大于阈值就赋值为某个最大值,否则赋值为 0。以 OpenCV 的 THRESH_BINARY 为例,其语义可以写成:
dst[i] = src[i] > thresh ? maxVal : 0;
从算法角度看,这件事几乎没有"算法难度",因为它本质上只是一次逐元素扫描。但也正因为逻辑极其简单,它特别适合用来研究底层性能优化:普通循环能不能更快?查表能不能更快?并行能不能更快?手写 AVX2 能不能打过 OpenCV 自带实现?
这次我就基于一张灰度图,分别实现并测试了三种方式:LUT + parallel_for_ 版本、手写 AVX2 版本、OpenCV 自带 cv::threshold 版本,并对性能结果做一个系统分析。
一、测试方法与三种实现思路
这次测试使用的是 Google Benchmark,测试对象是一张读入后转成灰度图的图像。阈值固定为 128,操作目标是生成二值图。最终对比的三种实现分别如下。
第一种是 LUT + OpenCV 并行 版本。这个版本先构造一个 256 大小的查找表,把 0~255 的所有输入值预先映射成输出值。比如当阈值为 128 时,0~128 对应输出 0,129~255 对应输出 255。真正处理图像时,每个像素不再做分支判断,而是直接:
d[c] = lut[s[c]];
为了进一步提升吞吐量,这个版本按行使用 cv::parallel_for_ 并行处理,并在每一行内部做了 16 次手动展开,尽量减少循环控制开销。
第二种是 手写 AVX2 版本。这个版本直接从 SIMD 角度出发,一次处理 32 个 uchar。因为 uchar 是无符号 8 位,而 AVX2 的 _mm256_cmpgt_epi8 做的是有符号比较 ,所以不能直接拿来比较 0~255 的无符号像素。这里用了一个非常经典的小技巧:把输入像素和阈值都先异或 0x80,把无符号区间 [0,255] 映射到有符号区间 [-128,127],这样就能继续使用 _mm256_cmpgt_epi8 完成比较。得到 mask 后,再通过 _mm256_blendv_epi8 在 0 和 255 之间选出结果,最后写回输出图像。
第三种则是直接调用 OpenCV 的 cv::threshold。这其实就是拿工业级库实现作为基准,看看我们自己写的版本究竟到了什么水平。
二、核心代码设计分析
1. LUT 并行版:最"工程化"的手写优化
LUT 版本的核心思想非常朴素:既然阈值比较的输入范围只有 0~255,那就直接把所有可能结果预先算好。这样图像遍历时就不再需要分支,也不再需要每次都重新比较阈值,而是直接查表。
这种方式的优点很明显。首先,它把每个像素的处理逻辑压缩到了极小:一次内存读取,一次 LUT 索引,一次写回。其次,它非常稳定,不需要考虑 SIMD 指令集兼容性,也不需要处理有符号无符号比较的细节。再次,它和 parallel_for_ 的组合也很自然,因为按行划分任务几乎没有额外复杂性。
但它的局限也很明显:虽然逻辑上"省掉了比较",但实际上每个像素还是要多走一次 LUT 访问,这本质上还是一次内存依赖操作。对于这种极简单算子来说,性能常常不是被"算力"限制,而是被"内存访问"和"调度开销"限制。特别是图像不够大时,并行本身的开销反而可能不划算。
2. AVX2 版:最"底层"的优化练习
AVX2 版本更接近底层 SIMD 编程的本质。它不再把问题看成"一个个像素判断",而是看成"每次搬 32 个字节到寄存器里,统一做比较和选择"。
这一版最值得注意的地方不是 load/store,而是无符号阈值比较的处理方法 。因为 _mm256_cmpgt_epi8 处理的是有符号 int8,如果直接拿 uchar 像素去比较,超过 127 的值就会被解释成负数,逻辑会彻底错乱。所以代码中先构造:
const __m256i vthresh = _mm256_set1_epi8(thresh ^ 0x80);
const __m256i vxor = _mm256_set1_epi8(static_cast<char>(0x80));
然后每批输入都做:
auto vm_signed = _mm256_xor_si256(vm, vxor);
auto mask = _mm256_cmpgt_epi8(vm_signed, vthresh);
这一步其实就是把"无符号比较"变成了"有符号比较"。从原理上看,它没有改变大小关系,只是换了一套坐标系。这个技巧在 AVX2 里非常常见,也是 uchar SIMD 处理中很值得掌握的一类方法。
得到 mask 后,再用:
auto vy = _mm256_blendv_epi8(vzero, v255, mask);
完成结果选择。也就是说,这一版的 SIMD 主循环结构已经很清晰了:load → 映射到 signed 域 → compare → blend → store。从学习角度看,这是一段很典型也很有价值的 AVX2 练手代码。
3. OpenCV threshold:成熟库的工业级实现
cv::threshold 看起来只是一个简单 API,但它背后往往不是"普通 for 循环"。OpenCV 在这类基础算子上通常会根据平台能力自动选择更优路径,比如专门的 SIMD 分支、优化过的循环、可能的并行实现以及更细致的边界处理。
也就是说,虽然表面上你只是写了一行:
cv::threshold(src, dst, thresh, 255, cv::THRESH_BINARY);
但实际调用的很可能已经是经过平台适配和长期打磨的高性能实现。所以它作为对照组,既是基线,也是目标。
三、Benchmark 结果解读
你的测试结果如下:
Benchmark Time CPU Iterations
BM_threshold_fast/128 40054 ns 36409 ns 22710
BM_threshold_avx/128 11693 ns 10631 ns 74231
BM_cvthreshold/128 10666 ns 9697 ns 67263
如果只看 CPU 时间,那么三者大致关系非常清楚:
LUT 并行版:36409 ns
手写 AVX2 版:10631 ns
OpenCV threshold:9697 ns
先看最明显的一点:LUT + parallel_for_ 明显最慢。它比手写 AVX2 慢了大约 3.4 倍,比 OpenCV 自带版本慢了接近 3.8 倍。这说明在这次实验环境下,这种"查表 + 并行"的策略并不适合这个算子。
为什么会这样?因为阈值二值化实在太简单了。每个像素只有极少的计算量,这意味着并行调度带来的线程分发、任务切分、lambda 捕获等成本,可能已经接近甚至超过了真正的图像计算成本。换句话说,这不是一个"算得很重,值得开多线程"的任务,而更像是一个"遍历很快,调度反而成了负担"的任务。再加上 LUT 本身也会引入额外的内存访问,因此最终表现并不理想。
再看第二点:手写 AVX2 已经非常接近 OpenCV 。你的 AVX2 版本 CPU 时间是 10631 ns,而 cv::threshold 是 9697 ns,差距大概只有 9% 左右。对于一个自己手写的基础 SIMD 实现来说,这个结果其实已经相当不错了。这说明你的 SIMD 主循环思路是正确的,尤其是在 uchar 无符号比较这个细节上,处理方式已经和真正工程代码很接近。
最后看第三点:OpenCV 还是略快一点。这一点并不意外。工业库实现通常有几个天然优势。第一,它可能用了更成熟的底层 dispatch 路径。第二,它可能在循环展开、边界处理、内存访问组织上做得更细。第三,它可能针对特定数据布局或 CPU 做了长期调优。你手写的 AVX2 版虽然已经很好,但还属于"正确而简洁"的学习型实现;而 OpenCV 的目标是"尽可能榨干平台性能"的生产级实现。
四、为什么 LUT 并行反而输了
这个结果其实特别值得单独讲一下,因为很多人一开始会下意识觉得:查表很快,并行也很快,那两个结合起来应该更快才对。但真实工程里不一定如此。
首先,阈值二值化本身的计算过于简单,几乎没有复杂算术。它不像卷积、滤波、矩阵乘法那样单个元素的计算负担很重,所以"多线程平摊计算成本"的收益有限。其次,LUT 虽然减少了分支,但并没有减少内存操作,反而引入了一次额外索引依赖。最后,parallel_for_ 的开销在图像规模不够大时很容易变得显眼。
所以这一组测试的真正结论不是"LUT 不好",而是:对于像 threshold 这样极轻量、极规则、极适合 SIMD 的基础算子,手写 SIMD 或库内 SIMD 路径往往比 LUT 并行更有优势。
换句话说,LUT 更适合那种"逻辑复杂,但输入值范围很小"的场景;而 threshold 这种本来就简单到极致的算子,更适合直接走向量化。
五、为什么手写 AVX2 没能超过 OpenCV
很多人写完 SIMD 代码以后,都会自然地期待"只要我用了 AVX2,就一定能比库更快"。但现实里并不是这样。你这次结果就很典型:自己写的 AVX2 很强,但 OpenCV 仍然略胜一筹。
本质原因在于,"使用了 AVX2"只说明你进入了优化赛道,不代表你一定跑到了赛道终点。 真正影响性能的还有很多细节,比如:
你的主循环是否做了更激进的展开;
边界处理是否尽量减少跳转;
内存访问是否完全贴合缓存行为;
编译器是否能进一步优化寄存器分配;
库内部是否根据 CPU 特性选择了更优路径;
是否还结合了线程、HAL、IPP 或其他底层后端。
也就是说,AVX2 只是"工具",不是"自动胜利按钮"。但反过来说,你能把手写版本做到和 OpenCV 只差不到 10%,已经说明你的底层理解是到位的。
六、这次实验最大的收获是什么
如果把这次实验抽象成几个结论,我觉得最有价值的是下面这几点。
第一,阈值二值化是非常适合 SIMD 入门的算子。它逻辑简单、数据连续、没有复杂依赖,主循环结构非常清楚,特别适合用来练习 load、compare、blend、store 这一整套 SIMD 基本套路。
第二,uchar 的无符号比较是 AVX2 里的一个关键难点 。你这次通过 ^ 0x80 的方式把无符号比较转成有符号比较,这个技巧非常值得掌握。它说明真正写 SIMD 时,难点常常不在"怎么搬数据",而在"怎么正确表达语义"。
第三,并行不总是更快,LUT 也不总是更快。优化不能靠想象,必须靠 benchmark。一个看起来"理论上很合理"的方案,在轻量算子上可能反而被调度和访存拖慢。
第四,工业库的高性能来自大量细节积累。你这次和 OpenCV 的差距已经很小了,但那一点点差距背后,往往就是成熟库长时间打磨出来的价值。