OpenCV 通用内部函数:C++ 代码矢量化实战指南
目标
本教程的目标是提供使用功能矢量化 C++ 代码以提高运行速度的指南。我们将简要介绍 SIMD 内部函数以及如何使用宽寄存器,然后介绍使用宽寄存器的基本操作。
OpenCV 通用内部函数(Universal Intrinsics)的使用方法,旨在通过 SIMD 技术提升 C++ 代码运行速度。内容涵盖理论概念、寄存器结构、加载存储操作、算术与逻辑运算、归约与掩码操作,并通过一维及二维卷积的标量与矢量实现对比演示了性能优化效果。文章详细展示了如何定义可变与恒定大小寄存器、执行元素级运算以及处理边界情况,同时提供了关于数据对齐、编译器优化及跨平台兼容性的最佳实践建议。

本教程的目标是提供使用功能矢量化 C++ 代码以提高运行速度的指南。我们将简要介绍 SIMD 内部函数以及如何使用宽寄存器,然后介绍使用宽寄存器的基本操作。
在本节中,我们将简要介绍一些概念,以帮助更好地理解该功能。
内部函数是由编译器单独处理的函数。这些函数通常经过优化,以最有效的方式执行,因此运行速度比正常实现更快。但是,由于这些函数依赖于编译器,因此很难编写可移植的应用程序。
SIMD 代表 单指令,多数据(Single Instruction, Multiple Data)。SIMD 内部函数允许处理器对计算进行矢量化。数据存储在所谓的寄存器中。寄存器可以是 128 位、256 位或 512 位宽。每个寄存器存储相同数据类型的多个值。寄存器的大小和每个值的大小决定了总共存储的值的数量。
根据 CPU 支持的指令集,您可以使用不同的寄存器。
OpenCV 的通用内部函数提供了对 SIMD 矢量化方法的抽象,并允许用户使用内部函数,而无需编写特定于系统的代码。
OpenCV Universal Intrinsics 支持以下指令集:
现在,我们将介绍可用的结构和功能:
通用内部函数集将每个寄存器实现为基于特定 SIMD 寄存器的结构。所有类型都包含枚举,该枚举给出类型可以容纳的值的确切数量。这样就无需在实现过程中对值的数量进行硬编码 nlanes。
注意:每个寄存器结构都在命名空间 cv 下。
有两种类型的寄存器:
这些结构没有固定的大小,它们的确切位长度是在编译过程中根据可用的 SIMD 功能推断出来的。因此,枚举的值是在编译时确定的。
每个结构都遵循以下约定:
v_[type of value][size of each value in bits]
例如,v_uint8 保存 8 位无符号整数,v_float32 保存 32 位浮点值。然后我们声明一个寄存器,就像我们在 C++ 中声明任何对象一样。
根据可用的 SIMD 指令集,特定寄存器将保存不同数量的值。例如:如果您的计算机支持最大 256 位寄存器:
v_uint8 将保存 32 个 8 位无符号整数。v_float64 将容纳 4 个 64 位浮点数(双精度)。cv::v_uint8 a; // a is a register supporting uint8(char) data
int n = a.nlanes(); // n holds 32
可用的数据类型和大小:
| 类型 | 大小(以位为单位) |
|---|---|
| uint | 8, 16, 32, 64 |
| int | 8, 16, 32, 64 |
| float | 32, 64 |
这些结构具有固定的位大小并保存恒定数量的值。我们需要知道系统支持哪些 SIMD 指令集,并选择兼容的寄存器。仅当需要确切的位长度时才使用它们。
每个结构都遵循约定:
v_[type of value][size of each value in bits]x[number of values]
假设我们要存储 128 位寄存器中的 32 位有符号整数。由于寄存器大小是已知的,我们可以找出寄存器中的数据点数(128/32 = 4):
cv::v_int32x4 reg1; // holds 4 32-bit signed integers.
512 位寄存器中的 64 位浮点数:
cv::v_float64x8 reg2; // reg2.nlanes() = 8
现在我们知道寄存器是如何工作的,让我们看一下用于用值填充这些寄存器的函数。
加载函数允许您将值加载到寄存器中。
构造函数 - 在声明寄存器结构时,我们可以提供一个内存地址,寄存器将从中拾取连续的值,或者将值显式地作为多个参数提供(显式多个参数仅适用于恒定大小的寄存器):
float ptr[32] = {1, 2, 3, ..., 32}; // ptr is a pointer to a contiguous memory block of 32 floats
// Variable Sized Registers
cv::v_float32 reg1(ptr); // reg1 stores first x values according to the maximum register size available.
cv::v_float32 reg2(ptr + x); // reg stores the next x values
// Constant Sized Registers
cv::v_float32x4 reg1(ptr); // reg1 stores the first 4 floats (1, 2, 3, 4)
cv::v_float32x4 reg2(ptr + 4); // reg2 stores the next 4 floats (5, 6, 7, 8)
// Or we can explicitly write down the values.
cv::v_float32x4(1, 2, 3, 4);
Load 函数 - 我们可以使用 load 方法并提供数据的内存地址:
float ptr[32] = {1, 2, 3, ..., 32};
cv::v_float32 reg_var;
reg_var = cv::v_load(ptr); // loads values from ptr[0] upto ptr[reg_var.nlanes() - 1]
cv::v_float32x4 reg_128;
reg_128 = cv::v_load(ptr); // loads values from ptr[0] upto ptr[3]
cv::v_float32x8 reg_256;
reg_256 = cv::v_load(ptr); // loads values from ptr[0] upto ptr[7]
cv::v_float32x16 reg_512;
reg_512 = cv::v_load(ptr); // loads values from ptr[0] upto ptr[15]
注意:load 函数假定数据未对齐。如果您的数据是对齐的,则可以使用 v_load_aligned()。
存储函数允许您将寄存器中的值存储到特定的内存位置。
要将寄存器中的值存储到内存位置,可以使用 v_store 函数:
float ptr[4];
cv::v_store(ptr, reg); // store the first 128 bits(interpreted as 4x32-bit floats) of reg into ptr.
注意:确保 ptr 与寄存器具有相同的类型。您还可以在执行操作之前将寄存器转换为正确的类型。简单地对指向特定类型的指针进行类型转换将导致对数据的错误解释。
通用内部函数集提供元素二元和一元运算。
我们可以按元素对两个寄存器进行加、减、乘和除。寄存器必须具有相同的宽度并保持相同的类型。将两个寄存器相乘,例如:
cv::v_float32 a, b; // {a1, ..., an}, {b1, ..., bn}
cv::v_float32 c;
c = a + b // {a1 + b1, ..., an + bn}
c = a * b; // {a1 * b1, ..., an * bn}
我们可以对寄存器的每个元素的位进行左移或右移。我们还可以在两个寄存器之间按元素应用按位 &、|、^ 和 ~ 运算符:
cv::v_int32 as; // {a1, ..., an}
cv::v_int32 al = as << 2; // {a1 << 2, ..., an << 2}
cv::v_int32 bl = as >> 2; // {a1 >> 2, ..., an >> 2}
cv::v_int32 a, b;
cv::v_int32 a_and_b = a & b; // {a1 & b1, ..., an & bn}
我们可以使用 <、>、<=、>=、== 和 != 运算符比较两个寄存器之间的值。由于每个寄存器都包含多个值,因此这些操作不会得到单个布尔值。相反,对于真值,所有位都转换为 1(8 位为 0xff,16 位为 0xffff,以此类推),而 false 值返回转换为零的位。
// let us consider the following code is run in a 128-bit register
cv::v_uint8 a; // a = {0, 1, 2, ..., 15}
cv::v_uint8 b; // b = {15, 14, 13, ..., 0}
cv::v_uint8 c = a < b;
/*
let us look at the first 4 values in binary
a = |00000000|00000001|00000010|00000011|
b = |00001111|00001110|00001101|00001100|
c = |11111111|11111111|11111111|11111111|
If we store the values of c and print them as integers, we will get 255 for true values and 0 for false values.
*/
// In a computer supporting 256-bit registers
cv::v_int32 a; // a = {1, 2, 3, 4, 5, 6, 7, 8}
cv::v_int32 b; // b = {8, 7, 6, 5, 4, 3, 2, 1}
cv::v_int32 c = (a < b); // c = {-1, -1, -1, -1, 0, 0, 0, 0}
/*
The true values are 0xffffffff, which in signed 32-bit integer representation is equal to -1.
*/
我们可以使用 v_min() 和 v_max() 函数返回包含两个寄存器的元素最小值或最大值的寄存器:
cv::v_int32 a; // {a1, ..., an}
cv::v_int32 b; // {b1, ..., bn}
cv::v_int32 mn = cv::v_min(a, b); // {min(a1, b1), ..., min(an, bn)}
cv::v_int32 mx = cv::v_max(a, b); // {max(a1, b1), ..., max(an, bn)}
注意:比较和最小/最大运算符不适用于 64 位整数。按位移位和逻辑运算符仅适用于整数值。按位移位仅适用于 16、32 和 64 位寄存器。
v_reduce_min()、v_reduce_max() 和 v_reduce_sum() 返回一个值,表示整个寄存器的最小值、最大值或总和:
cv::v_int32 a; // a = {a1, ..., a4}
int mn = cv::v_reduce_min(a); // mn = min(a1, ..., an)
int sum = cv::v_reduce_sum(a); // sum = a1 + ... + an
掩码操作允许我们在宽寄存器中复制条件。这些包括:
cv::v_uint8 a; // {a1, .., an}
cv::v_uint8 b; // {b1, ..., bn}
cv::v_int32x4 mask; // {0xff, 0, 0, 0xff, ..., 0xff, 0}
cv::v_uint8 Res = cv::v_select(mask, a, b); // {a1, b2, b3, a4, ..., an-1, bn}
/*
"Res" will contain the value from "a" if mask is true (all bits set to 1),
and value from "b" if mask is false (all bits set to 0)
We can use comparison operators to generate mask and v_select to obtain results based on conditionals.
It is common to set all values of b to 0. Thus, v_select will give values of "a" or 0 based on the mask.
*/
在下一节中,我们将矢量化单通道的简单卷积函数,并将结果与标量实现进行比较。
注意:并非所有算法都可以通过手动矢量化进行改进。事实上,在某些情况下,编译器可能会自动矢量化代码,从而为标量实现产生更快的结果。
我们将首先实现一维卷积,然后对其进行矢量化。2-D 矢量化卷积将在各行之间执行 1-D 卷积以产生正确的结果。
void conv1d(const Mat& src, Mat& dst, const Mat& kernel)
{
int len = src.cols;
dst = Mat(1, len, CV_32F);
int sz = kernel.cols / 2;
copyMakeBorder(src, src, 0, 0, sz, sz, BORDER_REPLICATE);
for (int i = 0; i < len; i++)
{
double value = 0;
for (int k = -sz; k <= sz; k++)
value += src.ptr<float>(0)[i + k + sz] * kernel.ptr<float>(0)[k + sz];
dst.ptr<float>(0)[i] = (float)value;
}
}
我们首先设置变量并在 src 矩阵的两侧创建边框,以处理边缘情况。
对于主循环,我们选择一个索引 i,并使用 k 变量将其与内核一起偏移。我们将值存储在 value 中,并将其添加到 dst 矩阵中。
我们现在将看一维卷积的矢量化版本。
void conv1dsimd(const Mat& src, const Mat& kernel, float* ans, int row = 0, int rowk = 0, int len = -1)
{
if (len == -1)
len = src.cols;
Mat src_32, kernel_32;
const float alpha = 1.0f;
src.convertTo(src_32, CV_32F, alpha);
int ksize = kernel.cols, sz = kernel.cols / 2;
copyMakeBorder(src_32, src_32, 0, 0, sz, sz, BORDER_REPLICATE);
int step = cv::Traits<cv::v_float32x4>::vlanes();
float* sptr = src_32.ptr(row), *kptr = kernel.ptr(rowk);
for (int k = 0; k < ksize; k++)
{
cv::v_float32 kernel_wide = cv::v_setall_f32(kptr[k]);
int i;
for (i = 0; i + step < len; i += step)
{
cv::v_float32 window = cv::v_load(sptr + i + k);
cv::v_float32 sum = cv::v_add(cv::v_load(ans + i), cv::mul(kernel_wide, window));
cv::v_store(ans + i, sum);
}
for (; i < len; i++)
{
*(ans + i) += sptr[i + k] * kptr[k];
}
}
}
在我们的例子中,内核是一个浮点数。由于内核的数据类型最大,我们将 src 转换为 float32,形成 src_32。我们也像对待标量案件一样划定边界。
现在,对于 kernel 中的每一列,我们计算该值与所有长度为 step 的窗口向量的标量乘积。我们将这些值添加到已存储的值中。
我们声明一个指针到 src_32 和 kernel 并运行一个循环处理每个内核元素。
我们加载一个寄存器与当前内核元素。窗口从 0 移动到 len - step 并与其与 kernel_wide 数组的乘积添加到存储在 ans 中的值。我们将值存回 ans。
由于长度可能不能被 steps 整除,我们直接处理剩余的值。尾部值的数量将始终小于 step 并且不会显著影响性能。我们将所有值存储到 ans,这是一个 float 指针。我们也可以直接将它们存储在一个 Mat 对象中。
以下是迭代示例:
For example:
kernel: {k1, k2, k3}
src: ...|a1|a2|a3|a4|...
iter1:
for each idx i in (0, len), 'step' idx at a time
kernel_wide: |k1|k1|k1|k1|
window: |a0|a1|a2|a3|
ans: ...| 0| 0| 0| 0|...
sum = ans + window * kernel_wide
= |a0 * k1|a1 * k1|a2 * k1|a3 * k1|
iter2:
kernel_wide: |k2|k2|k2|k2|
window: |a1|a2|a3|a4|
ans: ...|a0 * k1|a1 * k1|a2 * k1|a3 * k1|...
sum = ans + window * kernel_wide
= |a0 * k1 + a1 * k2|a1 * k1 + a2 * k2|a2 * k1 + a3 * k2|a3 * k1 + a4 * k2|
iter3:
kernel_wide: |k3|k3|k3|k3|
window: |a2|a3|a4|a5|
ans: ...|a0 * k1 + a1 * k2|a1 * k1 + a2 * k2|a2 * k1 + a3 * k2|a3 * k1 + a4 * k2|...
sum = sum + window * kernel_wide
= |a0*k1 + a1*k2 + a2*k3|a1*k1 + a2*k2 + a3*k3|a2*k1 + a3*k2 + a4*k3|a3*k1 + a4*k2 + a5*k3|
Note:函数参数还包括 row, rowk 和 len。这些值在使用函数作为 2-D 卷积的中间步骤时使用。
假设我们的内核有 ksize 行。为了计算特定行的值,我们计算前一行 ksize/2 和下一行 ksize/2 的一维卷积,以及相应的内核行。最终值只是单个一维卷积的总和。
void convolute_simd(const Mat& src, Mat& dst, const Mat& kernel)
{
int rows = src.rows, cols = src.cols;
int ksize = kernel.rows, sz = ksize / 2;
dst = Mat(rows, cols, CV_8U);
copyMakeBorder(src, src, sz, sz, 0, 0, BORDER_REPLICATE);
int step = cv::Traits<cv::v_float32x4>::vlanes();
for (int i = 0; i < rows; i++)
{
for (int k = 0; k < ksize; k++)
{
float ans[N] = {0};
conv1dsimd(src, kernel, ans, i + k, k, cols);
int j;
for (j = 0; j + step < cols; j += step)
{
cv::v_float32 sum = cv::v_add(cv::v_load(&dst.ptr<float>(i)[j]), cv::v_load(&ans[j]));
cv::v_store(&dst.ptr<float>(i)[j], sum);
}
for (; j < cols; j++)
dst.ptr<float>(i)[j] += ans[j];
}
}
const float alpha = 1.0f;
dst.convertTo(dst, CV_8U, alpha);
}
我们首先初始化变量,并在 src 矩阵的上方和下方创建一个边框。左右两侧由一维卷积函数处理。
对于每一行,我们计算其上方和下方行的一维卷积。然后,我们将这些值添加到 dst 矩阵中。
我们最终将 dst 矩阵转换为 8 位 矩阵。
在本教程中,我们使用了水平梯度内核。对于这两种方法,我们获得了相同的输出图像。
运行时的改进各不相同,取决于 CPU 中可用的 SIMD 功能。
在进行手动矢量化时,请注意以下几点以确保代码的正确性和性能:
v_load 可以处理非对齐数据,但使用 v_load_aligned 配合对齐的内存分配(如 aligned_alloc 或 OpenCV 的 alignSize)通常能获得更好的性能。-O3 -march=native),观察是否已有自动向量化效果,再决定是否手动干预。if-else 语句通常能保持流水线效率。
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online