OpenCV 4.12.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)。
  • x86(AVX2)支援256位暫存器,
  • x86(AVX512)支援512位暫存器

現在我們將介紹可用的結構和函式

  • 暫存器結構
  • 載入和儲存
  • 數學運算
  • 規約與掩碼

暫存器結構

通用內在函式集將每個暫存器實現為基於特定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
      

    可用資料型別和大小

    Type位大小
    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.
      
    • 512位暫存器中的64位浮點數
        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);
      


    • 載入函式 - 我們可以使用載入方法並提供資料的記憶體地址

        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}
    


  • 比較運算子:我們可以使用<、>、<=、>=、==和!=運算子比較兩個暫存器之間的值。由於每個暫存器包含多個值,這些操作不會返回單個布林值。相反,對於真值,所有位都被轉換為1(8位為0xff,16位為0xffff等),而假值返回轉換為0的位。
      // 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. 現在,對於中的每一列,我們計算該值與所有長度為step視窗向量的標量積。我們將這些值新增到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++)
      {
    • 我們用當前核元素載入一個暫存器。一個視窗從0len - 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整除,我們直接處理剩餘的值。尾部值的數量總是小於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行與相應核行的1-D卷積。最終值就是各個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 = 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. 對於每一行,我們計算其上方和下方行的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 = 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位unsigned char矩陣
    const int alpha = 1;
    dst.convertTo(dst, CV_8UC1, alpha);

結果

在本教程中,我們使用了水平梯度核。兩種方法都獲得了相同的輸出影像。

執行時間的改進效果各不相同,將取決於您的CPU中可用的SIMD功能。