上一个教程 : 如何使用 OpenCV parallel_for_ 实现代码并行化
_ | _ |
---|---|
兼容性 | OpenCV >= 3.0 |
目标
本教程的目的是指导如何使用 Universal intrinsics 功能来矢量化 C++ 代码,以提高运行速度。我们将简要介绍 SIMD 内部函数以及如何使用宽寄存器,随后将介绍使用宽寄存器进行基本操作的教程。
理论
在本节中,我们将简要介绍几个概念,以便更好地帮助理解相关功能。
内部函数
内部函数是由编译器单独处理的函数。这些函数通常经过优化,以尽可能最高效的方式执行,因此运行速度比普通实现更快。不过,由于这些函数依赖于编译器,因此很难编写可移植的应用程序。
SIMD
SIMD 代表单指令、多数据。SIMD 内核允许处理器进行矢量化计算。数据存储在所谓的寄存器中。一个寄存器的宽度可以是 128 位、256 位或 512 位。每个寄存器存储相同数据类型的多个值。寄存器的大小和每个值的大小决定了存储的总值数量。
根据 CPU 支持的指令集,您可以使用不同的寄存器。要了解更多信息,请点击此处
通用内部函数
OpenCV 的通用内部函数(universal intrinsics)为 SIMD 矢量化方法提供了一个抽象,允许用户使用内部函数,而无需编写系统专用代码。
OpenCV 通用内核支持以下指令集:
- 各种类型的 128 位寄存器支持,适用于多种体系结构,包括
- x86(SSE/SSE2/SSE4.2)、
- ARM(NEON)、
- PowerPC(VSX)
- MIPS(MSA)。
- x86(AVX2) 支持 256 位寄存器,ARM(NEON)支持 512 位寄存器。
- x86(AVX512) 支持 512 位寄存器。
下面我们将介绍可用的结构和功能:
- 寄存器结构
- 加载和存储
- 数学运算
- 还原和屏蔽
寄存器结构
通用内部函数集根据特定的 SIMD 寄存器,将每个寄存器作为一个结构来实现。所有类型都包含 nlanes 枚举,该枚举给出了类型可容纳值的确切数量。这样,在实现过程中就无需对值的数量进行硬编码。
注意事项
每个寄存器结构都属于cv
命名空间。
寄存器有两种类型
- 可变大小寄存器: 这些结构没有固定大小,在编译过程中会根据可用的 SIMD 功能推导出它们的确切位长。因此,
nlanes
枚举的值是在编译时确定的。
每个结构遵循以下约定:
v_[值的类型][每个值的大小(位]
例如,v_uint8 表示 8 位无符号整数,v_float32 表示 32 位浮点数值。然后,我们像在 C++ 中声明任何对象一样声明寄存器
根据可用的 SIMD 指令集,特定寄存器将保存不同数量的值。例如 如果您的计算机最大支持 256 位寄存器、
-
- v_uint8 将保存 32 个 8 位无符号整数
- v_float64 将保存 4 个 64 位浮点数(双倍数)
v_uint8 a; // a 是一个支持 uint8(字符)数据的寄存器
int n = a.nlanes; // n 存放 32 个整数
可用数据类型和大小:
类型 | 大小(位) |
---|---|
uint | 8、16、32、64 |
int | 8、16、32、64 |
float | 32, 64 |
- 常量寄存器: 这些结构具有固定的位大小,并保存恒定数量的值。我们需要知道系统支持什么 SIMD 指令集,然后选择兼容的寄存器。只有在需要精确位长的情况下才使用这些寄存器。
每个结构都遵循以下约定
v_[值的类型][每个值的位数大小]x[值的个数]
假设我们要存储
- 在 128 位寄存器中存储 32 位(位数大小)带符号整数。由于已经知道寄存器的大小,我们可以求出寄存器中的数据点数(128/32 = 4):
v_int32x8 reg1 // 保存 8 个 32 位带符号整数。
- 512 位寄存器中的 64 位浮点数:
v_float64x8 reg2 // reg2.nlanes = 8
加载和存储操作
既然我们已经知道寄存器是如何工作的,那么让我们来看看用于向这些寄存器填充数值的函数。
-
加载 加载函数允许将数值加载到寄存器中。
- 构造函数 - 在声明寄存器结构时,我们可以提供一个内存地址,寄存器将从该地址获取连续的数值,或者以多个参数的形式明确提供数值(明确的多个参数仅适用于恒定大小的寄存器):
float ptr[32] = {1, 2, 3 ..., 32}; // ptr 是指向 32 个浮点数连续内存块的指针
// 可变大小寄存器 //
int x = v_float32().nlanes; // 设置 x 为寄存器可容纳的数值个数
v_float32 reg1(ptr); // reg1 根据寄存器的最大可用容量存储前 x 个值。
v_float32 reg2(ptr + x); // reg 保存下一个 x 值
// 恒定大小的寄存器 //
v_float32x4 reg1(ptr); // reg1 存储前 4 个浮点数(1、2、3、4)
v_float32x4 reg2(ptr + 4); // reg2 存储后 4 个浮点数(5、6、7、8)
// 或者,我们可以明确写下这些值。
v_float32x4(1, 2, 3, 4);
- 加载函数 - 我们可以使用加载方法并提供数据的内存地址:
float ptr[32] = {1, 2, 3, ..., 32};
v_float32 reg_var;
reg_var = vx_load(ptr); // 加载 ptr[0] 至 ptr[reg_var.nlanes - 1] 的值
v_float32x4 reg_128;
reg_128 = v_load(ptr); // 读取 ptr[0] 到 ptr[3] 的值
v_float32x8 reg_256;
reg_256 = v_256_load(ptr); // 加载 ptr[0] 至 ptr[7] 的值
v_float32x16 reg_512;
reg_512 = v512_load(ptr); // 加载 ptr[0] 至 ptr[15] 的值
注意
加载函数假定数据未对齐。如果数据是对齐的,可以使用vx_load_aligned()
函数。
- 存储 使用存储函数可以将寄存器中的值存储到特定的内存位置。
- 要将寄存器中的值存储到内存位置,可以使用 v_store() 函数:
float ptr[4];
v_store(ptr, reg); // 将 reg 的前 128 位(解释为 4x32 位浮点数)存储到 ptr 中。
注意
确保ptr
与寄存器类型相同。在执行操作前,还可以将寄存器转换为适当的类型。简单地将指针类型转换为特定类型会导致对数据的错误解释。
二元和一元操作符
通用内在函数集提供了元素二元和一元操作。
- 算术运算 我们可以对两个寄存器进行加、减、乘、除运算。两个寄存器必须具有相同的宽度和类型。例如,对两个寄存器进行乘法运算:
v_float32 a, b; // {a1, ..., an}, {b1, ..., bn}
v_float32 c;
c = a + b // {a1 + b1, ..., an + bn}
c = a * b; // {a1 * b1, ..., an * bn}
- 比特逻辑和移位: 我们可以对寄存器中每个元素的位进行左移或右移。我们还可以在两个寄存器元素之间应用按位运算的 &、|、^ 和 ~ 操作符:
v_int32 as; // {a1, ..., an}
v_int32 al = as << 2; // {a1 << 2, ..., an << 2}
v_int32 bl = as >> 2; // {a1 >> 2, ..., an >> 2}
v_int32 a, b;
v_int32 a_and_b = a & b; // {a1 & b1, ..., an & bn}
- 比较操作符: 我们可以使用 <, >, <= , >=, == 和 != 操作符比较两个寄存器之间的值。由于每个寄存器都包含多个值,我们在进行这些操作时不会得到一个单一的 bool 值。相反,对于真值,所有位都转换为 1(8 位为 0xff,16 位为 0xffff,等等),而假值则返回转换为 0 的位。
// 让我们考虑在 128 位寄存器中运行以下代码
v_uint8 a; // a = {0, 1, 2, ..., 15}
v_uint8 b; // b = {15, 14, 13, ..., 0}
v_uint8 c = a < b;
/*
让我们看看前 4 个二进制值
a = |00000000|00000001|00000010|00000011|
b = |00001111|00001110|00001101|00001100|
c = |11111111|11111111|11111111|11111111|
如果我们存储 c 的值并将其打印为整数,我们将得到 255 表示真值,0 表示假值。
*/
---
// 在支持 256 位寄存器的计算机中
v_int32 a; // a = {1, 2, 3, 4, 5, 6, 7, 8}
v_int32 b; // b = {8, 7, 6, 5, 4, 3, 2, 1}
v_int32 c = (a < b); // c = {-1, -1, -1, -1, 0, 0, 0, 0}
/*
真值为 0xffffffff,在有符号的 32 位整数表示中等于-1。
*/
- 最小/最大操作: 我们可以使用函数 v_min() 和 v_max(),返回包含两个寄存器元素的最小值或最大值的寄存器:
v_int32 a; // {a1, ..., an}
v_int32 b; // {b1, ..., bn}
v_int32 mn = v_min(a, b); // {min(a1, b1), ..., min(an, bn)}
v_int32 mx = v_max(a, b); // {max(a1, b1), ..., max(an, bn)}
注释
比较运算符和最小/最大运算符不适用于 64 位整数。位移和逻辑运算符仅适用于整数值。位移操作仅适用于 16、32 和 64 位寄存器。
还原和屏蔽
- 还原操作: v_reduce_min()、v_reduce_max() 和 v_reduce_sum() 返回一个值,表示整个寄存器的最小值、最大值或总和:
v_int32 a; // a = {a1, ..., a4}
int mn = v_reduce_min(a); // mn = min(a1, ..., an)
int sum = v_reduce_sum(a); // sum = a1 + ... + an
- 掩码操作: 掩码操作允许我们在宽寄存器中复制条件。这些操作包括
v_check_all() - 返回一个 bool 值,如果寄存器中的所有值都小于零,则该值为真。
v_check_any() - 如果寄存器中的任何值小于零,则返回一个 bool 值。
v_select() - 返回一个寄存器,根据掩码混合两个寄存器。
v_uint8 a; // {a1, ..., an}
v_uint8 b; // {b1, ..., bn}
v_int32x4 mask: // {0xff, 0, 0, 0xff, ..., 0xff, 0}
v_uint8 Res = v_select(mask, a, b) // {a1, b2, b3, a4, ..., an-1, bn}
/*
如果掩码为 true(所有位都置 1),"Res "将包含 "a "的值、
如果掩码为假(所有位都设置为 0),"Res "将包含 "a "的值,以及 "b "的值。
我们可以使用比较运算符生成掩码,使用 v_select 获得基于条件的结果。
因此,v_select 将根据掩码给出 "a "或 0 的值。
*/
演示
在下面的章节中,我们将矢量化一个简单的单通道卷积函数,并将结果与标量实现进行比较。
注意事项
并非所有算法都能通过手动矢量化得到改进。事实上,在某些情况下,编译器可能会自动矢量化代码,从而使标量实现的结果更快。
你可以从前面的教程中了解更多关于卷积的知识。我们使用与上一教程相同的天真实现,并将其与矢量化版本进行比较。
完整的教程代码在这里。
矢量化卷积
我们将首先实现一维卷积,然后将其矢量化。二维矢量化卷积将跨行执行一维卷积,以产生正确的结果。
一维卷积: 标量
void conv1d(Mat src, Mat &dst, Mat kernel)
{
int len = src.cols;
dst = Mat(1, len, CV_8UC1);
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<uchar>(0)[i + k + sz] * kernel.ptr<float>(0)[k + sz];
dst.ptr<uchar>(0)[i] = saturate_cast<uchar>(value);
}
}
- 我们首先设置变量,并在 src 矩阵两侧设置边框,以处理边缘情况。
int len = src.cols;
dst = Mat(1, len, CV_8UC1);
int sz = kernel.cols / 2;
copyMakeBorder(src, src, 0, 0, sz, sz, BORDER_REPLICATE);
- 在主循环中,我们选择一个索引 i,并使用 k 变量将其与内核一起向两边偏移。我们将值存储在 value 中,并将其添加到 dst 矩阵中。
for (int i = 0; i < len; i++)
{
double value = 0;
for (int k = -sz; k <= sz; k++)
value += src.ptr<uchar>(0)[i + k + sz] * kernel.ptr<float>(0)[k + sz];
dst.ptr<uchar>(0)[i] = saturate_cast<uchar>(value);
}
一维卷积: 向量
现在我们来看看一维卷积的矢量化版本。
void conv1dsimd(Mat src, 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 int alpha = 1;
src.convertTo(src_32, CV_32FC1, alpha);
int ksize = kernel.cols, sz = kernel.cols / 2;
copyMakeBorder(src_32, src_32, 0, 0, sz, sz, BORDER_REPLICATE);
int step = v_float32().nlanes;
float *sptr = src_32.ptr<float>(row), *kptr = kernel.ptr<float>(rowk);
for (int k = 0; k < ksize; k++)
{
v_float32 kernel_wide = vx_setall_f32(kptr[k]);
int i;
for (i = 0; i + step < len; i += step)
{
v_float32 window = vx_load(sptr + i + k);
v_float32 sum = vx_load(ans + i) + kernel_wide * window;
v_store(ans + i, sum);
}
for (; i < len; i++)
{
*(ans + i) += sptr[i + k]*kptr[k];
}
}
}
- 在我们的例子中,内核是一个浮点数。由于内核的数据类型最大,我们将 src 转换为 float32,形成 src_32。同时,我们也要像天真的情况一样,制作一个边框。
Mat src_32, kernel_32;
const int alpha = 1;
src.convertTo(src_32, CV_32FC1, alpha);
int ksize = kernel.cols, sz = kernel.cols / 2;
copyMakeBorder(src_32, src_32, 0, 0, sz, sz, BORDER_REPLICATE);
- 现在,对于内核中的每一列,我们都要计算该值与所有长度为步长的窗口向量的标量乘积。我们将这些值与已存储在 ans 中的值相加
int step = v_float32().nlanes;
float *sptr = src_32.ptr<float>(row), *kptr = kernel.ptr<float>(rowk);
for (int k = 0; k < ksize; k++)
{
v_float32 kernel_wide = vx_setall_f32(kptr[k]);
int i;
for (i = 0; i + step < len; i += step)
{
v_float32 window = vx_load(sptr + i + k);
v_float32 sum = vx_load(ans + i) + kernel_wide * window;
v_store(ans + i, sum);
}
for (; i < len; i++)
{
*(ans + i) += sptr[i + k]*kptr[k];
}
}
- 我们声明一个指向 src_32 和内核的指针,并为每个内核元素运行一个循环
int step = v_float32().nlanes;
float *sptr = src_32.ptr<float>(row), *kptr = kernel.ptr<float>(rowk);
for (int k = 0; k < ksize; k++)
{
- 我们用当前内核元素加载寄存器。将一个窗口从 0 移到 len - step,并将其与 kernel_wide 数组的乘积加到存储在 ans 中的值上。我们将数值存回 ans
v_float32 kernel_wide = vx_setall_f32(kptr[k]);
int i;
for (i = 0; i + step < len; i += step)
{
v_float32 window = vx_load(sptr + i + k);
v_float32 sum = vx_load(ans + i) + kernel_wide * window;
v_store(ans + i, sum);
}
- 由于长度可能无法被步数整除,因此我们直接处理剩余的值。尾值的数量总是小于步长,不会对性能造成很大影响。我们将所有值存储到一个浮点指针 ans 中。我们也可以直接将它们存储到 Mat 对象中
for (; i < len; i++)
{
*(ans + i) += sptr[i + k]*kptr[k];
}
- 下面是一个迭代示例:
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|
注
函数参数还包括 row、rowk 和 len。这些值用于将函数用作二维卷积的中间步骤
二维卷积
假设我们的核有 k 个大小的行。要计算某一行的值,我们需要计算前 ksize/2 行和后 ksize/2 行与相应内核行的一维卷积。最终值就是各个 1-D 卷积之和
void convolute_simd(Mat src, Mat &dst, Mat kernel)
{
int rows = src.rows, cols = src.cols;
int ksize = kernel.rows, sz = ksize / 2;
dst = Mat(rows, cols, CV_32FC1);
copyMakeBorder(src, src, sz, sz, 0, 0, BORDER_REPLICATE);
int step = v_float32().nlanes;
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)
{
v_float32 sum = vx_load(&dst.ptr<float>(i)[j]) + vx_load(&ans[j]);
v_store(&dst.ptr<float>(i)[j], sum);
}
for (; j < cols; j++)
dst.ptr<float>(i)[j] += ans[j];
}
}
const int alpha = 1;
dst.convertTo(dst, CV_8UC1, alpha);
}
- 我们首先对变量进行初始化,并在 src 矩阵的上方和下方创建一个边框。左右两边由 1-D 卷积函数处理。
int rows = src.rows, cols = src.cols;
int ksize = kernel.rows, sz = ksize / 2;
dst = Mat(rows, cols, CV_32FC1);
copyMakeBorder(src, src, sz, sz, 0, 0, BORDER_REPLICATE);
int step = v_float32().nlanes;
- 对于每一行,我们计算其上下两行的 1-D 卷积,然后将这些值添加到 dst 矩阵中。
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)
{
v_float32 sum = vx_load(&dst.ptr<float>(i)[j]) + vx_load(&ans[j]);
v_store(&dst.ptr<float>(i)[j], sum);
}
for (; j < cols; j++)
dst.ptr<float>(i)[j] += ans[j];
}
}
- 最后,我们将 dst 矩阵转换为 8 位无符号 char 矩阵
const int alpha = 1;
dst.convertTo(dst, CV_8UC1, alpha);
结果
在教程中,我们使用了水平梯度核。两种方法得到的输出图像相同。
运行时间的改进各不相同,取决于 CPU 的 SIMD 能力。