ぐだぐだ低レベルプログラミング(16) Arm NEONを使ってみる1

今回から、少し実用的?に、ArmのNEONを学んでみたいと思います。所謂SIMD(Sigle Instruction/Multiple Data)の命令セットです。Intelでいうと、AVXとか、SSEとかに相当します。Arm NEONの場合、128ビット幅のレジスタなので一度に単精度の浮動小数であれば4個、16ビットの整数なら8個を計算できます。GPUの並列度には遠く及びませんが、使えるものは使いましょう。それに、我がRaspberry Pi 3 model B+でも立派に使用できるのであります。

<訂正>この回はNeon命令使用に行きついていませぬ。その一歩手前で終わっております。

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

Neonですが、多分、最近のCortex-A系のArmプロセッサを使っていれば、きっと無意識にお世話になっているのではないかと思います。ライブラリの中で計算をしているモノどもが知らず知らずのうちに使っているに違いありません。なお、決してNeon使っていない一例が、目の前にある

NVIDIA Jetson Nano

です。流石にNVIDIAは自社のGPUを集積しているので、CPUはArmですが、Neonは積んでいません。さて、本題に戻ります。

プロの人が書いたライブラリ関数がNeon使っている

ならば、それにお任せするのが妥当だと思います。デバッグ済でチューニング済、わざわざ独自に同じことをやることもないでしょう。当然ですが「ちょっと変わったことをやりたい場合」そうは問屋が卸しません。そういう場合、いくつかのチョイスがあります。

  1. コンパイラにNEONのコードを吐き出してもらう
  2. コンパイラ・イントリンシックスを用いてプログラムする
  3. アセンブラ(インラインアセンブラ含む)で書く

数字の小さい方が労力すくなく、数字が大きい方が労力大なのはお分かりでしょう。当然、いろいろ「芸」を施せるのは後の方になります。そうはいいつつ、Neonに不慣れな私としては、まずは1からやって行きたいと思います。これは簡単。Arm純正のコンパイラだけでなく、gccもNeonをサポートしているので、コンパイルするときにNeon使ってやってね、とお願いするだけです。こんな感じ。

$ gcc -o neon_sample0 -O3 -mfpu=neon -ftree-vectorize neon_sample0.c

「お願い」のオプションは以下の2つ

-mfpu=neon

-ftree-vectorize

<訂正>この回の設定であると、SIMD命令は生成されまへん。次回を待て?

さて、それでどんなCのコードをNeon化(SIMD化)してもらったかというと、以下のような簡単なコードです。

void vector_mul(const int vlen, float* __restrict x, float* __restrict y, float* __restrict result) {
        for(int i = 0; i < vlen; i++) {
                result[i] = x[i] * y [i];
        }
}

ベクトルの要素毎の積。ま、後で加算(縮約)入れて、内積計算にする意図はアリアリですが、現状は要素毎の積でしかありませぬ。ここで唯一「普通」と違うのは、関数引数のポインタに

__restrict

なる修飾子を加えていること。これは、コンパイラに「安心して」ベクトル化してもらうため。ポインタが指す先が重ならないようにしておけばこの修飾子はOKと。そこで、計算すべき値は以下の大域変数に置いておくことにしました。

#define VEC_MAX (100000)

float a[VEC_MAX];
float b[VEC_MAX];
float c[VEC_MAX];

領域だけは、10万個もとってありますが、今回はテストなのでわずかに12要素で実行させてみました。当然、上記の関数を呼び出す前に、入力にする配列aとbに乱数詰め込んだりして準備をし、上記の関数を呼び出して、処理が終わったら結果を印字という塩梅です。

$ ./neon_sample0 12

vector length : 12
0.840188,0.394383,0.331356
0.783099,0.798440,0.625258
0.911647,0.197551,0.180097
0.335223,0.768230,0.257528
0.277775,0.553970,0.153879
0.477397,0.628871,0.300221
0.364784,0.513401,0.187281
0.952230,0.916195,0.872428
0.635712,0.717297,0.455994
0.141603,0.606969,0.085948
0.016301,0.242887,0.003959
0.137232,0.804177,0.110358

ちゃんと計算しているようです。こちらの興味は、実際に生成されているNeonのコードにあるので、例によって objdumpしてみればこんな感じ。

<訂正>Vのつく命令生成されていますが、よく見りゃsXXみたいなレジスタ名。これは、SIMDならぬ、ベクトルレジスタといっても下1個分、32ビット幅での使用。

0001051c <vector_mul>:
   1051c:	e3500000 	cmp	r0, #0
   10520:	d12fff1e 	bxle	lr
   10524:	e0810100 	add	r0, r1, r0, lsl #2
   10528:	ecf17a01 	vldmia	r1!, {s15}
   1052c:	ecb27a01 	vldmia	r2!, {s14}
   10530:	e1500001 	cmp	r0, r1
   10534:	ee677a87 	vmul.f32	s15, s15, s14
   10538:	ece37a01 	vstmia	r3!, {s15}
   1053c:	1afffff9 	bne	10528 <vector_mul+0xc>
   10540:	e12fff1e 	bx	lr

vmul.f32という命令が、まさにNeonでなくVFPというべき、掛け算している本体。前後にあるvのつく命令 vldmiaは引数のロード、vstmiaは引数のストア、ちゃんとgccが「ベクトル化(ここではSIMD化)」してくれているのが分かります。<=SIMDじゃない!

ご参考にcのコードの全体も掲げておきます。

#include <stdio.h>
#include <stdlib.h>

#define VEC_MAX (100000)

float a[VEC_MAX];
float b[VEC_MAX];
float c[VEC_MAX];

void vector_mul(const int vlen, float* __restrict x, float* __restrict y, float* __restrict result) {
    for(int i = 0; i < vlen; i++) {
        result[i] = x[i] * y [i];
    }
}

void init_vector(const int vlen) {
    for(int i = 0; i < vlen; i++) {
        a[i] = (float)rand() / RAND_MAX;
        b[i] = (float)rand() / RAND_MAX
    }
}

void print_vector(const int vlen) {
    for(int i = 0; i < vlen; i++) {
        printf("%f,%f,%f\n", a[i], b[i], c[i]);
    }
}

int main(int argc, char *argv[]) {
    int vlen = 0;

    if (argc > 1) {
        vlen = atoi(argv[1]);
        vlen = (vlen > VEC_MAX) ? VEC_MAX : vlen;
    }
    printf("vector length : %d\n", vlen);

    init_vector(vlen);
    vector_mul(vlen, a, b, c);
    print_vector(vlen);

    return 0;
}

次回は、もう少しコンパイラにお願いする内容を増やしてみたいと思います。

(ちゃんと、NEONの64bit幅/128bit幅のレジスタ使いまする)

ぐだぐだ低レベルプログラミング(15) 変数アクセスのコードを眺めてみれば3 へ戻る

ぐだぐだ低レベルプログラミング(17) Arm NEONを使ってみる2 へ進む