ぐだぐだ低レベルプログラミング(19) Arm NEONをつかってみる4

ようやくコンパイラにNEONのクワッドワードのレジスタを使うコードを吐き出してもらえるようになったので、わずかにハードルを上げてみたいと思います。当初から予定通りで、内積計算ですね。ベクトルの要素毎に単純な掛け算をしているのと比べると、それらの和をとっていかなければならないので、計算はちょっとだけ複雑。それに何と言っても結果はスカラー。コンパイラはどのように料理してくれるのでしょうか。

※「ぐだぐだ低レベルプログラミング」投稿順indexはこちら

例によって、今回対象のCの関数のソースをまず掲げます。毎度、変わり方が微妙で、なにほども進歩していないように見えるソースコードであります。

float innerProd(const int vlen, float* __restrict x, float* __restrict y) {
    float result = 0.0;

    for(int i = 0; i < 4*vlen; i++) {
        result += x[i] * y [i];
    }

    return result;
}

前回の反省どおり、コンパイラがベクトルの長さは常に4の倍数に決まっていると「理解」できるようにしてみました。しかし、今回は、掛けた結果を足し込んでいかないとならないので、最後の結果はスカラー値1個にせねばなりません。そんな場合もコンパイラは塩梅よく始末してくれるんでしょうか。まあ、論より証拠で、コンパイルしてみましょう。コンパイルオプションは前回と同じであります。

000105f0 <innerProd>:
   105f0:	e1a00100 	lsl	r0, r0, #2
   105f4:	e3500000 	cmp	r0, #0
   105f8:	da00000b 	ble	1062c <innerProd+0x3c>
   105fc:	e0820100 	add	r0, r2, r0, lsl #2
   10600:	f2c00050 	vmov.i32	q8, #0	; 0x00000000
   10604:	f4624a8d 	vld1.32	{d20-d21}, [r2]!
   10608:	e1500002 	cmp	r0, r2
   1060c:	f4612a8d 	vld1.32	{d18-d19}, [r1]!
   10610:	f2440df2 	vmla.f32	q8, q10, q9
   10614:	1afffffa 	bne	10604 <innerProd+0x14>
   10618:	f2400da1 	vadd.f32	d16, d16, d17
   1061c:	f3400da0 	vpadd.f32	d16, d16, d16
   10620:	ee103b90 	vmov.32	r3, d16[0]
   10624:	ee003a10 	vmov	s0, r3
   10628:	e12fff1e 	bx	lr
   1062c:	e3a03000 	mov	r3, #0
   10630:	ee003a10 	vmov	s0, r3
   10634:	e12fff1e 	bx	lr

いやー、結構いい感じにコンパイルしてくれているんじゃないでしょうかね。今回は1命令づつ勉強させていただきましょう。まずは、最初の

lsl r0, r0, #2

例のArmのABI規則により、r0レジスタには第1引数の値が入っている筈。それを2ビット左シフト、即ち4倍しておりますな。まさにこれは、以下の計算に違いない。

4*vlen

その4倍した結果(ベクトルの要素数)を0と比べています。

cmp r0, #0

比べた結果が、0か0より小さい(データを符号付きと解釈)ならば0x1062C番地に分岐しています。

ble 1062c

これは、もしvlenに0やマイナスの値を突っ込まれてしまった場合、何もしない、ということを保証してくれていますな。計算する場合には真っすぐ下に落ちるので、そちらから見ていきます。

add r0, r2, r0, lsl #2

再びフレキシブル第2オペランドが活躍しています。r0を左2ビットシフト(4倍)したものをr2(2番目のベクトルの先頭番地を指している筈)に加えたものをr0に格納しています。直前のr0の値は先ほど見た通りで 4*vlenでしたから、ベクトルの要素長さ、それを4倍(1要素は4バイト)しているので、バイトで数えたベクトル長さを加算していることになります。これは、ベクトルの末尾の番地ですな。

次にq8というクワッドレジスタをゼロクリアしています。

vmov.i32 q8, #0

型は32ビット整数型ですが、ゼロクリアしたことに変わりはなしと。このq8は積算の中間結果を保持しておくためのものでしょう。

次から本体処理の先頭です。

vld1.32 {d20-d21}, [r2]!

このvld1.32 {d20-d21}, [r2]!という書き方は結構まどろっこしく感じますが、1要素が32ビットの「構造体」をダブルワード(64ビット)幅のレジスタd20とd21に、r2レジスタが保持しているアドレスからロード(64ビット2本なので、128ビット=16バイト)、そして最後のビックリマークでr2にロード幅分のアドレス増分16を加えて次回ロードの準備をしておく、というもの。

cmp r0, r2

その直後に、先ほど出てきた r0と更新したばかりのr2を比べています。もし、r2がr0と一致するならば、末尾である筈。cmp命令でフラグに結果は残りますが、ここでは一端とっておくようです。まだ、やる仕事あるから。cmpを先にしているのは、多分、ロードを2回続ける隙間の時間でcmpは終わってしまうからと推測します。次の命令で、もう一方のベクトルの値4要素16バイトをロード。

vld1.32 {d18-d19}, [r1]!

ここで、d18-d19は、実はq9と同じもの。d20-d21はq10と同じもの。

vmla.f32 q8, q10, q9

さすれば、上のvmla.f32は、q10とq9をvmlaして、q8(積算用に準備した中間レジスタ)にいれる。さて、vmlaとは、

ベクタ積和命令

であります。コンテンポラリーなプロセッサには必ず入っているかと思います。要素毎掛け算した結果を、積算レジスタの保持する値に足し込んでいく、1命令で2度オイシイもの。

bne 10604

そして上記の分岐で、ne=ノット・イコールであるならば、先ほどのループ先頭に戻れ、と。こうしてループを回っていく毎に、4要素ずつ同時に掛け算して、加算されていくわけでした。しかし、最終結果はスカラーであります。その始末は、bneを下に落ちた(つまりイコール、r0がr2と一致した)ときに。

vadd.f32 d16, d16, d17

先ほどの逆で、q8というクワッドワードレジスタは、d16とd17の2本に分かれるのでした。だから、d16とd17を足した結果をd16に入れれば、上半分2要素と下半分2要素をば、足したことになる。これで4要素が2要素になりました。

しかし、次の

vpadd.f32 d16, d16, d16

は何か? Arm社の説明を引用させていただきましょう。

vpadd (ベクタペアワイズ加算)は、2 つのベクタの隣接する要素のペアを加算し、その結果をデスティネーションベクタに返します。

ぶっちゃけ、d16の上要素と下要素を足し込む計算。結果は下に入る筈。

そして、またちょっとまどろっこしいですが、

vmov.32 r3, d16[0]

まず、d16[0]つまり、d16の下要素を、レジスタr3へ転送(取り出し)て、

vmov s0, r3

その値をs0レジスタ(スカラーの浮動小数戻り値ならばここに入れるお約束)に入れている。

そして以下で関数から戻る、と。

bx lr

その下は、もうお分かりでしょう。最初のチェックではねられたvlenの場合は、0をもって返る、と。

めでたしめでたし。

ぐだぐだ低レベルプログラミング(18) Arm NEONをつかってみる3

ぐだぐだ低レベルプログラミング(20) RISC-V、nop、mv、li??