|
昨日のカウンタ: 今日のカウンタ: |
せっかくAndroidで画像のモノクロ化の速度比較をしたのだから、ついでのiOSでもやっておこうか。それぐらいの気持で速度を測り始めたのですが、小手先の技は通用するかな、最適化意識したらどうなるのかな、という方向で調べてたら、ちょっと時間がかかってしまいました。
iOS側では画像とそれをバイト列としてラスターデータの間の変換処理は時間に含めていません。インスタンス変数のsourceDataにNSDataとして画像のバイト列をあらかじめ格納しておきます。そしてsourceDataからNSData* targetDataにモノクロ化後のデータを入れるまでの処理を計測の対象とします。
説明の前に先に計測した時間を示します。Androidの時と同じく1024×768の画像を100回モノクロ化しています。NDKの時と全く同じアルゴリズムのものが表の一番上の「uchar」の行です。最適化オプションは配布ビルド時のデフォルトである-Osとしています。*1
機種名 | iPhone 5s (-Os) | iPhone 5 (-Os) | iPhone 4 (-Os) |
---|---|---|---|
uchar | 307 | 621 | 2529 |
uint32 | 341 | 657 | 2422 |
uint64 | 231 | 767 | 2539 |
uint16x8 | 165 | 1191 | 3347 |
float | 447 | 1151 | 6159 |
今回はどういう風に書いたら最適化が効きやすいのかな、CPUアーキテクチャを意識してコード書いたら最適化はどうなるのかなといった視点でいくつかパターンを用意してみました。uchar, uint32, uint64, uint16×8は全て整数演算で、floatだけは参考情報として浮動小数点演算で計算させてみました。
整数演算の中ではucharが一番無難なコード、uint32がARMv7までの32ビットで速くならないかなと意識したコード、uint64が64ビットCPUで速くならないかなと意識したコード、uint16x8が16ビット×8個(128ビット)のNEONの計算を素に書いたコードとなります。各々を簡単に説明してみましょう。
static const unsigned int LoopCount = 100; static const unsigned int r_lum_i = 76; static const unsigned int g_lum_i = 150; static const unsigned int b_lum_i = 30; // (中略) - (void)mono_uchar { unsigned char const *inp = (unsigned char*)sourceData.bytes; unsigned char *outp = (unsigned char*)targetData.mutableBytes; int length4 = bitmapHeight * bitmapWidth * 4; int m; int r, g, b; for (int j = 0; j < LoopCount; j ++) for (int i = 0; i < length4; i += 4) { r = inp[i + 0]; g = inp[i + 1]; b = inp[i + 2]; m = (r * r_lum_i + g * g_lum_i + b * b_lum_i) >> 8; outp[i + 3] = 0xff; outp[i + 0] = outp[i + 1] = outp[i + 2] = m ; } }
int length = bitmapHeight * bitmapWidth; uint32_t s; uint m; for (int j = 0; j < LoopCount; j ++) for (int i = 0; i < length; i ++) { s = inp[i]; m = (((s >> 16) & 0xff) * b_lum_i + ((s >> 8) & 0xff) * g_lum_i + ((s >> 0) & 0xff) * r_lum_i) >> 8; outp[i] = 0xff000000 | m | (m << 8) | (m << 16); }上の最適化-Osの場合では、そんなに速くなってないですね。iPhone4では一番速いとなってますが、誤差の範囲ですし。
const int length_2 = bitmapWidth * bitmapHeight >> 1; uint64_t s, m; for (uint j = 0; j < LoopCount; j ++) for (uint i = 0; i < length_2; i ++) { s = inp[i]; m = ((((s >> 16) & 0xff000000ff) * b_lum_i) + (((s >> 8) & 0xff000000ff) * g_lum_i) + (((s >> 0) & 0xff000000ff) * r_lum_i)) & 0xff000000ff00; outp[i] = 0xff000000ff000000 | (m << 8) | m | (m >> 8); }iPhone5やiPhone4ではCPUとして64ビットレジスタがありませんし、遅くなるのは仕方ありません。(むしろ思ったほど遅くなってませんでした。) iPhone5sではucharやuint32より明確に速くなってるのでちょっと嬉しいですね。
const uint length_2 = bitmapWidth * bitmapHeight >> 1; const uint16x8_t lum_i = vmovl_u8(vcreate_u8((0x0001000000010000 * b_lum_i) | (0x0000010000000100 * g_lum_i) | (0x0000000100000001 * r_lum_i))); uint16x8_t sv, mv; uint64_t m; for (uint j = 0; j < LoopCount; j ++) for (uint i = 0; i < length_2; i ++) { sv = vmovl_u8(vld1_u8(inp + i * 8)); mv = vmulq_u16(sv, lum_i); mv = vshrq_n_u16(mv, 8); m = (uint64_t)vmovn_u16(mv); m = ((m >> 16) + (m >> 8) + m) & 0xff000000ff; outp[i] = 0xff000000ff000000 | (m * 0x010101); }で、結果はというと、iPhone5sでの-Osでは最速です。うーん。こんな小手先の技が通用しちゃうんですね。ちょっと残念。
uint32_t s; float r, g, b; uint m; for (int j = 0; j < LoopCount; j ++) for (int i = 0; i < length; i ++) { s = inp[i]; r = (float)(s & 0x0000ff); g = (float)(s & 0x00ff00); b = (float)(s & 0xff0000); m = (uint)(r * (r_lum_f / 0x0000ff * 255) + g * (g_lum_f / 0x00ff00 * 255) + b * (b_lum_f / 0xff0000 * 255)); outp[i] = 0xff000000L | (m << 16) | (m << 8) | m; }計算結果を見るとどの機種でも、整数演算(uchar, uint32, uint64, uint16x8)のどれよりも遅くなっていますが、仕方ないところでしょうか。
先に書いたようにリリースビルドでのデフォルトの最適化オプションは-Osですが、一般的には-Ofastを指定すると更なる最適化を行ってくれます。*1 他の条件は一切変えずに計測したのが以下の結果です。
機種名 | iPhone 5s (-Ofast) | iPhone 5 (-Ofast) | iPhone 4 (-Ofast) |
---|---|---|---|
uchar | 319 | 656 | 2797 |
uint32 | 124 | 320 | 1335 |
uint64 | 249 | 858 | 5556 |
uint16x8 | 152 | 1209 | 3534 |
float | 168 | 368 | 1618 |
ucharで-Osから-Ofastへの差異は少ないのですが、それ以外では最適化の効果が大きく出て高速化されているようです。NEONなどのSIMD処理を使ってループの中の2回〜4回の処理を一度に行っているようです。ではiPhone5sのARMv8の場合にて-Ofastによる最適化がどのように行われているか、ちらっと眺めてみましょう。ループの中の処理だけ抜き出し、邪魔になりそうなコメントは抜いて、あと私の方で最低限のコメントを入れてみました。 コンパイル元のコードについては前の記事を参照して下さい。
add x17, x11, x16 ; x17 ldrb w0, [x17] ; w0 に緑要素(green)を読込 madd w0, w0, w13, wzr ; w0 := green×150 ldurb w1, [x17, #-1] ; w1に赤要素(red)を読み込み madd w0, w1, w12, w0 ; w0 := red×76 + w0 = red×76 + green×150 ldrb w17, [x17, #1] madd w17, w17, w14, w0 ; w17 := blue×30+w0 = blue×30 + red×76 + green×150 lsr w17, w17, #8 ; w17 := w17 >> 8 add x0, x10, x16 add x16, x16, #4 strb w15, [x0, #2] ; 青要素に書込み strb w17, [x0, #1] ; 緑要素に書込み strb w17, [x0] ; 赤要素に書込み sturb w17, [x0, #-1] ; αチャンネルに書込みびっくりするぐらい最適化してません。C言語ほとんどそのままですね。ちなみにw12, w13, w14, w15に各々即値で76, 150, 30, 0xffが入っています。
ldr q5, [x2], #16 ; 4ピクセル分(128ビット==16バイト)まとめてq5レジスタ(≒v5)に読み込み ushr.4s v4, v5, #16 and.16b v4, v4, v0 ; 4バイト毎に16ビット右シフトして 0x000000ff とandしたのをv4 ushr.4s v6, v5, #8 and.16b v6, v6, v0 ; 4バイト毎に8ビット右シフトして 0x000000ff とandしたのをv6 mul.4s v6, v6, v1 ; 4バイトづつ v6 := v6 × 150 and.16b v5, v5, v0 ; シフトせず 4ビット毎に 0x000000ff とandしたのをv5に mla.4s v6, v5, v2 ; 4バイトづつ v6 := v6 + v5 × 76 mla.4s v6, v4, v3 ; 4バイトづつ v6 := v6 + v4 × 30 ushr.4s v4, v6, #8 ; 4バイトづつまとめて右へ8つシフト shl.4s v5, v4, #8 orr.16b v5, v4, v5 shl.4s v4, v4, #16 orr.16b v4, v5, v4 ; 4ピクセル分のモノクロ計算後RGB要素をv4とする orr.4s v4, #255, lsl #24 ; 4ピクセル分のαチャンネルを255とする str q4, [x1], #16 ; 4ピクセル分の計算結果をまとめて格納 sub x17, x17, #4 cbnz x17, LBB6_5こんな感じで128ビットのNEONレジスタを使って、頼んでもいないのに4ピクセルの計算にまとめていました。しかも最適化後でもシンプル!
ldr q4, [x2], #16 ; 4ピクセル分読み込み ushr.2d v2, v4, #16 and.16b v2, v2, v0 ; v2に赤要素(4ピクセル分) ushr.2d v3, v4, #8 and.16b v3, v3, v0 ; v3に緑要素(4ピクセル分) and.16b v4, v4, v0 ; v4に青要素(4ピクセル分) umov.d x3, v4[1] madd x3, x3, x15, xzr ; 青要素の1,2ピクセル目を計算 fmov d5, x3 fmov x3, d4 madd x3, x3, x15, xzr ; 青要素の3,4ピクセル目を計算 fmov d4, x3 zip1.2d v4, v4, v5 ; 計算結果を再びv4に格納(4ピクセル分) umov.d x3, v3[1] madd x3, x3, x14, xzr (長いので以下略)こっちも128ビットのNEONレジスタを使ってくれているのですが、計算が64ビット単位になっているせいで、あまりシンプルになっていません。C言語側で無理して64ビットに2ピクセル分の計算を収めたところが足を引っ張っている印象です。結局2ピクセル単位でしか計算できていませんし…*2
ldr d1, [x19, x13] ; 2ピクセル分(64ビット)まとめて読み込み ushll.8h v1, v1, #0 ; 8個の8ビット数値を8個の16ビット数値に拡張 mul.8h v1, v1, v0 ; 各色要素毎に{r,gb}_lum_iを乗算 ushr.8h v1, v1, #8 ; 各色要素毎に右に8ビットシフト xtn.8b v1, v1 ; 8個の16ビット数値を8個の8ビット数値に戻す fmov x13, d1 add x14, x13, x13, lsr #8 add x13, x14, x13, lsr #16 and x13, x13, #0xff000000ff ; モノクロ化後の色要素をx13とする madd x13, x13, x10, xzr ; 各色要素にモノクロ化した数値を格納 orr x13, x13, #0xff000000ff000000 ; αチャンネルを付与 str x13, [x0, x11]. ; メモリへ2ピクセル分のデータを格納 add x11, x11, #8 sub w12, w12, #1 cbnz w12, LBB8_3
Cで書いたソースそのままです。元が明示的にNEONのこの命令使えというソースからなので、最適化の余地がないのは仕方ないところでしょうか。これが、-Ofastの下ではuint32に負けてしまう理由なのでしょうか。
ldr q18, [x16], #16 ; 128ビットレジスタに4ピクセル分読み込み and.16b v17, v18, v3 ucvtf.4s v17, v17 ; 4ピクセル分の赤要素を4つのfloatへ and.16b v19, v18, v4 ucvtf.4s v19, v19 ; 4ピクセル分の緑要素を4つのfloatへ and.16b v18, v18, v5 ucvtf.4s v18, v18 ; 4ピクセル分の青要素を4つのfloatへ fmul.4s v19, v19, v6 fmla.4s v19, v7, v17 fmla.4s v19, v16, v18 ; 各色要素に{r,g,b}_lum_i を乗算し加算 fcvtzu.4s v17, v19 ; 再び4つの整数(unsigned int)に変換 shl.4s v18, v17, #8 orr.16b v18, v17, v18 shl.4s v17, v17, #16 orr.16b v17, v18, v17 ; 4ピクセル分のモノクロ値をRGBに設定 orr.4s v17, #255, lsl #24 ; 4ピクセル分のαチャンネルを設定 str q17, [x15], #16 sub x14, x14, #4 cbnz x14, LBB9_5
読みやすいです。そしてNEONを使った並列計算をうまく生かせた最適化になっています。これにより浮動小数点演算でありながら最速のuint32に迫る速度をiPhone5s/iPhone5/iPhone4のいずれでも出せています。
iPhone5sでの事前の予測としてはuint64かuint16x8が-Ofast付けても高速かなーと思っていたのですが、uint32が最速となりました。最適化後のアセンブリコードを眺めてみると、いずれもNEONをしっかり使ったものになっています。
同じNEONを使ってもuint16x8では一つの128ビットレジスタでの2ピクセル同時計算に留まらせた一方で、uint32では色要素毎の3つの128ビットレジスタを使って4ピクセル同時計算にまで最適化できているのが大きな違いでしょうか。
うまく最適化を生かせるCコードを書けば、素人の下手な最適化(uint16x8)に比べてより良い最適化(uint32)をしてくれるというのは嬉しいところです。また浮動小数点演算でもうまく最適化にはまれば良いコードを出してくれるのも嬉しいところです。
色々と最適化かけて比較してきましたが、一つ忘れてました。つまり-O0、つまり最適化なしです。デバッグビルドのデフォルトです。
機種名 | iPhone 5s (-O0) | iPhone 5 (-O0) | iPhone 4 (-O0) |
---|---|---|---|
uchar | 1491 | 2669 | 10238 |
uint32 | 1294 | 2311 | 8393 |
uint64 | 730 | 1020 | 3616 |
uint16x8 | 785 | 1872 | 7435 |
float | 2018 | 4208 | 16766 |
あらら、uint16x8が一番速いのかと思いきや、uint64が一番速かったですね。