OpenCV 4.11.0
开源计算机视觉
加载中…
搜索中…
无匹配项
使用通用内联函数向量化您的代码

上一教程: 如何使用 OpenCV parallel_for_ 并行化您的代码

兼容性OpenCV >= 3.0

目标

本教程的目标是指导您如何使用 通用内联函数 功能来向量化您的 C++ 代码,以加快运行速度。我们将简要介绍SIMD 内联函数以及如何使用宽寄存器,然后讲解使用宽寄存器的基本操作教程。

理论

在本节中,我们将简要介绍一些概念,以帮助您更好地理解该功能。

内联函数

内联函数是由编译器单独处理的函数。这些函数通常经过优化,以尽可能高效的方式执行,因此比普通实现运行得更快。但是,由于这些函数依赖于编译器,因此编写可移植应用程序变得困难。

SIMD

SIMD 代表单指令多数据。SIMD 内联函数允许处理器向量化计算。数据存储在所谓的寄存器中。寄存器的宽度可能是128 位256 位512 位。每个寄存器存储多个相同数据类型。寄存器的尺寸和每个值的尺寸决定了总共存储的值的数量。

根据您的 CPU 支持的指令集,您可以使用不同的寄存器。要了解更多信息,请查看 此处

通用内联函数

OpenCV 的通用内联函数为 SIMD 向量化方法提供了一种抽象,允许用户使用内联函数而无需编写特定于系统的代码。

OpenCV 通用内联函数支持以下指令集

  • 128 位寄存器的各种类型支持已针对各种架构实现,包括
    • x86 (SSE/SSE2/SSE4.2),
    • ARM (NEON),
    • PowerPC (VSX),
    • MIPS (MSA).
  • 256 位寄存器在 x86 (AVX2) 上受支持,并且
  • 512 位寄存器在 x86 (AVX512) 上受支持

我们现在将介绍可用的结构和函数

  • 寄存器结构
  • 加载和存储
  • 数学运算
  • 归约和掩码

寄存器结构

通用内联函数集将每个寄存器实现为基于特定 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 将存储 32 个 8 位无符号整数
    • v_float64 将存储 4 个 64 位浮点数(双精度浮点数)
        v_uint8 a;                            // a is a register supporting uint8(char) data
        int n = a.nlanes;                     // n holds 32
      

    可用数据类型和大小

    类型大小(位)
    uint8, 16, 32, 64
    int8, 16, 32, 64
    float32, 64
  • 固定大小寄存器:这些结构具有固定的位大小,并容纳固定数量的值。我们需要知道系统支持什么 SIMD 指令集,并选择兼容的寄存器。只有在需要确切位长时才使用这些寄存器。

    每个结构都遵循以下约定

    v_[type of value][size of each value in bits]x[number of values]
    

    假设我们想要存储

    • 32 位(位大小)有符号整数到128 位寄存器中。由于寄存器大小已知,我们可以找出寄存器中的数据点数128/32 = 4
        v_int32x8 reg1                       // holds 8 32-bit signed integers.
      
    • 64 位浮点数到 512 位寄存器中
        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 方法并提供数据的内存地址

        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]
      
      注意
      加载函数假设数据未对齐。如果您的数据已对齐,您可以使用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.
      

      注意
      确保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 值。相反,对于真值,所有位都转换为一(8 位为 0xff,16 位为 0xffff 等),而假值返回转换为零的位。
      // 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_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() - 返回一个布尔值,如果寄存器中的所有值都小于零,则为真。
    • 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)
      
            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.
        */
      

演示

在下一节中,我们将为单通道向量化一个简单的卷积函数,并将结果与标量实现进行比较。

注意
并非所有算法都能通过手动向量化得到改进。事实上,在某些情况下,编译器可能会自动向量化代码,从而为标量实现产生更快的结果。

您可以从之前的教程中了解更多关于卷积的知识。我们使用之前的教程中相同的朴素实现,并将其与向量化版本进行比较。

完整的教程代码在此处

卷积向量化

我们将首先实现一维卷积,然后将其向量化。二维向量化卷积将跨行执行一维卷积以产生正确的结果。

一维卷积:标量

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);
}
}
  1. 我们首先设置变量,并在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);
  2. 对于主循环,我们选择一个索引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 = VTraits<v_float32x4>::vlanes();
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 = v_add(vx_load(ans + i), v_mul(kernel_wide, window));
v_store(ans + i, sum);
}
for (; i < len; i++)
{
*(ans + i) += sptr[i + k]*kptr[k];
}
}
}
  1. 在我们的例子中,内核是浮点数。由于内核的数据类型最大,我们将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);
  2. 现在,对于kernel中的每一列,我们计算该值与长度为step的所有window向量的标量积。我们将这些值添加到已存储在ans中的值。
    int step = VTraits<v_float32x4>::vlanes();
    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 = v_add(vx_load(ans + i), v_mul(kernel_wide, window));
    v_store(ans + i, sum);
    }
    for (; i < len; i++)
    {
    *(ans + i) += sptr[i + k]*kptr[k];
    }
    }
    • 我们声明指向src_32和kernel的指针,并为每个内核元素运行一个循环。
      int step = VTraits<v_float32x4>::vlanes();
      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 = v_add(vx_load(ans + i), v_mul(kernel_wide, window));
      v_store(ans + i, sum);
      }
    • 由于长度可能不能被步长整除,因此我们直接处理剩余的值。尾部值的数目总是小于step,并且不会显著影响性能。我们将所有值存储到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|
      
注意
函数参数还包括rowrowklen。在将函数用作二维卷积的中间步骤时,会用到这些值。

二维卷积

假设我们的卷积核有ksize行。为了计算特定行的值,我们计算前ksize/2行和后ksize/2行与相应卷积核行的1D卷积。最终值只是各个1D卷积的总和。

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 = VTraits<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)
{
v_float32 sum = v_add(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);
}
  1. 我们首先初始化变量,并在src矩阵的上下创建边界。左右两侧由一维卷积函数处理。
    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 = VTraits<v_float32x4>::vlanes();
  2. 对于每一行,我们计算其上下一行的1D卷积。然后我们将这些值添加到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 = v_add(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];
    }
    }
  3. 最后,我们将dst矩阵转换为一个8位无符号字符矩阵。
    const int alpha = 1;
    dst.convertTo(dst, CV_8UC1, alpha);

结果

在本教程中,我们使用了水平梯度核。两种方法得到相同的输出图像。

运行时间的改进差异很大,取决于你的CPU中可用的SIMD功能。