18

リアルタイムの画像処理を行う iPhone アプリケーションに取り組んでいます。そのパイプラインの最も初期のステップの 1 つは、BGRA 画像をグレースケールに変換することです。いくつかの異なる方法を試しましたが、タイミング結果の違いは、私が想像していたよりもはるかに大きいものでした. 最初に C を使用してみました。B+2*G+R/4 を加算して明度への変換を概算します。

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

uchar *pOut = imByte.data;
for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
    unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
    pOut[0] = sumA / 4;
    unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
    pOut[1] = sumB / 4;
    unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
    pOut[2] = sumC / 4;
    unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
    pOut[3] = sumD / 4;
    pOut +=4;
}       
}

このコードは、352x288 の画像を変換するのに 55 ミリ秒かかります。次に、本質的に同じことを行うアセンブラーコードを見つけました

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

unsigned int *pOut = (unsigned int*) imByte.data;

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
  register unsigned int nBGRA1 asm("r4");
  register unsigned int nBGRA2 asm("r5");
  unsigned int nZero=0;
  unsigned int nSum1;
  unsigned int nSum2;
  unsigned int nPacked1;
  asm volatile(
           
               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #0]       \n"   // Load in two BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  \n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  \n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    \n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    \n"   // Add G again
               "mov %[nPacked1], %[nSum1], LSR #2 \n"    // Init packed word   
               "mov %[nSum2], %[nSum2], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #8 \n"   // Add to packed word                 

               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #8]       \n"   // Load in two more BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  \n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  \n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    \n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    \n"   // Add G again
               "mov %[nSum1], %[nSum1], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum1], LSL #16 \n"   // Add to packed word
               "mov %[nSum2], %[nSum2], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #24 \n"   // Add to packed word                 
              
               ///////////
               ////////////
               
               : [pIn]"+r" (pIn), 
         [nBGRA1]"+r"(nBGRA1),
         [nBGRA2]"+r"(nBGRA2),
         [nZero]"+r"(nZero),
         [nSum1]"+r"(nSum1),
         [nSum2]"+r"(nSum2),
         [nPacked1]"+r"(nPacked1)
               :
               : "cc"  );
  *pOut = nPacked1;
  pOut++;
 }
 }

この関数は、同じ画像を 12 ミリ秒で変換します。これは、ほぼ 5 倍の速さです。これまでアセンブラでプログラミングしたことはありませんでしたが、このような単純な操作では C よりもそれほど高速ではないだろうと想定していました。この成功に触発されて検索を続けたところ、NEON 変換の例が見つかりまし

void greyScaleNEON(uchar* output_data, uchar* input_data, int tot_pixels)
{
__asm__ volatile("lsr          %2, %2, #3      \n"
                 "# build the three constants: \n"
                 "mov         r4, #28          \n" // Blue channel multiplier
                 "mov         r5, #151         \n" // Green channel multiplier
                 "mov         r6, #77          \n" // Red channel multiplier
                 "vdup.8      d4, r4           \n"
                 "vdup.8      d5, r5           \n"
                 "vdup.8      d6, r6           \n"
                 "0:                           \n"
                 "# load 8 pixels:             \n"
                 "vld4.8      {d0-d3}, [%1]!   \n"
                 "# do the weight average:     \n"
                 "vmull.u8    q7, d0, d4       \n"
                 "vmlal.u8    q7, d1, d5       \n"
                 "vmlal.u8    q7, d2, d6       \n"
                 "# shift and store:           \n"
                 "vshrn.u16   d7, q7, #8       \n" // Divide q3 by 256 and store in the d7
                 "vst1.8      {d7}, [%0]!      \n"
                 "subs        %2, %2, #1       \n" // Decrement iteration count
                 "bne         0b            \n" // Repeat unil iteration count is not zero
                 :
                 :  "r"(output_data),           
                 "r"(input_data),           
                 "r"(tot_pixels)        
                 : "r4", "r5", "r6"
                 );
}

タイミングの結果は信じがたいものでした。同じ画像を 1 ミリ秒で変換します。アセンブラの 12 倍、C の 55 倍という驚異的な速さです。このようなパフォーマンスの向上が可能だとは思いもしませんでした。これを踏まえて、いくつか質問があります。まず、私は C コードで何かひどく間違ったことをしていますか? こんなに遅いなんて、いまだに信じられません。第二に、これらの結果がまったく正確である場合、どのような状況でこれらの改善が見られると期待できますか? パイプラインの他の部分を 55 倍速く実行できるようになるという見通しに、私がどれほど興奮しているか想像できるでしょう。アセンブラ/NEON を学習し、かなりの時間がかかるループ内でそれらを使用する必要がありますか?

更新 1: C 関数からのアセンブラー出力をテキスト ファイルに投稿しまし

タイミングは、OpenCV 関数を使用して行われます。

double duration = static_cast<double>(cv::getTickCount()); 
//function call 
duration = static_cast<double>(cv::getTickCount())-duration;
duration /= cv::getTickFrequency();
//duration should now be elapsed time in ms

結果

提案されたいくつかの改善点をテストしました。まず、Viktor の推奨に従って、すべてのフェッチを最初に配置するように内側のループを並べ替えました。内側のループは次のようになりました。

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{     
  //Jul 16, 2012 MR: Read and writes collected
  sumA = pIn[0] + 2 * pIn[1] + pIn[2];
  sumB = pIn[4] + 2 * pIn[5] + pIn[6];
  sumC = pIn[8] + 2 * pIn[9] + pIn[10];
  sumD = pIn[12] + 2 * pIn[13] + pIn[14];
  pOut +=4;
  pOut[0] = sumA / 4;
  pOut[1] = sumB / 4;
  pOut[2] = sumC / 4;
  pOut[3] = sumD / 4;
}

この変更により、処理時間が 53ms に短縮され、2ms 改善されました。次に、Victor の推奨に従って、関数を uint としてフェッチするように変更しました。内側のループは次のようになりました

unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
uchar* out = temp.data;

for(; in_int < end; in_int+=4)   // Does four pixels at a time
{
    unsigned int pixelA = in_int[0];
    unsigned int pixelB = in_int[1];
    unsigned int pixelC = in_int[2];
    unsigned int pixelD = in_int[3];
        
    uchar* byteA = (uchar*)&pixelA;
    uchar* byteB = (uchar*)&pixelB;
    uchar* byteC = (uchar*)&pixelC;
    uchar* byteD = (uchar*)&pixelD;         
        
    unsigned int sumA = byteA[0] + 2 * byteA[1] + byteA[2];
    unsigned int sumB = byteB[0] + 2 * byteB[1] + byteB[2];
    unsigned int sumC = byteC[0] + 2 * byteC[1] + byteC[2];
    unsigned int sumD = byteD[0] + 2 * byteD[1] + byteD[2];

    out[0] = sumA / 4;
    out[1] = sumB / 4;
    out[2] = sumC / 4;
    out[3] = sumD / 4;
    out +=4;
    }

この変更により劇的な効果があり、処理時間が 14 ミリ秒に短縮され、39 ミリ秒 (75%) 短縮されました。この最後の結果は、11ms のアセンブラ パフォーマンスに非常に近いものです。rob が推奨する最終的な最適化は、__restrict キーワードを含めることでした。次の行を変更するすべてのポインター宣言の前に追加しました

__restrict unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
__restrict uchar* out = temp.data;  
...
__restrict uchar* byteA = (uchar*)&pixelA;
__restrict uchar* byteB = (uchar*)&pixelB;
__restrict uchar* byteC = (uchar*)&pixelC;
__restrict uchar* byteD = (uchar*)&pixelD;  
...     

これらの変更は、処理時間に測定可能な影響を与えませんでした。お世話になりました。今後はメモリ管理に細心の注意を払います。

4

4 に答える 4

5

NEON の「成功」の理由のいくつかについて、ここに説明があります: http://hilbert-space.de/?p=22

「-S -O3」スイッチを使用して C コードをコンパイルして、GCC コンパイラの最適化された出力を確認してください。

私見ですが、成功の鍵は、両方のアセンブリ バージョンで採用されている最適化された読み取り/書き込みパターンです。また、NEON/MMX/その他のベクトル エンジンもサチュレーションをサポートしています (「unsigned ints」を使用せずに、結果を 0..255 にクランプします)。

ループ内の次の行を参照してください。

unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
pOut[1] = sumB / 4;
unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
pOut[2] = sumC / 4;
unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut[3] = sumD / 4;
pOut +=4;

読み取りと書き込みは本当に混在しています。ループのサイクルのわずかに優れたバージョンは次のようになります

// and the pIn reads can be combined into a single 4-byte fetch
sumA = pIn[0] + 2 * pIn[1] + pIn[2];
sumB = pIn[4] + 2 * pIn[5] + pIn[6];
sumC = pIn[8] + 2 * pIn[9] + pIn[10];
sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut +=4;
pOut[0] = sumA / 4;
pOut[1] = sumB / 4;
pOut[2] = sumC / 4;
pOut[3] = sumD / 4;

ここでの「unsigned in sumA」行は、実際には alloca() 呼び出し (スタック上の割り当て) を意味する可能性があるため、一時的な var 割り当て (関数呼び出し 4 回) で多くのサイクルを浪費していることに注意してください。

また、pIn[i] のインデックス作成は、メモリからの 1 バイトのフェッチのみを行います。これを行うより良い方法は、int を読み取ってから 1 バイトを抽出することです。処理を高速化するには、「unsgined int*」を使用して 4 バイト (pIn[i * 4 + 0]、pIn[i * 4 + 1]、pIn[i * 4 + 2]、pIn[i * 4 + 3])。

NEON バージョンは明らかに優れています。

             "# load 8 pixels:             \n"
             "vld4.8      {d0-d3}, [%1]!   \n"

             "#save everything in one shot   \n"
             "vst1.8      {d7}, [%0]!      \n"

メモリ アクセスの時間を大幅に節約できます。

于 2012-07-16T16:39:49.347 に答える
4

パフォーマンスが非常に重要な場合 (一般的にリアルタイムの画像処理と同様)、マシン コードに注意を払う必要があります。お気づきのように、ベクトル命令 (リアルタイムの画像処理などのために設計されています) を使用することが特に重要になる可能性があります。また、コンパイラがベクトル命令を自動的に効果的に使用することは困難です。

アセンブリにコミットする前に試してみるべきことは、コンパイラ組み込み関数を使用することです。コンパイラ組み込み関数は、アセンブリよりも移植性が高いわけではありませんが、読み書きが容易で、コンパイラが操作しやすいはずです。保守性の問題は別として、アセンブリのパフォーマンスの問題は、オプティマイザを効果的にオフにすることです (適切なコンパイラ フラグを使用してオプティマイザをオンにしましたよね?)。つまり、インライン アセンブリでは、コンパイラはレジスタの割り当てなどを微調整することができないため、内部ループ全体をアセンブリに記述しない場合でも、可能な限り効率的ではない可能性があります。

ただし、コンパイラによって生成されたアセンブリを検査し、それがばかげているかどうかを判断できるため、新たに発見したアセンブリの専門知識を有効に活用できます。その場合は、C コードを微調整し (コンパイラが管理していない場合は、おそらく手動でパイプライン処理を行う)、再コンパイルし、アセンブリ出力を見て、コンパイラが意図したとおりに動作しているかどうかを確認してから、ベンチマークを行うことができます。それが実際に速く実行されているかどうかを確認するには...

上記を試しても、コンパイラに正しいことをさせることができない場合は、先に進んで内部ループをアセンブリに記述してください (そして、結果が実際に高速かどうかをもう一度確認してください)。上記の理由により、ループ分岐を含む内側のループ全体を取得してください。

最後に、他の人が述べたように、「正しいこと」とは何かを考えてみてください。マシン アーキテクチャを学習することのもう 1 つの利点は、物事がどのように機能するかのメンタル モデルが得られることです。そのため、効率的なコードを組み立てる方法を理解する可能性が高くなります。

于 2012-07-16T17:29:00.037 に答える
3

Viktor Latypovの回答には多くの良い情報がありますが、もう1つ指摘したいのは、元のC関数では、コンパイラはそれを判断できず、メモリの重複しない領域を指すことですpInpOut次の行を見てください。

pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];

コンパイラは、 orまたは(またはその他の)pOut[0]と同じであると想定する必要があります。したがって、基本的にループ内のコードの順序を変更することはできません。pIn[4]pIn[5]pIn[6]pIn[x]

それらを宣言することで、重複しないことpInをコンパイラに伝えることができます。pOut__restrict

__restrict uchar *pIn = (uchar*) imBGRA.data;
__restrict uchar *pOut = imByte.data;

これにより、元の C バージョンが少し高速化される可能性があります。

于 2012-07-16T17:46:49.297 に答える
0

これは、パフォーマンスと保守性の間の一種のトスです。通常、アプリの読み込みと機能が迅速に行われることは、ユーザーにとって非常に便利ですが、トレードオフがあります。現在、アプリの保守はかなり困難であり、速度の向上は正当化されていない可能性があります。アプリのユーザーが速度が遅いと不満を漏らしている場合、これらの最適化は努力する価値があり、保守性が欠けていますが、アプリを高速化する必要があるためである場合は、最適化にここまで踏み込むべきではありません。アプリの起動時にこれらの画像変換を行う場合、速度は重要ではありませんが、アプリの実行中に常にそれらを実行している (そして多くのことを実行している) 場合は、より理にかなっています。ユーザーが時間を費やし、実際にスローダウンを経験するアプリの部分のみを最適化します。

また、アセンブリを見ると、除算ではなく乗算のみが使用されているため、C コードについて調べてください。もう 1 つの例は、乗算を 2 から 2 の加算に最適化することです。iPhone アプリケーションでは乗算が加算よりも遅くなる可能性があるため、これも別のトリックである可能性があります。

于 2012-07-16T16:14:43.313 に答える