前回は、asmVisの実行タイミングをグラフィカルに表示する機能、命令の実行順序を画面上で編集する機能、Enhanced DP対応のシミュレーション機能、JavaプログラムでありLinux以外にWindowsでも実行できる点を説明しました。
SPUはEven/Oddの2つの実行パイプラインを持つ2命令同時発行のスーパースカラプロセッサですが、EvenパイプとOddパイプでは実行できる命令が異なります。 Evenパイプは主に算術演算と比較演算を、Oddパイプではロード/ストアとブランチ命令を実行できます。 V字の実行タイミングを得るためには、EvenパイプとOddパイプの命令に均等に分散していることが重要になってきます。
また、ストールは効率を大きく引き下げるため、できる限り避けなくてはなりません。依存関係のない命令を多く配置する方法を考える必要があります。
この記事では、命令の実行タイミングがどのように変化していくか、実際にasmVisで確認しながらコードを最適化していきましょう。
List 1はLocal Store(LS)に置かれた幅_width、高さ_heightの2次元画像に対して、全体に定数を加算して明るさを増加させるという画像処理ルーチンです。画素は1ピクセルあたり8ビットで表現されていることとし、加算によって8ビットの値域を超える場合は255とする飽和処理を行っています。_outStrideおよび_in1Strideは画像のスキャンライン同士の距離で、ピクセル数単位で指定します。なお、uint8_tやuint32_tなどは比較的新しいC言語標準のtypedef型で、inttypes.hで定義されています。
List 1. 画像定数加算プログラム
#include <inttypes.h>
#include <vec_types.h>
void add_const(
uint8_t* _outPtr,
int32_t _outStride,
const uint8_t* _in1Ptr,
int32_t _in1Stride,
uint8_t _value,
uint32_t _width,
uint32_t _height)
{
uint32_t x, y;
for (y = 0; y < _height; y++)
{
uint8_t* out = _outPtr;
const uint8_t* in = _in1Ptr;
for (x = 0; x < _width; x++)
{
uint32_t temp;
temp = *in++ + _value;
if ( temp > 255 ) temp = 255;
*out++ = (uint8_t)temp;
}
_outPtr += _outStride;
_in1Ptr += _in1Stride;
}
}
|
さて、このプログラムを、asmVisでパイプラインを見てみましょう。全てはここから始まります。
一見して、ひどく隙間の多い結果になっていることが見て取れます。これは演算の依存関係があるために、前の命令が完了してからでないと後の命令を開始できない状態、つまりパイプラインストールが発生しているからです。
これからこのプログラムに最適化テクニックを適用していきますが、最適化の成果を検討するために、性能評価のルールを決めておきましょう。
- 最も内側のループ1周あたりに処理されるピクセル数:ループあたりにいくつのピクセルを処理していくかを検討しながら、SIMD命令やループアンローリングを適用していきます。List 1では、もちろん1です。
-
最も内側のループのサイクル数:このルーチンの実行時間を支配するのは、主に変数
xのループのサイクル数です。このループの最初の命令発行から、最後の命令発行までのサイクルを数えます。List 1では.L5ループが該当し、最初の命令は7クロック目、最後の命令は30クロック目で発行されていますから、24サイクルと数えます。 -
最も内側のループの命令数:サイクル数と同様に命令数も数えましょう。このループの最初の命令発行から、最後の命令発行までの命令数を数えます。List 1の.sファイルを見ると、
.L5ループの最初の命令は21:ai$25,$12,13、最後の命令は37:brnz$13,.L5で17行ありますが、.L15ラベルのために1行使っているため、16命令です。 - ピクセルあたりのサイクル数:上記のサイクル数をピクセル数で割ったものです。少ないほどよいことになります。
- ピクセルあたりの命令数:上記のサイクル数をピクセル数で割ったものです。少ないほどよいことになります。
- サイクルあたりの命令数:上記の命令数をサイクル数で割ったものです。大きいほどよく、最大値は2です。
これらの指標を改めて表にしておきます。
| ピクセル数 | サイクル数 | 命令数 | サイクル/ピクセル | 命令/ピクセル | 命令/サイクル | |
|---|---|---|---|---|---|---|
| List 1 | 1 | 24 | 16 | 24.00 | 14.00 | 0.67 |
次にSIMD命令を使用して、サイクルあたり16ピクセルずつ処理するように変更します。関数のインターフェースは変更しません。
List 2. 16ピクセルずつSIMD処理
#include <inttypes.h>
#include <vec_types.h>
void add_const(
uint8_t* _outPtr,
int32_t _outStride,
const uint8_t* _in1Ptr,
int32_t _in1Stride,
uint8_t _value,
uint32_t _width,
uint32_t _height)
{
uint32_t x, y;
vec_ushort8 v_value = spu_splats((uint16_t)_value);
vec_ushort8 v_0x00ff = spu_splats((uint16_t)0x00ff);
vec_uchar16 v_zero = (vec_uchar16)spu_splats(0);
vec_uchar16 v_pattern1 = { 0, 16, 2, 18, 4, 20, 6, 22,
8, 24, 10, 26, 12, 28, 14, 30 };
vec_uchar16 v_pattern2 = { 1, 17, 3, 19, 5, 21, 7, 23,
9, 25, 11, 27, 13, 29, 15, 31 };
for (y = 0; y < _height; y++)
{
vec_uchar16* v_out = (vec_uchar16*)_outPtr;
const vec_uchar16* v_in = (const vec_uchar16*)_in1Ptr;
for (x = 0; x < _width; x += 16)
{
vec_uchar16 v_src;
vec_uchar16 v_dst;
vec_ushort8 v_temp1, v_temp2;
vec_ushort8 v_mask1, v_mask2;
// Load
v_src = *v_in++;
// Convert to short vector
v_temp1 = (vec_ushort8)spu_shuffle(v_zero, v_src, v_pattern1);
v_temp2 = (vec_ushort8)spu_shuffle(v_zero, v_src, v_pattern2);
// Add
v_temp1 = spu_add(v_temp1, v_value);
v_temp2 = spu_add(v_temp2, v_value);
// Saturate
v_mask1 = spu_cmpgt(v_temp1, v_0x00ff);
v_mask2 = spu_cmpgt(v_temp2, v_0x00ff);
v_temp1 = spu_sel(v_temp1, v_0x00ff, v_mask1);
v_temp2 = spu_sel(v_temp2, v_0x00ff, v_mask2);
// Gather to uchar vector
v_dst = (vec_uchar16)spu_shuffle(v_temp1, v_temp2, v_pattern2);
// Store
*v_out++ = v_dst;
}
_outPtr += _outStride;
_in1Ptr += _in1Stride;
}
}
|
さて、asmVisでパイプラインを見てみましょう。
命令間の依存関係があるため前の演算が終わるのを待ってから次の命令を実行しなければならずにパイプラインが頻繁にストールしている状況は変わっていません。
しかし、SIMD命令は活用してループあたり16ピクセル処理していますから、総合的な性能は向上しているはずです。指標を調べてみましょう。
| ピクセル数 | サイクル数 | 命令数 | サイクル/ピクセル | 命令/ピクセル | 命令/サイクル | |
|---|---|---|---|---|---|---|
| List 2 | 16 | 26 | 14 | 1.63 | 0.88 | 0.54 |
パイプラインストールを解消するには、ループアンローリングが有効です。ループアンローリングとは、forなどのループ文で書かれている反復コードを、ループを使わずに(あるいは数回のループごとの単位で)書き下してしまうことです。
ループアンローリングをすることで1回のループに含まれる依存関係のない命令が増えます。それを活用して依存関係のない命令を次々と発行してしまい、演算完了待ちをしている時間を隠蔽してしまうことができます。ループの周回間に演算の依存関係がない場合には、一般に有効な方法と言えるでしょう。
ループアンローリングを実施したものが次のコードです。
List 3. ループアンローリングを適用
#include <inttypes.h>
#include <vec_types.h>
void add_const(
uint8_t* _outPtr,
int32_t _outStride,
const uint8_t* _in1Ptr,
int32_t _in1Stride,
uint8_t _value,
uint32_t _width,
uint32_t _height)
{
uint32_t x, y;
vec_ushort8 v_value = spu_splats((uint16_t)_value);
vec_ushort8 v_0x00ff = spu_splats((uint16_t)0x00ff);
vec_uchar16 v_zero = (vec_uchar16)spu_splats(0);
vec_uchar16 v_pattern1 = { 0, 16, 2, 18, 4, 20, 6, 22,
8, 24, 10, 26, 12, 28, 14, 30 };
vec_uchar16 v_pattern2 = { 1, 17, 3, 19, 5, 21, 7, 23,
9, 25, 11, 27, 13, 29, 15, 31 };
for (y = 0; y < _height; y++)
{
vec_uchar16* v_out = (vec_uchar16*)_outPtr;
const vec_uchar16* v_in = (const vec_uchar16*)_in1Ptr;
for (x = 0; x < _width; x += 64)
{
vec_uchar16 v_srcA, v_srcB, v_srcC, v_srcD;
vec_uchar16 v_dstA, v_dstB, v_dstC, v_dstD;
vec_ushort8 v_tempA1, v_tempA2, v_tempB1, v_tempB2,
v_tempC1, v_tempC2, v_tempD1, v_tempD2;
vec_ushort8 v_maskA1, v_maskA2, v_maskB1, v_maskB2,
v_maskC1, v_maskC2, v_maskD1, v_maskD2;
// Load
v_srcA = *v_in++;
v_srcB = *v_in++;
v_srcC = *v_in++;
v_srcD = *v_in++;
// Convert to short vector
v_tempA1 = (vec_ushort8)spu_shuffle(v_zero, v_srcA, v_pattern1);
v_tempA2 = (vec_ushort8)spu_shuffle(v_zero, v_srcA, v_pattern2);
v_tempB1 = (vec_ushort8)spu_shuffle(v_zero, v_srcB, v_pattern1);
v_tempB2 = (vec_ushort8)spu_shuffle(v_zero, v_srcB, v_pattern2);
v_tempC1 = (vec_ushort8)spu_shuffle(v_zero, v_srcC, v_pattern1);
v_tempC2 = (vec_ushort8)spu_shuffle(v_zero, v_srcC, v_pattern2);
v_tempD1 = (vec_ushort8)spu_shuffle(v_zero, v_srcD, v_pattern1);
v_tempD2 = (vec_ushort8)spu_shuffle(v_zero, v_srcD, v_pattern2);
// Add
v_tempA1 = spu_add(v_tempA1, v_value);
v_tempA2 = spu_add(v_tempA2, v_value);
v_tempB1 = spu_add(v_tempB1, v_value);
v_tempB2 = spu_add(v_tempB2, v_value);
v_tempC1 = spu_add(v_tempC1, v_value);
v_tempC2 = spu_add(v_tempC2, v_value);
v_tempD1 = spu_add(v_tempD1, v_value);
v_tempD2 = spu_add(v_tempD2, v_value);
// Saturate
v_maskA1 = spu_cmpgt(v_tempA1, v_0x00ff);
v_maskA2 = spu_cmpgt(v_tempA2, v_0x00ff);
v_maskB1 = spu_cmpgt(v_tempB1, v_0x00ff);
v_maskB2 = spu_cmpgt(v_tempB2, v_0x00ff);
v_maskC1 = spu_cmpgt(v_tempC1, v_0x00ff);
v_maskC2 = spu_cmpgt(v_tempC2, v_0x00ff);
v_maskD1 = spu_cmpgt(v_tempD1, v_0x00ff);
v_maskD2 = spu_cmpgt(v_tempD2, v_0x00ff);
v_tempA1 = spu_sel(v_tempA1, v_0x00ff, v_maskA1);
v_tempA2 = spu_sel(v_tempA2, v_0x00ff, v_maskA2);
v_tempB1 = spu_sel(v_tempB1, v_0x00ff, v_maskB1);
v_tempB2 = spu_sel(v_tempB2, v_0x00ff, v_maskB2);
v_tempC1 = spu_sel(v_tempC1, v_0x00ff, v_maskC1);
v_tempC2 = spu_sel(v_tempC2, v_0x00ff, v_maskC2);
v_tempD1 = spu_sel(v_tempD1, v_0x00ff, v_maskD1);
v_tempD2 = spu_sel(v_tempD2, v_0x00ff, v_maskD2);
// Gather to uchar vector
v_dstA = (vec_uchar16)spu_shuffle(v_tempA1, v_tempA2, v_pattern2);
v_dstB = (vec_uchar16)spu_shuffle(v_tempB1, v_tempB2, v_pattern2);
v_dstC = (vec_uchar16)spu_shuffle(v_tempC1, v_tempC2, v_pattern2);
v_dstD = (vec_uchar16)spu_shuffle(v_tempD1, v_tempD2, v_pattern2);
// Store
*v_out++ = v_dstA;
*v_out++ = v_dstB;
*v_out++ = v_dstC;
*v_out++ = v_dstD;
}
_outPtr += _outStride;
_in1Ptr += _in1Stride;
}
}
|
| ピクセル数 | サイクル数 | 命令数 | サイクル/ピクセル | 命令/ピクセル | 命令/サイクル | |
|---|---|---|---|---|---|---|
| List 3 | 64 | 43 | 59 | 0.67 | 0.92 | 1.37 |
4周のループを展開したためループあたりの命令数はほぼ4倍に増えているにも関わらず、ループに必要なサイクル数は2倍以下に収まっています。 ループアンローリングにより依存関係のない演算を判断しやすい状況が促進され、複数の命令をパイプライン処理できるようになったためです。
この劇的な性能向上を見るともっと多くの周回をアンローリングしたいところですが、アンローリングすることにより処理単位を制約してしまうというトレードオフもあります。例えばList 3では64ピクセルの倍数に制約されています。許容される制約と求める性能の間で適切な値を見つけましょう。
またこの記事では詳しく議論していませんが、処理単位以外にもトレードオフを考えるべきポイントがあります。例えばコードサイズとレジスタ消費量です。ループアンローリングを行う場合は一般にコードサイズが大きくなります。貴重なLocal Storeの消費量に配慮する必要があります。また、SPEでは128本ものレジスタを使用できますが、ループアンローリングする周回を大きくした場合には枯渇する可能性にも十分注意を払うべきです。
さて、サイクル数/ピクセルの値を比べるとすでに元のスカラーコードに比べて30倍以上の高速化を果たしていますが、まだ終わりではありません。List 3のパイプラインをasmVisで見てみると命令が右側のOddパイプに偏っていることがわかります。もしEvenパイプの命令で置き換えられる演算があれば、より理想的なV字型に近づけることができるでしょう。
List 3では3分の1程度がOddパイプで実行されるshuffle命令となっています。これはSPUには8ビットデータ用の演算命令がほとんどないため、16ビット整数に変換してから演算を行っているためです。
この変換を行っている最初のブロックと最後のブロックを工夫して、Evenパイプのand命令とor命令に置き換えることが可能です。
List 4. shuffle命令の一部をEvenパイプ命令に置き換え
#include <inttypes.h>
#include <vec_types.h>
void add_const(
uint8_t* _outPtr,
int32_t _outStride,
const uint8_t* _in1Ptr,
int32_t _in1Stride,
uint8_t _value,
uint32_t _width,
uint32_t _height)
{
uint32_t x, y;
vec_ushort8 v_value = spu_splats((uint16_t)_value);
vec_ushort8 v_0x00ff = spu_splats((uint16_t)0x00ff);
vec_uchar16 v_zero = (vec_uchar16)spu_splats(0);
vec_uchar16 v_pattern1 = { 0, 16, 2, 18, 4, 20, 6, 22,
8, 24, 10, 26, 12, 28, 14, 30 };
vec_uchar16 v_pattern2 = { 1, 17, 3, 19, 5, 21, 7, 23,
9, 25, 11, 27, 13, 29, 15, 31 };
vec_uchar16 v_pattern3 = { 0x80, 0, 0x80, 2, 0x80, 4, 0x80, 6,
0x80, 8, 0x80, 10, 0x80, 12, 0x80, 14 };
for (y = 0; y < _height; y++)
{
vec_uchar16* v_out = (vec_uchar16*)_outPtr;
const vec_uchar16* v_in = (const vec_uchar16*)_in1Ptr;
for (x = 0; x < _width; x += 64)
{
vec_uchar16 v_srcA, v_srcB, v_srcC, v_srcD;
vec_uchar16 v_dstA, v_dstB, v_dstC, v_dstD;
vec_uchar16 v_maskA, v_maskB, v_maskC, v_maskD;
vec_ushort8 v_tempA1, v_tempA2, v_tempB1, v_tempB2,
v_tempC1, v_tempC2, v_tempD1, v_tempD2;
// Load
v_srcA = *v_in++;
v_srcB = *v_in++;
v_srcC = *v_in++;
v_srcD = *v_in++;
// Convert to short vector
v_tempA1 = (vec_ushort8)spu_shuffle(v_srcA, v_srcA, v_pattern3);
v_tempA2 = (vec_ushort8)spu_and((vec_ushort8)v_srcA, v_0x00ff);
v_tempB1 = (vec_ushort8)spu_shuffle(v_srcB, v_srcB, v_pattern3);
v_tempB2 = (vec_ushort8)spu_and((vec_ushort8)v_srcB, v_0x00ff);
v_tempC1 = (vec_ushort8)spu_shuffle(v_srcC, v_srcC, v_pattern3);
v_tempC2 = (vec_ushort8)spu_and((vec_ushort8)v_srcC, v_0x00ff);
v_tempD1 = (vec_ushort8)spu_shuffle(v_srcD, v_srcD, v_pattern3);
v_tempD2 = (vec_ushort8)spu_and((vec_ushort8)v_srcD, v_0x00ff);
// Add
v_tempA1 = spu_add(v_tempA1, v_value);
v_tempA2 = spu_add(v_tempA2, v_value);
v_tempB1 = spu_add(v_tempB1, v_value);
v_tempB2 = spu_add(v_tempB2, v_value);
v_tempC1 = spu_add(v_tempC1, v_value);
v_tempC2 = spu_add(v_tempC2, v_value);
v_tempD1 = spu_add(v_tempD1, v_value);
v_tempD2 = spu_add(v_tempD2, v_value);
// Saturate
v_maskA = (vec_uchar16)spu_shuffle(v_tempA1, v_tempA2, v_pattern1);
v_maskB = (vec_uchar16)spu_shuffle(v_tempB1, v_tempB2, v_pattern1);
v_maskC = (vec_uchar16)spu_shuffle(v_tempC1, v_tempC2, v_pattern1);
v_maskD = (vec_uchar16)spu_shuffle(v_tempD1, v_tempD2, v_pattern1);
v_maskA = spu_cmpgt(v_maskA, v_zero);
v_maskB = spu_cmpgt(v_maskB, v_zero);
v_maskC = spu_cmpgt(v_maskC, v_zero);
v_maskD = spu_cmpgt(v_maskD, v_zero);
// Gather
v_dstA = (vec_uchar16)spu_shuffle(v_tempA1, v_tempA2, v_pattern2);
v_dstB = (vec_uchar16)spu_shuffle(v_tempB1, v_tempB2, v_pattern2);
v_dstC = (vec_uchar16)spu_shuffle(v_tempC1, v_tempC2, v_pattern2);
v_dstD = (vec_uchar16)spu_shuffle(v_tempD1, v_tempD2, v_pattern2);
v_dstA = spu_or(v_dstA, v_maskA);
v_dstB = spu_or(v_dstB, v_maskB);
v_dstC = spu_or(v_dstC, v_maskC);
v_dstD = spu_or(v_dstD, v_maskD);
// Store
*v_out++ = v_dstA;
*v_out++ = v_dstB;
*v_out++ = v_dstC;
*v_out++ = v_dstD;
}
_outPtr += _outStride;
_in1Ptr += _in1Stride;
}
}
|
asmVisで見てみましょう。
| ピクセル数 | サイクル数 | 命令数 | サイクル/ピクセル | 命令/ピクセル | 命令/サイクル | |
|---|---|---|---|---|---|---|
| List 4 | 64 | 32 | 51 | 0.50 | 0.80 | 1.59 |
ループ周回あたりの命令数も少し減りましたが、サイクル数はさらに減っています。shuffle命令がEvenパイプのand/or命令に置き換わり、ほとんどの行で左右のパイプに命令が発行されているより理想的なV字を描く状況に近づいたことがわかります。
もう一度実験結果を見比べてみましょう。
オリジナルから比較すると、ピクセルあたりのサイクル数は24サイクルから0.5サイクルにまで削減することができ、48倍の高速化を果たしました。実際はY方向のループの分のオーバーヘッドがありますから、spu-gcc 4.1.1でSPUプログラムとしてビルドして実際の実行した場合の時間も記しておきます。使用した画像サイズは1024x32です。
数値はお使いのコンパイラやシステムによって変化することがあります。コンパイルスイッチを変えてみたり、コンパイラそのものを変えてみるとどうなるか、ぜひみなさん自身で試してみてください。
| ピクセル数 | サイクル数 | 命令数 | サイクル/ピクセル | 命令/ピクセル | 命令/サイクル | 実行時間 [us] | add_const.o のサイズ [byte] | 対List 1 速度比 [us/us] | |
|---|---|---|---|---|---|---|---|---|---|
| List 1 | 1 | 24 | 16 | 24.00 | 14.00 | 0.67 | 246.17 | 743 | 1.00 |
| List 2 | 16 | 26 | 14 | 1.63 | 0.88 | 0.54 | 17.78 | 908 | 13.85 |
| List 3 | 64 | 43 | 59 | 0.67 | 0.92 | 1.37 | 7.41 | 1116 | 33.23 |
| List 4 | 64 | 32 | 51 | 0.50 | 0.80 | 1.59 | 5.64 | 1120 | 43.69 |
| 参考 | 128 | 53 | 93 | 0.41 | 0.73 | 1.75 | 4.95 | 1256 | 49.77 |
List 4までの最適化で、最終的なサイクルあたりの命令数は1.59となりました。最大値である2にはもう一息といったところですが、演算に先立って必ず多くのロード/ストア命令が必要なことが主な要因となっています。もっと多くのループをアンロールしたり、先ほどのshuffle命令をand/or命令に置き換えたようなトリックを探すことで、さらに改善することができるでしょう。
命令がSPUのパイプラインにどのように実行されるかをasmVisで見ながら、簡単な画像処理コードの最適化をしてみました。 SIMD命令やループアンローリングなどのテクニックで劇的に速度を改善させることができることを、asmVisを用いて視覚的に確認しながら作業できることをご紹介しました。
学ぶために
- XL C/C++コンパイラを試してみるには、XLC で行こう!: 第 1 回 Cell/B.E.用 XLC は2種類あるよを参考にしましょう。
- Cell/B.E.プログラミングをもっと学ぶには、次のdeveloperWorksシリーズがあります:
- "Programming high-performance applications on the Cell/B.E. processor"
- "The little broadband engine that could"
- The IBM Semiconductor Solutions Technical Library Cell Broadband Engine documentation セクションには、ダウンロードマニュアルや、仕様書などたくさんの有益な情報があります。
製品や技術を入手するために
- Assembly Visualizerの最新版はここで入手しましょう: alphaWorks :
IBM Assembly Visualizer for Cell Broadband Engine.
- 全ての Cell/B.E. 情報 --関連記事、ディスカッションフォーラム、CellSDK、その他のダウンロード-- は IBM developerWorks の Cell Broadband Engine resource center にあります。
- Cell/B.E. を手に入れるにはこちら: IBM ディープ・コンピューティング | Cell/B.E ブレード
- Cell/B.E.のカスタム製品のお問い合わせはこちら: Contact us about Cell Broadband Engine technology.
議論するために
-
ディスカッションフォーラムに参加してください.
- 質問はIBM developerWorks Power Architecture Cell Broadband Engine discussion forum へ投稿してください。
