核心功能:使用通用内部函数将代码矢量化 OpenCV v4.8.0

上一个教程如何使用 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 个整数

可用数据类型和大小:

类型大小(位)
uint8、16、32、64
int8、16、32、64
float32, 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_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 / 2copyMakeBorder(src, src, 0, 0, sz, sz, BORDER_REPLICATE)for (int i = 0; i < len; i++)
 {
 double value = 0for (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 / 2copyMakeBorder(src, src, 0, 0, sz, sz, BORDER_REPLICATE)
  1. 在主循环中,我们选择一个索引 i,并使用 k 变量将其与内核一起向两边偏移。我们将值存储在 value 中,并将其添加到 dst 矩阵中。
for (int i = 0; i < len; i++)
 {
 double value = 0for (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 / 2copyMakeBorder(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]}
 }
}
  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 / 2copyMakeBorder(src_32, src_32, 0, 0, sz, sz, BORDER_REPLICATE)
  1. 现在,对于内核中的每一列,我们都要计算该值与所有长度为步长的窗口向量的标量乘积。我们将这些值与已存储在 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)}
  1. 我们首先对变量进行初始化,并在 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. 对于每一行,我们计算其上下两行的 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]}
 }
  1. 最后,我们将 dst 矩阵转换为 8 位无符号 char 矩阵
 const int alpha = 1;
 dst.convertTo(dst, CV_8UC1, alpha)

结果

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

运行时间的改进各不相同,取决于 CPU 的 SIMD 能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值