C++のコンパイルとSIMDの確認

SIMDについて

SIMDとはSingle Instruction, Multiple Dataの略で、CPUの1サイクルで複数の命令を同時に実行する工夫である。なぜSIMDが必要であるか、SIMD化とは何かについては、以下のスライドを参照されたい。

SIMD化とは何か

以下では、実際にx86系の石でSIMDレジスタがどのように使われているかを確認してみる。その過程で、C++言語のコンパイルと実行、そしてアセンブリの確認をしよう。

プログラム作成とコンパイル

まずはsshで研究室サーバにログインせよ。Powershellでは問題が起きることが多いので、Windowsを使っている人はGit Bash等を利用すること。Macを使っている人はターミナルから接続せよ。ログインに成功したら、simdというフォルダを作成し、そこへ移動しよう。

mkdir simd
cd simd

次に、viでtest.cppというファイルを新規作成する。

vi test.cpp

すると、画面の下に

"test.cpp" [新ファイル]

という表示がされ、全画面になったはずだ。

さて、viの最大の特徴は「モード」を持つことだ。viには「ノーマルモード」と「編集モード」の二つのモードがあり、それを入れ替えながらファイルを編集する(他にもモードがあるが、深くは立ち入らない)。起動時には「ノーマルモード」になっており、そのままでは文字入力ができない。ここで「i」を押して編集モードに切り替えよう。

画面の下が-- 挿入 --という表示になったはずだ。この状態では、通常のエディタのように文字列を入力することができる。以下のプログラムを入力してみよう。

#include <cstdio>

int main(){
    printf("Hello World\n");
}

printではなくprintfと、最後のfが付くことに注意。これはprint format stringの略だ。

入力が終わったら、「ESC」キーを押すことで「ノーマルモード」に戻る。この状態でカーソルを動かしてみよう。ノーマルモードでは「hjkl」キーで移動する。「上下左右」が「kjlh」に対応する。カーソルキーでも移動させることは可能なのだが、ホームポジションから手を動かさなくて済むので慣れるとこちらの方が楽である。

さて、ノーマルモードで「:(コロン)」を入力すると、カーソルが画面一番下に移り、入力待ちになる(コマンドラインモード)。そこで「wq」と入力して、エンターキーを打つ。

「w」は「これまでの修正内容を保存せよ」という意味で、一般的なエディタのCtrl+Sにあたる。「q」は「エディタを終了せよ」という意味で、Windowsの一般的なエディタなら「Alt+F4」に対応する。

さて、これでtest.cppというファイルが作成されたはずだ。まずはlsで確認してみよう。

$ ls
test.cpp

次に、ファイルの中身をcatで確認しよう。これはconcatenate(連結する)の略だ。

$ cat test.cpp
#include <cstdio>

int main(){
  printf("Hello World\n");
}

このようにファイルの中身が表示されるはずだ。

ファイルが作成できたらコンパイルしよう。コンパイラはg++を使う。

g++ test.cpp

入力ミスをしていなければ、a.outというファイルが作成されたはずだ。

$ ls
a.out test.cpp

では、このプログラムを実行しよう。

$ a.out
bash: a.out: コマンドが見つかりませんでした...

エラーが出てしまった。コマンドには「パス」が通っていないとそのままでは実行することができない。パスの通っていないプログラムは、相対パス、もしくは絶対パスで実行する必要がある。ここでは相対パスを指定してみよう。

$ ./a.out
Hello World

無事に「Hello World」が表示されたことと思う。絶対パスも指定してみよう。

$ $HOME/simd/a.out
Hello World

こちらも実行できたと思う。相対パス、絶対パスが指定されていない場合、あらかじめ指定された「パス」を調べにいく。この「パス」を見てみよう。

$ echo $PATH

多数の文字列が表示されたことと思う。コマンドをパス無しで実行した場合、この「パス」のリストを順番に調べることになる。先ほどg++と、パス無しでプログラムが実行できたのは、パスのリストの中でこのファイルを見つけることができたからだ。見つけられた場所はwhichで調べることができる。

$ which g++
/usr/bin/g++

g++/usr/binの下にあることがわかった。同じファイルが複数のパスに存在する場合、最初に見つかったものが実行されるので覚えておこう。

ついでに、インテルコンパイラでのコンパイルも試しておこう。

$ rm a.out
$ icpc test.cpp
$ ./a.out
Hello World

やはり同じ結果が表示されるはずだ。

viのノーマルモードでの操作に慣れるため、もう少しだけVimを使ってみよう。またviでファイルを開いてみる。

vi test.cpp

ノーマルモードのまま、カーソルをprintfの行に移動させて、「yyp」と入力せよ。最後にエンターを入力するは必要ない。以下のように、カーソルのある行が複製されたのがわかるだろう。

#include <cstdio>

int main(){
  printf("Hello World\n");
  printf("Hello World\n");                                                    }
}

次に、そのままの状態で「dd」と入力せよ。これは「カーソルのある行を削除」する。これにより元に戻ったはずだ。

検索も体験してみよう。「/(スラッシュ)」を入力せよ。カーソルが一番下に移り、入力待ちになったはずだ。ここで「include」と入力してエンターキーを押してみよ。「include」がハイライトされたはずだ。ハイライトを消すには「:」を押してコマンドモードに入り、「noh」と入力すること。

また、viの終了は「:wq」でも可能だが、ただ「保存して終了」したい場合は「ZZ(大文字のZを二回)」でも可能だ。シフトキーを押しながらzを二回押せばよい。

アセンブリの表示

先ほど、g++ test.cppにより、コンパイラにソースファイルを食わせてコンパイルし、実行可能ファイルを作成した。我々から見るとソースファイルが実行可能ファイルに変換されたように見えるが、実は裏でコンパイラは様々なことをしている。コンパイラは、プログラムをまずアセンブリと呼ばれる言語に変換し、その後必要なルーチンをリンカが「リンク」することで実行可能ファイルが作成される(他にもいろいろなことをしているのだが、ここでは触れない)。以下では、アセンブリを見てみることにしよう。

アセンブリとは、「機械語」に、ほぼ一対一対応するプログラム言語だ。機械語は数字の羅列であり、人間には読みづらいので、それを読みやすくしたのが「アセンブリ」だと思っておけばとりあえず問題ない。

コンパイラに-Sオプションをつけてコンパイルしてみよう。大文字のSであることに注意。

g++ -S test.cpp

すると、同じディレクトリにtest.sというファイルが作成されるので、viで中身を見てみよう。中身はこんなファイルだ。

    .file   "test.cpp"
    .section    .rodata
.LC0:
    .string "Hello World"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $.LC0, %edi
    call    puts
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-36)"
    .section    .note.GNU-stack,"",@progbits

デフォルトではデバッグ情報等が入っていて読みづらいが、それらを除いた本質はこれだけだ。

main:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $.LC0, %edi
        call    puts
        movl    $0, %eax
        popq    %rbp
        ret

これがmain関数に対応するアセンブリだ。pushqpopqは関数呼び出しのために必要な操作であり、それらも除くと、以下だけである。

        movl    $.LC0, %edi
        call    puts

最初は、「ラベル$.LC0のアドレスをediというレジスタにコピーせよ」という意味で、次でputsという関数を呼び出している。putsediが指すアドレスの文字列を表示する関数である。このラベルを見てみよう。

.LC0:
    .string "Hello World"
    .text
    .globl  main
    .type   main, @function

文字列として「Hello World」が記載されている。ここを修正してみよう。「World」の先頭にカーソルを合わせて、「dw」と入力せよ。これは一単語削除する命令だ。「World」が消えるので「i」を押して挿入モードにしたら、「Assembly」と書いてエスケープキーを押し、「ZZ」で終了しよう。

.LC0:
    .string "Hello Assembly"
    .text
    .globl  main
    .type   main, @function

この状態でアセンブリを実行可能ファイルにしてみよう。g++test.sを食わせて見る(test.cppではないことに注意)。実行可能ファイルができるので、実行して、メッセージが変わっていることを確認しよう。

$ g++ test.s
$ ./a.out
Hello Assembly

アセンブリの修正が簡単だ、ということがわかったっだろうか。

部分コンパイル

C/C++言語は、部分コンパイルができる。部分コンパイルとは、大きなプログラムを複数の「部分」に分けてコンパイルすることで、大きなプログラムを一気にコンパイルするのに比べて、更新部分のみコンパイルできるためにコンパイル時間が短くなる。

以下では、小さなプログラムを組んで、そのアセンブリを見てみることにしよう。viでtest2.cppを作成せよ。

vi test2.cpp

入力内容は以下の通り。

double func(double a, double b){
  return a+b;
}

入力できたら、コンパイラに食わせてアセンブリを出力しよう。

g++ -S -O2 test2.cpp

デフォルトの出力はごちゃごちゃして見づらいので、-O2オプションで最適化をかけている。オプションは大文字のO(オー)であることに注意。アセンブリを見てみよう。

_Z4funcdd:
        addsd   %xmm1, %xmm0
        ret

不要部分を除くと、関数funcの中身はこれだけだ。内容は「xmm1レジスタとxmm0レジスタを足して、結果をxmm0レジスタに格納せよ」だけである。

関数funcの引数はabであったが、それは「それぞれxmm0レジスタとxmm1レジスタに入れて呼び出される」という決まりになっている。また、returnによる返り値は、「倍精度実数を返す場合はxmm0に値を入れる」と決まっている。このような決まりをApplication Binary Interface (ABI)と呼ぶ。

倍精度実数は64ビットなのだが、xmmレジスタは128ビットのSIMDレジスタになっている。通常、多くのCPUでは64ビット浮動小数点用のレジスタを備えているのだが、x86系のCPUでは、歴史的な事情から128ビットのレジスタを64ビットの演算にも用いることになっている(80ビットを用いるx87命令というものもあるが、ここでは触れない)。

xmmレジスタは128ビットだが、さらに幅を広げたレジスタがymmレジスタであり、256ビットある。これを見てみよう。

以下の内容をtest3.cppとして作成せよ。

#include <x86intrin.h>

__m256d func(__m256d a, __m256d b){
  return a+b;
}

これを、通常のオプションでコンパイルすると「__m256dなんて知らないよ」とエラーになる。

$ g++ -O2 -S test3.cpp
test3.cpp:3:1: エラー: ‘__m256d’ does not name a type
 __m256d func(__m256d a, __m256d b){
 ^

そこで、コンパイラに「我々は AVX2命令セットを使うよ」と教えてあげよう。そのためには-mavx2を指定する。

$ g++ -O2 -S -mavx2 test3.cpp

アセンブリを見てみるとこうなっている。

_Z4funcU8__vectordS_:
        vaddpd  %ymm1, %ymm0, %ymm0
        ret

ymmレジスタが使われていることがわかる。ここでは256ビットの演算、すなわち4つの浮動小数点演算が行われている。

先ほど、「普通の浮動小数点演算」でも128ビットのxmmレジスタが使われていた。では128ビットSIMD演算をしてみたらどうなるだろうか?

以下の内容でtest4.cppを作成せよ。

#include <x86intrin.h>
__m128d func(__m128d a, __m128d b){
  return a+b;
}

コンパイルしてアセンブリを見てみよう。

g++ -O2 -S test4.cpp

関数funcの中身はこうなっている。

_Z4funcU8__vectordS_:
        addpd   %xmm1, %xmm0
        ret

やはりxmmレジスタの足し算が行われているが、先ほどはaddsdという命令だったが、こんどはaddpdという命令に変わっている。addsdはxmmレジスタの「下位64ビットを足しなさい」という命令で、addpdは「下位と上位64ビットをそれぞれ足しなさい」というSIMD命令になっている。