本教程的目标是提供使用通用内部函数功能矢量化 C++ 代码以提高运行时速度的指南。我们将简要介绍 SIMD 内部函数以及如何使用宽寄存器,然后介绍使用宽寄存器的基本操作。
在本节中,我们将简要介绍一些概念,以帮助更好地理解该功能。
内部函数是由编译器单独处理的函数。这些函数通常经过优化,以最有效的方式执行,因此运行速度比正常实现更快。但是,由于这些函数依赖于编译器,因此很难编写可移植的应用程序。
SIMD 代表 单指令,多数据。SIMD 内部函数允许处理器对计算进行矢量化。数据存储在所谓的寄存器中。寄存器可以是 128 位、256 位或 512 位宽。每个寄存器存储相同数据类型的多个值。寄存器的大小和每个值的大小决定了总共存储的值的数量。
根据 CPU 支持的指令集,您可以使用不同的寄存器。要了解更多信息,请看这里
OpenCV 的通用内部函数提供了对 SIMD 矢量化方法的抽象,并允许用户使用内部函数,而无需编写特定于系统的代码。
OpenCV Universal Intrinsics 支持以下指令集:
现在,我们将介绍可用的结构和功能:
通用内部函数集将每个寄存器实现为基于特定 SIMD 寄存器的结构。所有类型都包含枚举,该枚举给出类型可以容纳的值的确切数量。这样就无需在实现过程中对值的数量进行硬编码。nlanes
注意
每个寄存器结构都在命名空间下。cv
有两种类型的寄存器:
可变大小的寄存器:这些结构没有固定的大小,它们的确切位长度是在编译过程中根据可用的 SIMD 功能推断出来的。因此,枚举的值是在编译时确定的。nlanes
每个结构都遵循以下约定:
v_[type of value][size of each value in bits]
例如,v_uint8 保存 8 位无符号整数,v_float32保存 32 位浮点值。然后我们声明一个寄存器,就像我们在 C++ 中声明任何对象一样
根据可用的 SIMD 指令集,特定寄存器将保存不同数量的值。例如:如果您的计算机支持最大 256 位寄存器,
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 |
浮 | 32, 64 |
恒定大小的寄存器:这些结构具有固定的位大小并保存恒定数量的值。我们需要知道系统支持哪些 SIMD 指令集,并选择兼容的寄存器。仅当需要确切的位长度时才使用它们。
每个结构都遵循约定:
v_[type of value][size of each value in bits]x[number of values]
假设我们要存储
v_int32x8 reg1 // holds 8 32-bit signed integers.
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 //
int x = v_float32().nlanes; // set x as the number of values the register can hold
v_float32 reg1(ptr); // reg1 stores first x values according to the maximum register size available.
v_float32 reg2(ptr + x); // reg stores the next x values
// Constant Sized Registers //
v_float32x4 reg1(ptr); // reg1 stores the first 4 floats (1, 2, 3, 4)
v_float32x4 reg2(ptr + 4); // reg2 stores the next 4 floats (5, 6, 7, 8)
// Or we can explicitly write down the values.
v_float32x4(1, 2, 3, 4);
Load 函数 - 我们可以使用 load 方法并提供数据的内存地址:
float ptr[32] = {1, 2, 3, ..., 32};
v_float32 reg_var;
reg_var = vx_load(ptr); // loads values from ptr[0] upto ptr[reg_var.nlanes - 1]
v_float32x4 reg_128;
reg_128 = v_load(ptr); // loads values from ptr[0] upto ptr[3]
v_float32x8 reg_256;
reg_256 = v256_load(ptr); // loads values from ptr[0] upto ptr[7]
v_float32x16 reg_512;
reg_512 = v512_load(ptr); // loads values from ptr[0] upto ptr[15]
注意
load 函数假定数据未对齐。如果您的数据是对齐的,则可以使用该函数。vx_load_aligned()
float ptr[4];
v_store(ptr, reg); // store the first 128 bits(interpreted as 4x32-bit floats) of reg into 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}
// let us consider the following code is run in a 128-bit register
v_uint8 a; // a = {0, 1, 2, ..., 15}
v_uint8 b; // b = {15, 14, 13, ..., 0}
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
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}
/*
The true values are 0xffffffff, which in signed 32-bit integer representation is equal to -1.
*/
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_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_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}
/*
"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(Mat src, Mat &dst, Mat 内核){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 值 = 0;for (int k = -sz; k <= sz; k++)值 += src.ptr(0)[i + k + sz] * kernel.ptr<float>(0)[k + sz]; dst.ptr(0)[i] = saturate_cast (值); }}
- 我们首先设置变量并在 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 值 = 0;for (int k = -sz; k <= sz; k++)值 += src.ptr(0)[i + k + sz] * kernel.ptr<float>(0)[k + sz]; dst.ptr(0)[i] = saturate_cast (值); }一维卷积:矢量
我们现在将看一维卷积的矢量化版本。
void conv1dsimd(Mat src, Mat 内核, float *ans, int row = 0, int rowk = 0, int len = -1){如果 (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 步骤 = VTraits::vlanes(); 浮点数 *sptr = src_32.ptr(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 + 步长 < len; i += 步长){v_float32窗口 = vx_load(sptr + i + k);v_float32和 = v_add(vx_load(ans + i), v_mul(kernel_wide, 窗口));v_store(ans + i,总和);}对于 (;我 {*(ans + i) += sptr[i + k]*kptr[k];}}}
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);
step
for (int k = 0; k < ksize; k++){v_float32 kernel_wide = vx_setall_f32(kptr[k]);int i;for (i = 0; i + 步长 < len; i += 步长){v_float32窗口 = vx_load(sptr + i + k);v_float32和 = v_add(vx_load(ans + i), v_mul(kernel_wide, 窗口));v_store(ans + i,总和);}对于 (;我 {*(ans + i) += sptr[i + k]*kptr[k];}}
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|
注意
函数参数还包括 row、rowk 和 len。当将函数用作 2-D 卷积的中间步骤时,会使用这些值
假设我们的内核有 ksize 行。为了计算特定行的值,我们计算前一行 ksize/2 和下一行 ksize/2 的一维卷积,以及相应的内核行。最终值只是单个一维卷积的总和
void convolute_simd(Mat src, Mat &dst, Mat 内核){int rows = src.rows, cols = src.cols;int ksize = kernel.rows, sz = ksize / 2;dst = Mat(行、列、CV_32FC1);copyMakeBorder(src, src, sz, sz, 0, 0, BORDER_REPLICATE);int 步骤 = VTraits::vlanes(); for (int i = 0; i <行; i++){for (int k = 0; k < ksize; k++){浮点数 ans[N] = {0};conv1dsimd(src, 内核, ans, i + k, k, cols);国际J;for (j = 0; j + 步长 < 列; j += 步长){v_float32 总和 = v_add(vx_load(&dst.ptr<float>(i)[j]), vx_load(&ans[j]));v_store(&dst.ptr<浮点数>(i)[j], 总和);}对于 (;J < cols;j++)dst.ptr<浮点数>(i)[j] += ans[j];}}const int alpha = 1;dst.convertTo(dst, CV_8UC1, alpha);}
unsigned char
第一阶段:零基础入门(3-6个月)
新手应首先通过少而精的学习,看到全景图,建立大局观。 通过完成小实验,建立信心,才能避免“从入门到放弃”的尴尬。因此,第一阶段只推荐4本最必要的书(而且这些书到了第二、三阶段也能继续用),入门以后,在后续学习中再“哪里不会补哪里”即可。
第二阶段:基础进阶(3-6个月)
熟读《机器学习算法的数学解析与Python实现》并动手实践后,你已经对机器学习有了基本的了解,不再是小白了。这时可以开始触类旁通,学习热门技术,加强实践水平。在深入学习的同时,也可以探索自己感兴趣的方向,为求职面试打好基础。
第三阶段:工作应用
这一阶段你已经不再需要引导,只需要一些推荐书目。如果你从入门时就确认了未来的工作方向,可以在第二阶段就提前阅读相关入门书籍(对应“商业落地五大方向”中的前两本),然后再“哪里不会补哪里”。
有需要的小伙伴,可以点击下方链接免费领取或者V扫描下方二维码免费领取
有需要的小伙伴,可以点击下方链接免费领取或者V扫描下方二维码免费领取