Elbrusプラットフォームでの低レベルコードの最適化:組み込み関数を使用したuint16_tのベクトル追加





この記事では、Elbrusプロセッサで実行できる低レベルの最適化について説明します。



原則として、このレベルの最適化はElbrusの開発の必要な段階ではありません。 高いパフォーマンスを必要とするほとんどの計算操作では、EMLライブラリの関数を使用できますし、使用する必要があります。



ただし、現在のバージョンのEMLでは、興味のある関数が見つからなかったため、独自に作成することにしました。

このために、組み込み関数を使用しました。 組み込み関数は、プログラマにとっては通常の関数のように見える構造ですが、その呼び出しは、非常に効率的なコードを備えたインプレースコンパイラによって置き換えられます。 ほとんどの場合、複数のデータ要素を含むレジスタに対して同じ操作を一度に実行できるベクトルプロセッサ拡張機能を使用する場合、組み込み関数が必要です。 最適化コンパイラでさえ、そのような設計がコードを高速化することを常に推測できるわけではありません。 そのような場合、以前は、最適化された適切なライブラリがなかった場合、アセンブラを使用する必要がありました。 しかし、アセンブラーコードのパフォーマンスは、レジスターの使用、ALU遅延の考慮、およびその他の素晴らしいことに大きく依存します。 また、ElbrusにはVLIWアーキテクチャもあります。つまり、アセンブラーで記述したい場合は、幅広いコマンドワードの形成を個別に監視する必要があります。 一方、最適化コンパイラはこのような微妙さのために作成されます。 組み込み関数への移行により、ユーザーとプログラムの間で作業をインテリジェントに分散できます。 組み込みコードは、関連するすべての組み込み関数をサポートするシステム間で簡単に転送できます。 つまり、私たちの状況では、組み込み関数が明らかに最適なソリューションです。







マイクロプロセッサElbrus-4CおよびElbrus-8Cは、64ビットレジスタでのベクトル演算をサポートしています。 このレジスタを使用すると、2つの32ビット整数、4つの16ビット整数、または8つの8ビット整数を同時に処理できます。 Elbrusマイクロプロセッサの組み込みセットには、データの変換、ベクトル要素の初期化、算術演算、ビットごとの論理演算、ベクトル要素の再配置のための操作が含まれています。







それでは、最適化を始めましょう。 コードを取得して、uint16_t型の2つの配列を追加し、3番目の配列に結果を書き込みます(EMLにはそのような操作はまだありません)。







//  0 // eml_16u *src1 -       // eml_16u *src2 -       // eml_16u *dst -     // len -   for (size_t i = 0; i < len; ++i) dst[i] = src1[i] + src2[i];
      
      





次に、組み込み関数を使用して書き換えます。 簡単にするために、 len



配列の長さを4で割って、配列の残りの要素を個別に処理すると仮定します。 次に、次のようになります。







 //  1 // eml_16u *src1 -       // eml_16u *src2 -       // eml_16u *dst -     // len -   static const size_t block_size = sizeof(eml_64u) / sizeof(eml_16u); for (size_t i = 0; i < len; i += block_size, src1 += block_size, src2 += block_size, dst += block_size) *(__di*)dst = __builtin_e2k_paddh(*(__di*)src1, *(__di*)src2);
      
      





ここで、 __di



は64ビットのデータ型であり、 __di



は16ビットの符号なし加算を実行する組み込み関数です。







ただし、このコードは最適ではありません。アドレスp



合わせされていない64ビットの数値r



をロードするp



プロセッサが次の基本演算を実行する必要があるためです。







  1. 64ビットのアライメント境界からs



    アドレスp



    オフセットs



    決定します(図1を参照)。 r



    の先頭を含む位置合わせされた64ビット数のアドレスはp - s



    です。 r



    の末尾を含む次の整列された64ビット数のアドレスはp - s + 8



    ます。







  2. メモリ2から64ビットの数値r 1 、r 2をアラインされたアドレスに含むロード







  3. r 1 、r 2s



    知って、数r



    を見つけます。




1.メモリからの64ビットデータの非整列ロードのスキーム。







さらに最適化するために、Elbrusマクロを使用してこれを明示的に記述します。







 __di s = E2K_BYTES_FROM_ALIGN(p, 8); __di tmp; E2K_PREPARE_ALIGN(s, tmp); const __di *p_aligned = (__di *)E2K_ALIGN_PTR_BACK(p, 8); __di r1 = *p_aligned; __di r2 = *(p_aligned + 1); __di r; E2K_ALIGN_DATA(r1, r2, r, tmp);
      
      





このようなコードは、ループの各反復で6つのメモリアクセスを実行し、元のバージョンでは不均衡なロードを実行します。 ただし、アライメントされたアドレスへの明示的なアクセスにより、Elbrusアーキテクチャで利用可能なアレイのスワップ用の特別なバッファーを使用して、メモリアクセスの効率を高めることができます(ところで、このバッファーは組み込み関数のないコードでも使用されていました)。







各反復でのメモリアクセス数を3に簡単に減らすことができ、ループの前の反復でロードされた値を保存します。 さらに、結果の配列ではアライメントされたレコードのみを使用し、配列の最初の部分を個別に処理します。 これにより、メモリの読み取りと書き込みの両方がより効率的になります。







 //  2 // eml_16u *src1 -       // eml_16u *src2 -       // eml_16u *dst -     // len -   size_t i = 0; //        64-  dst    size_t offset = E2K_BYTES_TO_ALIGN(dst, sizeof(eml_64u)) / sizeof(eml_16u); for (; i < offset; ++i) dst[i] = src1[i] + src2[i]; //     __di spec0, spec1; __di tmp0, tmp1; __di align1 = E2K_BYTES_FROM_ALIGN(src1 + offset, sizeof(eml_64u)); E2K_PREPARE_ALIGN(align1, spec0); __di align2 = E2K_BYTES_FROM_ALIGN(src2 + offset, sizeof(eml_64u)); E2K_PREPARE_ALIGN(align2, spec1); const __di *v1 = (__di *)E2K_ALIGN_PTR_BACK(src1 + offset, 8); const __di *v2 = (__di *)E2K_ALIGN_PTR_BACK(src2 + offset, 8); __di *v3 = (__di*)(dst + offset); __di d01, d11; __di d00 = *v1; __di d10 = *v2; ++v1; ++v2; static const size_t block_size = sizeof(eml_64u) / sizeof(eml_16u); size_t effective_len = offset + ((len - offset) & ~(block_size - 1)); for (; i < effective_len; i += block_size, ++v1, ++v2, ++v3) { d01 = *v1; d11 = *v2; E2K_ALIGN_DATA(d00, d01, tmp0, spec0); E2K_ALIGN_DATA(d10, d11, tmp1, spec1); *v3 = __builtin_e2k_paddh(tmp0, tmp1); d00 = d01; d10 = d11; } //    ,   
      
      





他に何ができるのでしょうか?







ただし、覚えているように、最新のElbrusには6つの実行チャネルがあり、最大24の命令を実行でき、それらは1ビートで実行されます。 これらの命令のうち、各チャネルにベクトルALUが1つしかないため、整数の算術演算は6つだけです(他の命令は実際の算術演算、ロード/書き込みなどに関連する可能性があります)。 6 ALUは異なり、各算術命令は特定のチャネルでのみ実行できます。 不飽和加算に適しているのはチャネル0と3のみであるため、1クロックサイクルでは2回までしか加算できません。 これらの2つの追加が独立している(つまり、最初の結果は2番目では使用されない)ことを慎重なコンパイラーに伝えるために、ループを拡張します。 これは、手動で、またはコンパイラ指令を使用して実行できます。







#pragma unroll(2)









さらに、予想されるループの反復回数をコンパイラーに伝えることができます。たとえば、約1024の数が画像行のループに適しています(これは認識可能な画像の線形サイズの合理的な推定値であり、MCSTの同僚はこのサイズを推奨しています。一般的な考え方は、コンパイラーが特別なループ最適化を使用することが適切であると見なすのに十分な大きさ):







#pragma loop count(1024)









もちろん、明らかに短いサイクルでは、コンパイラーは反対のヒントを残すべきです(以下を参照)。







 //  3 // eml_16u *src1 -       // eml_16u *src2 -       // eml_16u *dst -     // len -   size_t i = 0; //        64-  dst    size_t offset = E2K_BYTES_TO_ALIGN(dst, sizeof(eml_64u)) / sizeof(eml_16u); #pragma loop count(3) for (; i < offset; ++i) { dst[i] = src1[i] + src2[i]; } //     __di spec1, spec2; __di tmp0, tmp1; __di align1 = E2K_BYTES_FROM_ALIGN(src1 + offset, sizeof(eml_64u)); E2K_PREPARE_ALIGN(align1, spec1); __di align2 = E2K_BYTES_FROM_ALIGN(src2 + offset, sizeof(eml_64u)); E2K_PREPARE_ALIGN(align2, spec2); const __di *v1 = (__di *)E2K_ALIGN_PTR_BACK(src1 + offset, sizeof(eml_64u)); const __di *v2 = (__di *)E2K_ALIGN_PTR_BACK(src2 + offset, sizeof(eml_64u)); __di *v3 = (__di*)(dst + offset); __di d01, d11, d02, d12; __di d00 = *v1; __di d10 = *v2; ++v1; ++v2; size_t effective_len = offset + ((len - offset) & ~0x03); #pragma unroll(2) #pragma loop count(1024) for (; i < effective_len; i += 4, ++v1, ++v2, ++v3) { d01 = *v1; d11 = *v2; E2K_ALIGN_DATA(d00, d01, tmp0, spec0); E2K_ALIGN_DATA(d10, d11, tmp1, spec1); *v3 = __builtin_e2k_paddh(tmp0, tmp1); d00 = d01; d10 = d11; } //    ,   
      
      





次に、測定結果を示します。 これを行うために、長さ105の2つの配列の加算時間を測定しました。105回の反復の平均時間を表に示します。







オプション 平均アレイ追加時間、μs
0 219.0
1 250.7
2 62.6
3 31,4


最適化により、追加を7倍高速化できました! 生産性を最大限に高めるという目標を設定し、Elbrusの機能を少し時間をかけて研究することで、重要な結果を達成できることがわかります。







この執筆に関して繰り返し助言してくれたICSTスタッフに感謝します!








All Articles