OpenCV 5.0.0
Open Source Computer Vision
読み込み中...
検索中...
見つかりません
🤖 AIによる機械翻訳(非公式) — これは OpenCV 5.0.0 公式リファレンス(英語)を AI (Claude) で自動翻訳したものです。訳に誤りを含む場合があります。正確な情報は 公式英語版(原文) を参照してください。
Universal Intrinsicsによるコードのベクトル化

前のチュートリアル: OpenCV の parallel_for_ 関数を使ってコードを並列化する方法(畳み込みの例)

互換性OpenCV >= 4.11

目標

このチュートリアルの目的は、汎用イントリンシクス 機能を使ってC++コードをベクトル化し、実行を高速化するためのガイドを提供することである。まず SIMDイントリンシック とワイドな レジスタ の扱い方を簡単に見てから、ワイドレジスタを使った基本的な演算のチュートリアルへと進む。

理論

この節では、機能をより理解しやすくするために、いくつかの概念を簡単に見ていく。

Intrinsics

イントリンシックは、コンパイラによって個別に扱われる関数である。これらの関数は可能な限り効率的に動作するよう最適化されていることが多く、そのため通常の実装より高速に動作する。しかし、これらの関数はコンパイラに依存するため、移植性のあるアプリケーションを書くことが難しくなる。

SIMD

SIMDは Single Instruction, Multiple Data の略である。SIMDイントリンシックは、プロセッサが計算をベクトル化することを可能にする。データは レジスタ と呼ばれるものに格納される。レジスタ128ビット256ビット、あるいは 512ビット 幅をもつ。各 レジスタ同じデータ型複数の値 を格納する。レジスタのサイズと各値のサイズによって、格納される値の総数が決まる。

CPUがどの 命令セット をサポートしているかに応じて、異なるレジスタを使用できる場合がある。詳しくは こちら を参照

VLA

VLAは Vector Length Agnostic の略である。レジスタ幅をコンパイル時に固定するのではなく、実行時にハードウェアによって決定する仕組みである。これにより、単一のバイナリが同一アーキテクチャ内の異なるCPU間でその性能をスケールできる(例: RVVやSVE)。

Universal Intrinsics

OpenCVのuniversal intrinsicsは、SIMDおよびVLAのベクトル化手法に対する抽象化を提供し、システム固有のコードを書く必要なくイントリンシックを利用できるようにする。サポートされるSIMD/VLA技術については 汎用イントリンシクス に詳しく記載されている。

ここで、利用可能な構造体と関数を紹介する:

  • レジスタ構造体
  • ロードとストア
  • 数学的演算
  • リダクションとマスク

レジスタ構造体

Universal Intrinsicsのセットは、各レジスタを特定のSIMDレジスタに基づく構造体として実装している。すべての型は nlanes 列挙を含んでおり、これはその型が保持できる値の正確な個数を与える。これにより、実装中に値の個数をハードコードする必要がなくなる。

覚え書き
各レジスタ構造体は cv 名前空間の下にある。

レジスタには 2種類 ある:

  • 可変サイズレジスタ: これらの構造体は固定サイズをもたず、その正確なビット長は利用可能な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ビット浮動小数点数(double)を保持する
       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. 
    • 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); 


    • ロード関数 - 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]
      
      覚え書き
      load関数はデータがアラインされていないものとして扱う。データがアラインされている場合は、vx_load_aligned() 関数を使ってよい。
  • Store: Store functions allow you to store the values from a register into a particular memory location.
    • To store values from a register into a memory location, you may use the v_store() function:
        float ptr[4];
        v_store(ptr, reg); // store the first 128 bits(interpreted as 4x32-bit floats) of reg into ptr.
      

      覚え書き
      ptr がレジスタと同じ型であることを確認すること。演算を行う前にレジスタを適切な型へキャストすることもできる。ポインタを単に特定の型へキャストするだけでは、データの誤った解釈につながる。

二項演算子および単項演算子

universal intrinsicsのセットは、要素ごとの二項演算と単項演算を提供する。

覚え書き
OpenCV 4.11以降、Universal IntrinsicsにおけるC++の演算子オーバーロード(例: +, *)は非推奨となり、VLAアーキテクチャとの互換性を確保するために明示的なラッパー関数(例: v_add, v_mul)が推奨される。次も参照: https://github.com/opencv/opencv/issues/27267
  • 算術演算: 2つのレジスタを要素ごとに加算、減算、乗算、除算できる。レジスタは同じ幅で同じ型を保持していなければならない。例えば、2つのレジスタを乗算するには:
     v_float32 a, b; // {a1, ..., an}, {b1, ..., bn} v_float32 c = v_add(a, b); // {a1 + b1, ..., an + bn} v_flaot32 d = v_mul(a, b); // {a1 * b1, ..., an * bn} 


  • ビット論理演算とシフト: レジスタの各要素のビットを左シフトまたは右シフトできる。また、2つのレジスタ間で要素ごとにビット単位のand、or、xor、not演算子を適用できる:
     v_int32 as; // {a1, ..., an} v_int32 al = v_shl(as, 2); // {a1 << 2, ..., an << 2} v_int32 bl = v_shr(as, 2); // {a1 >> 2, ..., an >> 2} v_int32 a, b; v_int32 a_and_b = v_and(a, b); // {a1 & b1, ..., an & bn} 


  • 比較演算子: v_lt(<)、v_gt(>)、v_le(<=)、v_ge(>=)、v_eq(==)、v_ne(!=) を使って2つのレジスタ間の値を比較できる。各レジスタは複数の値を含むため、これらの演算からは単一のboolは得られない。代わりに、真の値についてはすべてのビットが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, ..., 13, 14, 15} v_uint8 b; // b = {15, 14, 13, ..., 2, 1, 0} v_uint8 c = v_lt(a, b); // c = {255, 255, 255, ..., 0, 0, 0} /* 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 = v_lt(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. */ 

  • Min/Max演算: v_min() および v_max() 関数を使うと、2つのレジスタの要素ごとの最小値または最大値を含むレジスタを返すことができる:
     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)} 

覚え書き
比較演算子とMin/Max演算子は64ビット整数では利用できない。ビットシフトと論理演算子は整数値に対してのみ利用可能である。ビットシフトは16、32、64ビットのレジスタでのみ利用可能である。

Reduce と Mask

  • リダクション演算: 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() - boolを返す。レジスタ内のすべての値が0未満であれば真となる。
    • v_check_any() - boolを返す。レジスタ内のいずれかの値が0未満であれば真となる。
    • v_select() - マスクに基づいて2つのレジスタをブレンドしたレジスタを返す。
       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. */ 

デモンストレーション

以下の節では、シングルチャンネルに対する単純な畳み込み関数をベクトル化し、その結果をスカラー実装と比較する。

覚え書き
すべてのアルゴリズムが手動のベクトル化によって改善されるわけではない。実際、特定のケースでは、コンパイラがコードを 自動ベクトル化 し、その結果スカラー実装の方が高速な結果を生み出すこともある。

畳み込みについては前のチュートリアルでさらに学べる。前のチュートリアルと同じ素朴な実装を用い、それをベクトル化版と比較する。

チュートリアルの完全なコードはここにある。

畳み込みのベクトル化

まず1次元畳み込みを実装し、それをベクトル化する。2次元のベクトル化畳み込みは、正しい結果を生成するために行方向に1次元畳み込みを実行する。

1 次元畳み込み:スカラー

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

1 次元畳み込み:ベクトル

ここで、1次元畳み込みのベクトル化版を見ていく。

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. ここではカーネルがfloatである。カーネルのデータ型が最も大きいため、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. Now, for each column in the kernel, we calculate the scalar product of the value with all window vectors of length step. We add these values to the already stored values in 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 未満であり、性能に大きく影響することはない。すべての値をfloatポインタである 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 も含まれる。これらの値は、この関数を2次元畳み込みの中間ステップとして使用する際に用いられる

2 次元畳み込み

カーネルが ksize 行をもつとする。特定の行の値を計算するには、前の ksize/2 行と次の ksize/2 行について、対応するカーネル行との1次元畳み込みを計算する。最終的な値は、個々の1次元畳み込みの単純な合計である

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 行列の上下に境界を作る。左右の側は1次元畳み込み関数によって処理される。
    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次元畳み込みを計算する。そしてその値を 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機能に依存する。