【opencv 450 core】使用统一向量指令(Universal Intrinsics)对代码进行矢量化

Vectorizing your code using Universal Intrinsics

使用 Universal Intrinsics 对代码进行矢量化

Goal

本教程的目标是提供使用通用内在函数功能对 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.

Theory

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

Intrinsics 内在的

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

SIMD

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-bits256-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

Universal Intrinsics

通用内在函数

OpenCVs 通用内在函数提供了对 SIMD 矢量化方法的抽象,并允许用户使用内在函数而无需编写系统特定代码。

OpenCV Universal Intrinsics 支持以下指令集:

  • 128 bit registers of various types support is implemented for a wide range of architectures including
    • x86(SSE/SSE2/SSE4.2),
    • ARM(NEON),
    • PowerPC(VSX),
    • MIPS(MSA).
  • 256 bit registers are supported on x86(AVX2) and
  • 512 bit registers are supported on x86(AVX512)

支持各种类型的 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

Register Structures

寄存器结构

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

Load and Store operations

加载和存储操作

现在我们知道了寄存器是如何工作的,让我们看看用于向这些寄存器填充值的函数。

  1. Load加载:加载函数允许您将值加载到寄存器中

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() 函数。

  1. Store: Store functions allow you to store the values from a register into a particular memory location.

存储:存储函数允许您将寄存器中的值存储到特定的内存位置。

要将寄存器中的值存储到内存位置,您可以使用 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 具有相同的类型。 您还可以在执行操作之前将寄存器转换为正确的类型。 简单地将指针类型转换为特定类型将导致对数据的错误解释。

Binary and Unary Operators

二元和一元运算符

通用内在函数集提供了元素明智的二元和一元运算。

  1. Arithmetics: We can add, subtract, multiply and divide two registers element-wise. The registers must be of the same width and hold the same type. To multiply two registers, for example:

算术:我们可以按元素对两个寄存器进行加法、减法、乘法和除法。 寄存器必须具有相同的宽度并保持相同的类型。 将两个寄存器相乘,例如:

  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}

  1. Bitwise Logic and Shifts: We can left shift or right shift the bits of each element of the register. We can also apply bitwise &, |, ^ and ~ operators between two registers element-wise:

按位逻辑和移位:我们可以左移或右移寄存器每个元素的位。 我们还可以在两个寄存器元素之间应用按位 &、|、^ 和 ~ 运算符:

  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. Comparison Operators: We can compare values between two registers using the <, >, <= , >=, == and != operators. Since each register contains multiple values, we don't get a single bool for these operations. Instead, for true values, all bits are converted to one (0xff for 8 bits, 0xffff for 16 bits, etc), while false values return bits converted to zero.

比较运算符:我们可以使用 <、>、<=、>=、== 和 != 运算符比较两个寄存器之间的值。 由于每个寄存器都包含多个值,因此我们不会为这些操作获得一个布尔值。 相反,对于真值,所有位都转换为 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。
  */

  1. Min/Max operations: We can use the v_min() and v_max() functions to return registers containing element-wise min, or max, of the two registers:

最小/最大操作:我们可以使用 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 and Mask

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 的值。*/

Demonstration

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

笔记

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

您可以从上一个教程中了解有关卷积的更多信息。 我们使用与上一教程相同的简单实现,并将其与矢量化版本进行比较。

完整的教程代码在这里。https://github.com/opencv/opencv/tree/4.x/samples/cpp/tutorial_code/univ_intrin/univ_intrin.cpp

Vectorizing Convolution

向量化卷积

我们将首先实现一维卷积,然后对其进行矢量化。 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); //防止颜色溢出

    }

}
  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);

  1. 对于主循环,我们选择索引 i 并使用 k 变量将其与内核一起在两侧偏移。 我们将值存储在 value 中并将其添加到 dst 矩阵中。
    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()

v_float32 simd512::vx_setall_f32

(

float 

v

)

inline

#include

  1. 在我们的例子中,内核kernel是一个浮点数。 由于内核的数据类型最大,我们将src转换为float32,形成src_32。 我们也像为naive 案例所做的那样制作边界。
    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);

  1. 现在,对于内核中的每一列,我们计算该值与所有长度步长的窗口向量的标量积。 我们将这些值添加到在 ans 中已经存储的值中
    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);

}
  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 = v_float32().nlanes;

  1. 对于每一行,我们计算它上面和下面的行的一维卷积。 然后我们将值添加到 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)//方式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];

        }

    }
  1. 我们最终将 dst 矩阵转换为 8 位无符号字符矩阵
    const int alpha = 1;

    dst.convertTo(dst, CV_8UC1, alpha);

Results

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

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 Intrinsicsicon-default.png?t=M276https://docs.opencv.org/4.5.5/d6/dd1/tutorial_univ_intrin.html

你可能感兴趣的:(opencv,c++,opencv,计算机视觉)