Vectorizing your code using Universal Intrinsics
使用 Universal Intrinsics 对代码进行矢量化
本教程的目标是提供使用通用内在函数功能对 C++ 代码进行矢量化以获得更快运行时的指南。 我们将简要介绍 SIMD 内在函数以及如何使用宽寄存器,然后是有关使用宽寄存器的基本操作的教程。
The goal of this tutorial is to provide a guide to using the Universal intrinsics feature to vectorize your C++ code for a faster runtime. We'll briefly look into SIMD intrinsics and how to work with wide registers, followed by a tutorial on the basic operations using wide registers.
在本节中,我们将简要介绍一些概念,以更好地帮助理解该功能。
内在函数是由编译器单独处理的函数。 这些功能通常经过优化以尽可能以最有效的方式执行,因此比正常实现运行得更快。 但是,由于这些函数依赖于编译器,因此很难编写可移植的应用程序。
SIMD 代表单指令多数据(Single Instruction, Multiple Data)。 SIMD Intrinsics 允许处理器矢量化计算。 数据存储在所谓的寄存器中。 寄存器可以是 128 位、256 位或 512 位宽。 每个寄存器存储多个相同数据类型的值。 寄存器的大小和每个值的大小决定了总共存储的值的数量。
SIMD stands for Single Instruction, Multiple Data. SIMD Intrinsics allow the processor to vectorize calculations. The data is stored in what are known as registers. A register may be 128-bits, 256-bits or 512-bits wide. Each register stores multiple values of the same data type. The size of the register and the size of each value determines the number of values stored in total.
根据您的 CPU 支持的指令集,您可以使用不同的寄存器。 要了解更多信息,请看这里
en.wikipedia.org
通用内在函数
OpenCVs 通用内在函数提供了对 SIMD 矢量化方法的抽象,并允许用户使用内在函数而无需编写系统特定代码。
OpenCV Universal Intrinsics 支持以下指令集:
支持各种类型的 128 位寄存器,适用于各种架构,包括
x86(SSE/SSE2/SSE4.2),
ARM(NEON霓虹灯),
PowerPC(VSX),
MIPS(MSA)。
x86(AVX2) 和支持 256 位寄存器
x86(AVX512) 支持 512 位寄存器
我们现在将介绍可用的结构和功能:
We will now introduce the available structures and functions:
寄存器结构
加载和存储
数学运算
减少和掩盖
Register structures
Load and store
Mathematical Operations
Reduce and Mask
寄存器结构
Universal Intrinsics 集将每个寄存器实现为基于特定 SIMD 寄存器的结构。 所有类型都包含 nlanes 枚举,它给出了该类型可以容纳的确切值的数量。 这消除了在实现期间对值的数量进行硬编码的需要。
笔记
每个寄存器结构都在 cv 命名空间下。
有two types 两种类型的寄存器:
Variable sized registers可变大小的寄存器:这些结构没有固定大小,它们的确切位长在编译期间根据可用的 SIMD 功能推导出来。 因此,nlanes 枚举的值是在编译时确定的。
每个结构都遵循以下约定:
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 位浮点数(双精度)
v_uint8 a; // a is a register supporting uint8(char) data
int n = a.nlanes; // n holds 32
可用的数据类型和大小:
Type |
Size in bits |
uint |
8, 16, 32, 64 |
int |
8, 16, 32, 64 |
float |
32, 64 |
Constant sized registers: 恒定大小的寄存器:这些结构具有固定的位大小并保存恒定数量的值。 我们需要知道系统支持哪些 SIMD 指令集并选择兼容的寄存器。 仅当需要精确的位长度时才使用这些。
每个结构都遵循约定:
v_[type of value][size of each value in bits]x[number of values]
假设我们要存储
32-bit(size in bits) signed integers in a 128 bit register ,128 位寄存器 中的 32 位(位大小)有符号整数。 由于已经知道寄存器大小,我们可以找出寄存器中的数据点数(128/32 = 4):
v_int32x8 reg1 // holds 8 32-bit signed integers.
64-bit floats in 512 bit register: 64 位浮点数在 512 位寄存器中:
v_float64x8 reg2 // reg2.nlanes = 8
加载和存储操作
现在我们知道了寄存器是如何工作的,让我们看看用于向这些寄存器填充值的函数。
Constructors构造函数 - 声明寄存器结构时,我们可以提供一个内存地址,寄存器将从该地址获取连续值,或者显式提供多个参数的值(显式多个参数仅适用于常量大小的寄存器):
float ptr[32] = {1, 2, 3 ..., 32}; // ptr is a pointer to a contiguous memory block of 32 floats ptr 是指向 32 个浮点数的连续内存块的指针
// Variable Sized Registers可变大小寄存器 //
int x = v_float32().nlanes; // set x as the number of values the register can hold将 x 设置为寄存器可以保存的值的数量
v_float32 reg1(ptr); // reg1 stores first x values according to the maximum register size available. reg1 根据可用的最大寄存器大小存储前 x 值。
v_float32 reg2(ptr + x); // reg stores the next x values reg 存储下一个 x 值
// 恒定大小的寄存器Constant Sized Registers //
v_float32x4 reg1(ptr); // reg1 存储前 4 个浮点数(1, 2, 3, 4)
v_float32x4 reg2(ptr + 4); // reg2 存储接下来的 4 个浮点数(5, 6, 7, 8)
// 或者我们可以明确地写下这些值。Or we can explicitly write down the values.
v_float32x4(1, 2, 3, 4);
Load Function
加载函数——我们可以使用加载方法并提供数据的内存地址:
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] 将值上载从 ptr[0]到 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]
笔记
加载函数假定数据未对齐。 如果您的数据是对齐的,您可以使用 vx_load_aligned() 函数。
存储:存储函数允许您将寄存器中的值存储到特定的内存位置。
要将寄存器中的值存储到内存位置,您可以使用 v_store() 函数
float ptr[4];
v_store(ptr, reg); // store the first 128 bits(interpreted as 4x32-bit floats) of reg into ptr. 将 reg 的前 128 位(解释为 4x32 位浮点数)存储到 ptr 中。
笔记
确保 ptr 与 register 具有相同的类型。 您还可以在执行操作之前将寄存器转换为正确的类型。 简单地将指针类型转换为特定类型将导致对数据的错误解释。
二元和一元运算符
通用内在函数集提供了元素明智的二元和一元运算。
算术:我们可以按元素对两个寄存器进行加法、减法、乘法和除法。 寄存器必须具有相同的宽度并保持相同的类型。 将两个寄存器相乘,例如:
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}
比较运算符:我们可以使用 <、>、<=、>=、== 和 != 运算符比较两个寄存器之间的值。 由于每个寄存器都包含多个值,因此我们不会为这些操作获得一个布尔值。 相反,对于真值,所有位都转换为 1(0xff 表示 8 位,0xffff 表示 16 位等),而假值则返回转换为零的位。
// let us consider the following code is run in a 128-bit register让我们考虑以下代码在 128 位寄存器中运行
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让我们看看二进制的前 4 个值
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. 如果我们存储 c 的值并将它们打印为整数,我们将得到 255 表示真值,0 表示假值。
*/
---
// In a computer supporting 256-bit registers在支持 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}
/*
The true values are 0xffffffff, which in signed 32-bit integer representation is equal to -1. 真正的值是 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 位寄存器。
Reduce Operations
: The v_reduce_min(), v_reduce_max() and v_reduce_sum() return a single value denoting the min, max or sum of the entire register:
减少操作: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
Mask Operations: Mask operations allow us to replicate conditionals in wide registers. These include
掩码操作:掩码操作允许我们在宽寄存器中复制条件。 这些包括
v_check_all() - 返回一个布尔值,如果寄存器中的所有值都小于零,则为真。
v_check_any() - 返回一个布尔值,如果寄存器中的任何值小于零,则为真。
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}
/*
"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)
如果掩码为真(所有位设置为 1),“Res”将包含“a”中的值,
如果掩码为假,则来自“b”的值(所有位设置为 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.
我们可以使用比较运算符生成掩码和 v_select 以根据条件获得结果。
通常将 b 的所有值设置为 0。因此,v_select 将根据掩码给出“a”或 0 的值。*/
在下一节中,我们将对单通道的简单卷积函数进行矢量化,并将结果与标量实现进行比较。
笔记
并非所有算法都通过手动矢量化得到改进。 事实上,在某些情况下,编译器可以自动向量化代码,从而为标量实现产生更快的结果。
您可以从上一个教程中了解有关卷积的更多信息。 我们使用与上一教程相同的简单实现,并将其与矢量化版本进行比较。
完整的教程代码在这里。https://github.com/opencv/opencv/tree/4.x/samples/cpp/tutorial_code/univ_intrin/univ_intrin.cpp
向量化卷积
我们将首先实现一维卷积,然后对其进行矢量化。 2-D 矢量化卷积将跨行执行 1-D 卷积以产生正确的结果。
1-D Convolution: Scalar 一维卷积:标量
void conv1d(Mat src, Mat &dst, eMat 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(0)[i + k + sz] * kernel.ptr(0)[k + sz];
dst.ptr(0)[i] = saturate_cast(value); //防止颜色溢出
}
}
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(0)[i + k + sz] * kernel.ptr(0)[k + sz];
dst.ptr(0)[i] = saturate_cast(value);
}
1-D Convolution: Vector 一维卷积:向量
我们现在来看看一维卷积的向量化版本。
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; //寄存器可以存储几个float32
float *sptr = src_32.ptr(row), *kptr = kernel.ptr(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);//图像的step个像素值加载到寄存器window
v_float32 sum = vx_load(ans + i) + kernel_wide * window;//
v_store(ans + i, sum);//sum寄存器的值赋值到ans+i处
}
for (; i < len; i++)
{
*(ans + i) += sptr[i + k]*kptr[k];
}
}
}
◆ vx_setall_f32()
|
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(row), *kptr = kernel.ptr(rowk);// 指向 src_32 和kernel的指针
for (int k = 0; k < ksize; k++)//遍历内核元素
{
v_float32 kernel_wide = vx_setall_f32(kptr[k]);//卷积核的第k个值
int i;
for (i = 0; i + step < len; i += step)//方式1:存储到浮点指针ans中
{
v_float32 window = vx_load(sptr + i + k);//第i次操作,第k个位置,像素的值 加载到寄存器window
v_float32 sum = vx_load(ans + i) + kernel_wide * window; //卷积计算
v_store(ans + i, sum); //从寄存器sum中的个值 存储到浮点指针ans的第i个位置
}
for (; i < len; i++)// 存储到矩阵ans中
{
*(ans + i) += sptr[i + k]*kptr[k];
}
}
我们声明一个指向 src_32 和kernel的指针,并为每个内核元素运行一个循环
int step = v_float32().nlanes;
float *sptr = src_32.ptr(row), *kptr = kernel.ptr(rowk);
for (int k = 0; k < ksize; k++)
{
我们用当前内核元素加载一个寄存器。 一个窗口从 0 移动到 len-step 并且它与 kernel_wide 数组的乘积被添加到存储在 ans 中的值中。 我们将值存储回 ans
v_float32 kernel_wide = vx_setall_f32(kptr[k]);//第k个内核值放入寄存器kernel_wide
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|
Note:
函数参数还包括 row、rowk 和 len。 这些值在将函数用作 2-D 卷积的中间步骤时使用
OpenCV: Vectorizing your code using Universal Intrinsics
2-D Convolution 二维卷积
假设我们的内核有 ksize 行。 为了计算特定行的值,我们计算前一个 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);//计算内核第k行在源图像第i行时对应的一维卷积
int j;
for (j = 0; j + step < cols; j += step)//遍历源图像所有列j
{
v_float32 sum = vx_load(&dst.ptr(i)[j]) + vx_load(&ans[j]);//
v_store(&dst.ptr(i)[j], sum);//
}
for (; j < cols; j++)
dst.ptr(i)[j] += ans[j];
}
}
const int alpha = 1;
dst.convertTo(dst, CV_8UC1, alpha);
}
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)//方式1
{ //第i行像素,第k行内核,所有列的卷积->dstptr(i)[j]->sum
v_float32 sum = vx_load(&dst.ptr(i)[j]) + vx_load(&ans[j]);//第i行像素内核第k行一维卷积第j列
v_store(&dst.ptr(i)[j], sum);
}
for (; j < cols; j++)//方式2
dst.ptr(i)[j] += ans[j];
}
}
const int alpha = 1;
dst.convertTo(dst, CV_8UC1, alpha);
在本教程中,我们使用了水平梯度内核。 我们为两种方法获得了相同的输出图像。
In the tutorial, we used a horizontal gradient kernel. We obtain the same output image for both methods.
Improvement in runtime varies and will depend on the SIMD capabilities available in your CPU.
运行时的改进因 CPU 中可用的 SIMD 功能而异。
OpenCV: Vectorizing your code using Universal Intrinsicshttps://docs.opencv.org/4.5.5/d6/dd1/tutorial_univ_intrin.html