C++のコンパイルとSIMDの確認
SIMDについて
SIMDとはSingle Instruction, Multiple Dataの略で、CPUの1サイクルで複数の命令を同時に実行する工夫である。なぜSIMDが必要であるか、SIMD化とは何かについては、以下のスライドを参照されたい。
以下では、実際にx86系の石でSIMDレジスタがどのように使われているかを確認してみる。その過程で、C++言語のコンパイルと実行、そしてアセンブリの確認をしよう。
プログラム作成とコンパイル
まずはsshで研究室サーバにログインせよ。Powershellでは問題が起きることが多いので、Windowsを使っている人はGit Bash等を利用すること。Macを使っている人はターミナルから接続せよ。ログインに成功したら、simd
というフォルダを作成し、そこへ移動しよう。
次に、viでtest.cpp
というファイルを新規作成する。
すると、画面の下に
という表示がされ、全画面になったはずだ。
さて、viの最大の特徴は「モード」を持つことだ。viには「ノーマルモード」と「編集モード」の二つのモードがあり、それを入れ替えながらファイルを編集する(他にもモードがあるが、深くは立ち入らない)。起動時には「ノーマルモード」になっており、そのままでは文字入力ができない。ここで「i」を押して編集モードに切り替えよう。
画面の下が-- 挿入 --
という表示になったはずだ。この状態では、通常のエディタのように文字列を入力することができる。以下のプログラムを入力してみよう。
print
ではなくprintf
と、最後のfが付くことに注意。これはprint format string
の略だ。
入力が終わったら、「ESC」キーを押すことで「ノーマルモード」に戻る。この状態でカーソルを動かしてみよう。ノーマルモードでは「hjkl」キーで移動する。「上下左右」が「kjlh」に対応する。カーソルキーでも移動させることは可能なのだが、ホームポジションから手を動かさなくて済むので慣れるとこちらの方が楽である。
さて、ノーマルモードで「:(コロン)」を入力すると、カーソルが画面一番下に移り、入力待ちになる(コマンドラインモード)。そこで「wq」と入力して、エンターキーを打つ。
「w」は「これまでの修正内容を保存せよ」という意味で、一般的なエディタのCtrl+Sにあたる。「q」は「エディタを終了せよ」という意味で、Windowsの一般的なエディタなら「Alt+F4」に対応する。
さて、これでtest.cpp
というファイルが作成されたはずだ。まずはls
で確認してみよう。
次に、ファイルの中身をcat
で確認しよう。これはconcatenate
(連結する)の略だ。
このようにファイルの中身が表示されるはずだ。
ファイルが作成できたらコンパイルしよう。コンパイラはg++
を使う。
入力ミスをしていなければ、a.out
というファイルが作成されたはずだ。
では、このプログラムを実行しよう。
エラーが出てしまった。コマンドには「パス」が通っていないとそのままでは実行することができない。パスの通っていないプログラムは、相対パス、もしくは絶対パスで実行する必要がある。ここでは相対パスを指定してみよう。
無事に「Hello World」が表示されたことと思う。絶対パスも指定してみよう。
こちらも実行できたと思う。相対パス、絶対パスが指定されていない場合、あらかじめ指定された「パス」を調べにいく。この「パス」を見てみよう。
多数の文字列が表示されたことと思う。コマンドをパス無しで実行した場合、この「パス」のリストを順番に調べることになる。先ほどg++
と、パス無しでプログラムが実行できたのは、パスのリストの中でこのファイルを見つけることができたからだ。見つけられた場所はwhich
で調べることができる。
g++
は/usr/bin
の下にあることがわかった。同じファイルが複数のパスに存在する場合、最初に見つかったものが実行されるので覚えておこう。
ついでに、インテルコンパイラでのコンパイルも試しておこう。
やはり同じ結果が表示されるはずだ。
viのノーマルモードでの操作に慣れるため、もう少しだけVimを使ってみよう。またviでファイルを開いてみる。
ノーマルモードのまま、カーソルをprintf
の行に移動させて、「yyp」と入力せよ。最後にエンターを入力するは必要ない。以下のように、カーソルのある行が複製されたのがわかるだろう。
次に、そのままの状態で「dd」と入力せよ。これは「カーソルのある行を削除」する。これにより元に戻ったはずだ。
検索も体験してみよう。「/(スラッシュ)」を入力せよ。カーソルが一番下に移り、入力待ちになったはずだ。ここで「include」と入力してエンターキーを押してみよ。「include」がハイライトされたはずだ。ハイライトを消すには「:」を押してコマンドモードに入り、「noh」と入力すること。
また、viの終了は「:wq」でも可能だが、ただ「保存して終了」したい場合は「ZZ(大文字のZを二回)」でも可能だ。シフトキーを押しながらzを二回押せばよい。
アセンブリの表示
先ほど、g++ test.cpp
により、コンパイラにソースファイルを食わせてコンパイルし、実行可能ファイルを作成した。我々から見るとソースファイルが実行可能ファイルに変換されたように見えるが、実は裏でコンパイラは様々なことをしている。コンパイラは、プログラムをまずアセンブリと呼ばれる言語に変換し、その後必要なルーチンをリンカが「リンク」することで実行可能ファイルが作成される(他にもいろいろなことをしているのだが、ここでは触れない)。以下では、アセンブリを見てみることにしよう。
アセンブリとは、「機械語」に、ほぼ一対一対応するプログラム言語だ。機械語は数字の羅列であり、人間には読みづらいので、それを読みやすくしたのが「アセンブリ」だと思っておけばとりあえず問題ない。
コンパイラに-S
オプションをつけてコンパイルしてみよう。大文字のSであることに注意。
すると、同じディレクトリに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
やpopq
は関数呼び出しのために必要な操作であり、それらも除くと、以下だけである。
最初は、「ラベル$.LC0
のアドレスをedi
というレジスタにコピーせよ」という意味で、次でputs
という関数を呼び出している。puts
はedi
が指すアドレスの文字列を表示する関数である。このラベルを見てみよう。
文字列として「Hello World」が記載されている。ここを修正してみよう。「World」の先頭にカーソルを合わせて、「dw」と入力せよ。これは一単語削除する命令だ。「World」が消えるので「i」を押して挿入モードにしたら、「Assembly」と書いてエスケープキーを押し、「ZZ」で終了しよう。
この状態でアセンブリを実行可能ファイルにしてみよう。g++
にtest.s
を食わせて見る(test.cpp
ではないことに注意)。実行可能ファイルができるので、実行して、メッセージが変わっていることを確認しよう。
アセンブリの修正が簡単だ、ということがわかったっだろうか。
部分コンパイル
C/C++言語は、部分コンパイルができる。部分コンパイルとは、大きなプログラムを複数の「部分」に分けてコンパイルすることで、大きなプログラムを一気にコンパイルするのに比べて、更新部分のみコンパイルできるためにコンパイル時間が短くなる。
以下では、小さなプログラムを組んで、そのアセンブリを見てみることにしよう。viでtest2.cpp
を作成せよ。
入力内容は以下の通り。
入力できたら、コンパイラに食わせてアセンブリを出力しよう。
デフォルトの出力はごちゃごちゃして見づらいので、-O2
オプションで最適化をかけている。オプションは大文字のO(オー)であることに注意。アセンブリを見てみよう。
不要部分を除くと、関数func
の中身はこれだけだ。内容は「xmm1レジスタとxmm0レジスタを足して、結果をxmm0レジスタに格納せよ」だけである。
関数func
の引数はa
とb
であったが、それは「それぞれ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
として作成せよ。
これを、通常のオプションでコンパイルすると「__m256dなんて知らないよ」とエラーになる。
$ g++ -O2 -S test3.cpp
test3.cpp:3:1: エラー: ‘__m256d’ does not name a type
__m256d func(__m256d a, __m256d b){
^
そこで、コンパイラに「我々は AVX2命令セットを使うよ」と教えてあげよう。そのためには-mavx2
を指定する。
アセンブリを見てみるとこうなっている。
ymmレジスタが使われていることがわかる。ここでは256ビットの演算、すなわち4つの浮動小数点演算が行われている。
先ほど、「普通の浮動小数点演算」でも128ビットのxmmレジスタが使われていた。では128ビットSIMD演算をしてみたらどうなるだろうか?
以下の内容でtest4.cpp
を作成せよ。
コンパイルしてアセンブリを見てみよう。
関数func
の中身はこうなっている。
やはりxmmレジスタの足し算が行われているが、先ほどはaddsd
という命令だったが、こんどはaddpd
という命令に変わっている。addsd
はxmmレジスタの「下位64ビットを足しなさい」という命令で、addpd
は「下位と上位64ビットをそれぞれ足しなさい」というSIMD命令になっている。