メイン

xbyak アーカイブ

2007年08月06日

LL魂2007デモコード

LL魂2007(LLSpirit)のライトニングトークで発表したときに使ったデモのコード(Python+Xbyak)を公開します.

download

使い方は添付のreadme.txtを参照してください.
ベンチマーク結果をあげておきます.

速度比(小さいほど速い)
ループ回数 実行時間(sec) 処理時間比(Xを1とする)
Xbyak(可変) 100000 12.14 1
python(可変) 100 10.73 894
psyco(可変) 1000 40.8 340
python(固定) 1000 42.55 354
psyco(固定) 10000 32.91 27.4
C++(固定) 100000 13.94 1.16
修正python* + Xbyak 100000 8.75 0.72(参考値)

psycoはPythonのJIT実行環境です.Xbyakはこれの特別版と考えればよいでしょう.psycoは何もしなくても速くなりますが,Xbyakは全て自分でコードを書く必要があります.

今回のベンチマークの目的は,関数を文字列として与えられたときに,それを実行時に評価(eval)する速度比を測ることです.Xbyakやpython(可変), psyco(可変)がそれにあたります.
それに対して,evalがどれぐらい重いのかを見るために,同時に関数を文字列でなく本来の関数として定義した場合の速度も測りました.それがpython(固定)やpsyco(固定),およびC++(固定)で作ったものです.

見てのとおり,可変と固定のハンディをものともせずXbyakがトップです.実は100倍どころの速度比ではありません.通常最速と思われるC++(固定)よりも速かったのは意外です.ただし,これはXbyakでSSE2を使ったためと思われます(でないと勝てるわけがない).

またpsyco(固定)の速度向上も目を見張るものがあります.何もせずにこれだけ速くなるのはたいしたものです.

# Pythonのarrayが16byte alignmentしてくれればXbyak版ももっと速くできる*のですが….

なお,コードを見ればわかりますが,Xbyak版はかなり手抜き(100行程度だし)であること,また一般的に上記のような性能向上が常に得られるとは限りませんが,ピンポイントで効果的に使えればかなりの破壊力があることは分かると思います.

コード内容については後日簡単に説明する予定です.

*おまけ(毒を食らわば皿まで)
Python本体ソースのmalloc関数たちを_aigned_mallocに変更してリコンパイルし,xbyak側のメモリアクセスをalignmentされていないときでも動くmovupsからalignment前提のmovapsに変更すると12sec => 8.75secとなり,効果があることが分かりました.

2007年09月26日

Xbyakで始めるx86(IA-32)入門

ここでは何回かに分けてx86(IA-32),いわゆる普通のPentiumパソコンで使われている機械語の説明をする予定です.
アセンブラ,アセンブリ言語としては拙作のXbyakを使うことにしました.
理由は,

  • 普通のgasやNASMによる解説はありふれていること
  • Windows,Linux,Intel Macで同じソースが使え,C++だけで閉じているためアセンブラの設定に悩まなくてすむこと
  • JITなどの特殊な最適化ができること

などがあります(半分は自己満足ですね).

内容は基本的なところから始めますが,場合によってはマニアックネタに走るかもしれません.最初のうちはx86アセンブリ言語入門と重複することも多いと思います.

以下は単なる私の価値観ですが,機械語を絶対に知っておくべきものであるとは思いません.けれども何事もかじってみるのは悪くないと思います.
必要だから勉強する,不要だから勉強しないという合理的な判断は好きではありません.知りたい,興味があるから勉強してみるという素朴な動機が大事だと思います.

閑話休題

まずXbyakをインストールしましょう.XbyakはIntel,AMDなどのPentium互換CPUが搭載されたWindows Xp,Vista,Linux,Macの32bit OSで動作し,開発にはC++コンパイラを使います.Visual C++ 6 + STLport(必須),Visual Studio 2005 Professional/Expression, Linux gcc 4.x, Intel Macなどで動作確認をしています(もしかしたらsecure OSではセキュリティレベルを下げないと動かないかもしれません).
zipファイルを上記ページからダウンロードしてきて展開します.

VC系の場合はxbyak.dswを開いてtest0をコンパイル,Linux,Macの場合はmakeでサンプルプログラムができます.

>./test0
xbyak version=1.06
0 + ... + 0 = 0
0 + ... + 1 = 1
0 + ... + 2 = 3
0 + ... + 3 = 6
0 + ... + 4 = 10
0 + ... + 5 = 15
0 + ... + 6 = 21
0 + ... + 7 = 28
0 + ... + 8 = 36
0 + ... + 9 = 45
0 + ... + 10 = 55
0 + 0 = 0
1 + 1 = 2
2 + 2 = 4
3 + 3 = 6
4 + 4 = 8
5 + 5 = 10
6 + 6 = 12
7 + 7 = 14
8 + 8 = 16
9 + 9 = 18
call atoi("123") = 123
jmp atoi("456") = 456
OK

の様に表示されればOKです.もし,ビルドエラーになる,動かないなどがあれば環境とエラー内容を教えていただけるとありがたいです.

インストールするにはLinux,Macの場合はrootになって

make install

とすると/usr/local/include/xbyakにインストールされます.

WindowsのVisual Studio 6の場合は[オプション]→[ディレクトリ],Visual Studio 2005 Expressionなどの場合は[ツール]→[オプション]→[プロジェクトおよびソリューション]→[VC++ディレクトリ]→[インクルードファイル]でincludeディレクトリに展開したxbyakディレクトリを追加してください.

サンプルプログラムについて

test0
1から10までの和や,足し算,atoi()の呼び出しテストです.
quantize
JPEGやMPEGで使われる量子化のサンプルです.起動して1~100の数値を入力してください.最初にC版,次にxbyakによる演算時間が表示されます.
calc
簡単な関数計算デモです.引数に"x*(x+1)"などを与えて実行してください.このデモのコンパイルにはboostのインストールが必要です.
bf
BrainfuckJIT環境です../bf hello.bfとしてください.

今回はXbyakのインストール方法について説明しました.次回はサンプルプログラムの説明などをする予定です.

2007年09月28日

Xbyakで始めるx86(IA-32)入門(2-1)

前回言い忘れましたが,このシリーズの目標はLL魂2007デモコードのchaos.cpp程度のものを読めて理解できることを考えています.
具体的には

  • レジスタやスタックを理解する
  • 関数を作れる
  • SIMD命令の基礎を知る
  • JITのメリットを理解する

あたりを目指します.なお,JITアセンブラは通常のアセンブラに比べてワンクッション概念が必要になります.もしかしたらアセンブリ言語の本当の入門としては不適切かもしれませんが,まあご了承ください.

それでは,最初に他の言語と同様,"Hello Xbyak!"を表示してみましょう.

#include "xbyak/xbyak.h"
#include <stdio.h>
 
struct HelloGenerator : public Xbyak::CodeGenerator { // (A)
    HelloGenerator() // (B)
    {
        push((int)"Hello Xbyak!"); // (C1)
        call((int)puts); // (C2)
        add(esp, 4); // (C3)
        ret(); // (C4)
    }
};
 
int main()
{
    HelloGenerator hello; // (D)
    void (*code)() = (void (*)())hello.getCode(); // (E)
    code(); // (F)
    return 0;
}

VC系ではコンソールアプリとしてコンパイルしてください.
gccでは-fno-operator-namesをつけるのを忘れないでください.これはandやorを演算子ではなく関数として扱いたいためです.

> g++ t.cpp -g -fno-operator-names
> ./a.out
> Hello Xbyak!

# C++的には

#include <stdio.h>
int main(int argc, char *argv[])
{
    argc == 1 and puts("need option");
}

ってできたのご存じでした?

それはともかくコードの説明に入ります.Xbyakでは実行時に命令をアセンブルしてバイトコード(以下マシン語と呼ぶことにします)を生成します.それを生成するためのクラスがXbyak::CodeGeneratorであり,これを継承する(A)ことでそのクラス内でx86の殆どの命令を記述できるようになります.

今後も繰り返すことになるかと思いますが,コンパイル時ではなく実行時であることに注意してください.ここではHelloGeneratorのコンストラクタにXbyak命令(面倒なので以後asmと呼びます)を記述している(B)ので,main()内のインスタンスhelloが生成されたとき(D)にアセンブルされます.

アセンブルされたマシン語はgetCode()メソッドで取得できます(E).この返り値は生成されたマシン語へのポインタですので,適当な関数ポインタ(詳細は後述)にキャストし,その関数を呼び出すことでマシン語を実行します(F).

以上がXbyakを使った場合の大まかな流れです.

続いて実行時の挙動を追いかけてみましょう.

VC系ではDebugモードでコンパイルし,(F)のところにブレークポイントを置いてデバッグ実行します.停止したところでVC6なら[表示]→[デバッグウィンドウ]→[混合モード],VC8なら[デバッグ]→[ウィンドウ]→[逆アセンブル]で

19:       code();
00401696 8B F4                mov         esi,esp
00401698 FF 95 F4 FE FF FF    call        dword ptr [ebp-10Ch]
0040169E 3B F4                cmp         esi,esp
004016A0 E8 2B 14 01 00       call        __chkesp (00412ad0)

というようなウィンドウに入ってください.[F10]を一度押し,callのところで[F11]を押すと

003730A4 68 B0 01 44 00       push        offset string "Hello Xbyak!" (004401b0)
003730A9 E8 62 FA 09 00       call        puts (00412b10)
003730AE 83 C4 04             add         esp,4
003730B1 C3                   ret

が表示されると思います.左端の数値は違うかもしれません.意味も今は分からなくて結構です.ただ上記サンプルの(C1)~(C4)に対応していることを確認してください.

gccの場合はgdbを使っておっかけましょう.まず起動してgetCode()のところでブレークポイントを置きます.

>gdb ./a.out
>b HelloGenerator.getCode
Breakpoint 1 at 0x804b2b6: file xbyak/xbyak.h, line 1307.

実行した後,[n]と[Enter]でステップ実行し,code()の直前まで進みます.

(gdb) r
Starting program: /home/shigeo/Program/xbyak/a.out
Breakpoint 1, Xbyak::CodeGenerator::getCode (this=0xbffff2e0) at xbyak/xbyak.h:1307
1307                    assert(!hasUndefinedLabel());
(gdb) n
1309                    return top_;
(gdb)([Enter]を押す)
1310            } ([Enter]を押す)
(gdb) ([Enter]を押す)
main () at t.cpp:19
19              code();

ここでコードがどうなっているか見ます.その前にデバッグに見やすいaliasを作っておきます.

(gdb) define al
Type commands for definition of "al".
End with a line saying just "end".
>x/11i $pc
>end

と定義してみてください.

(gdb) al
0x8048904 <main+52>:    mov    eax,DWORD PTR [ebp-12]
0x8048907 <main+55>:    call   eax
0x8048909 <main+57>:    mov    ebx,0x0
0x804890e <main+62>:    lea    eax,[ebp-0x108]
0x8048914 <main+68>:    mov    DWORD PTR [esp],eax
0x8048917 <main+71>:    call   0x8049c2c <~HelloGenerator>
0x804891c <main+76>:    mov    DWORD PTR [ebp-0x10c],ebx
0x8048922 <main+82>:    jmp    0x8048952 <main+130>
0x8048924 <main+84>:    mov    DWORD PTR [ebp-0x110],eax
0x804892a <main+90>:    mov    ebx,DWORD PTR [ebp-0x110]
0x8048930 <main+96>:    lea    eax,[ebp-0x108]

siを使ってasmレベルでのステップ実行をします.

(gdb) si
0x08048907      19              code();
(gdb) ([Enter]を押す)
0x0804c190 in ?? ()

call eaxが実行された瞬間からXbyakが生成したマシン語に突入しています.alで確認しましょう.

(gdb) al
0x804c194:      push   0x804b44b
0x804c199:      call   0x8048734 <puts@plt>
0x804c19e:      add    esp,0x4
0x804c1a1:      ret

やはり上記サンプルの(C1)~(C4)に対応していることを確認してください.
うっかり行き過ぎたら,最初からやり直してマシン語を一行ずつ実行する感じをつかんでください.gdbを使うときはアセンブラで遊ぶ時に便利なgdb設定を参考にすると便利だと思います.上記alもこのリンク先の.gdbinitで定義されているのを拝借しました.

今回はXbyakの基本的な書き方と実行時に確認する方法の説明をしました.長くなりましたので一端区切ります.次はコードの説明に入ります.

2007年10月01日

Xbyakで始めるx86(IA-32)入門(2-2)

前置きが長くなりましたが,x86(IA-32)について説明を始めます.他のCPUでも概ね似たようなものですが,アセンブリ言語(asm)を記述するときに最低限必要な知識は概ね次の四つです.

  • アドレス
  • レジスタ
  • インストラクションポインタ
  • スタック

順次説明します.

アドレス

メモリを読み書きするには場所を指定する必要があります.その場所のことをアドレスといいます.32bitOSでは通常32bitの絶対値(0~4294967295)で指定します.主にC/C++におけるstaticな変数やglobalな変数を扱うときに利用します.仮想メモリや物理メモリなどの話はとりあえず無視してかまいません.必要だと思われたときに勉強してください.

レジスタ

CPU内部で利用できる変数のことです.32bit整数を格納する汎用レジスタ,浮動小数を格納するFPUレジスタ,SIMD命令で扱われるMMX/SSEレジスタなどがあります.このうち汎用レジスタが最低限必要なものです.汎用レジスタは8個あり,eax, ebx, ecx, edx, esi, edi, ebp, espと名前も役割も決まっています.当面はこの8個のレジスタのみを意識するだけで十分です.というか8個しかありません.通常の言語では変数名は自由につけられ,しかも好きなだけ使えるのとは大違いです.

インストラクションポインタ

CPUがまさに今実行しているアドレスです.命令を実行するごとに自動的に次の命令が格納されているアドレスを指すようになります.条件分岐や関数呼び出しなどはインストラクションポインタを変更することで実現されます.

スタック

メモリの一部は,スタック形式(FILO:最初に格納したものは最後に取り出せるデータ形)で扱われる領域として定義されおり,その部分を指します.主にC/C++における局所変数を扱うときに利用されます.スタック領域はある特定値(環境によって異なります)から0に向かう方向に伸びます.0になってしまうとスタックを使い切りスタックオーバーフローとなります.そのスタック領域の先頭アドレスは汎用レジスタespに格納されています(多分Extended Stack Pointerの略).そのためスタックを操作するにはespを扱うことになります.

要はCPUがやっているのは,メモリをレジスタに読みこんで演算し,結果をメモリ/レジスタに格納する,結果によってインストラクションポインタを変更する.これだけです.アセンブリ言語で開発するということはこれらの手続きを一つ一つ丁寧に記述するということです.難しいのではなく,面倒なのですね.

さて,"Hello Xbyak!"を解説します.

    push((int)"Hello Xbyak!"); // (C1)
    call((int)puts); // (C2)
    add(esp, 4); // (C3)
    ret(); // (C4)

このプログラムはCでいうところの

void hello()
{
    puts("Hello Xbyak!");
}

と同じです(関数名は関係ないですがとりあえずhelloとします).

C1.

文字列"Hello Xbyak!"はメモリ上のstaticな領域に格納されています.これをputsの引数として与えるには,そのアドレスをスタック領域に保存します.なぜスタック領域に保存するのかについては今後説明します.とりあえずここでは関数の引数はスタックに格納する必要があることを覚えてください.

そして,データをスタック領域に保存するにはpush命令を使います.intにキャストしているのはXbyakの文法のせいです.つまりこの行は"Hello Xbyak!"が格納されたアドレスをスタック領域に確保することを意味します.スタックで述べたようにスタック領域は0に向かいますので,pushするとespの値が4(byte)減ります.デバッガでpushの前後でespの値が4減っていることを確認してください.

C2.

引数がスタック領域に確保にされたのでCのputs関数を呼び出します.Xbyakの文法上,intにキャストしています.この行が実行されるとコンソールに文字列が表示されていることをデバッガで確認してください.

C3.

C1でスタック領域に引数を渡したので,このままではスタック領域は4byte減ったままになります.スタックは貴重です.この関数ではもうこの領域は使いませんから開放する必要があります.espに4を足してスタック領域を基に戻しましょう.add esp, 4はC表記のesp += 4;と同じです.

C4.

hello()関数を抜けて元のmain()関数に戻ります.そのためにはret命令を使います.デバッガでこの命令を実行するとmainに戻ることを確認してください.

以下にgdbで追いかけたときのログを表示します.実際に自分でも実行してみると理解しやすいでしょう.

(gdb) al
0x804b190:      push   0x804a756
0x804b195:      call   0x8048710 <puts@plt>
0x804b19a:      add    esp,0x4
0x804b19d:      ret
(gdb) p $esp
$1 = (void *) 0xbffff2ac      // (*)
(gdb) si
0x0804b195 in ?? ()           // (C1)を実行した
(gdb) p $esp
$2 = (void *) 0xbffff2a8      // (*)に比べてespの値が4減っている
(gdb) ni                      // (C2)callを実行する
Hello Xbyak!                  // 文字列が表示された
0x0804b19a in ?? ()
(gdb) si
0x0804b19d in ?? ()           // (C3)を実行
(gdb) p $esp
$3 = (void *) 0xbffff2ac      // espの値が(*)に戻った
(gdb) ni
main () at t.cpp:19
19              return 0;

今回はアドレス,レジスタとスタック,puts("Hello Xbyak!")の中身を説明しました.次回はサンプルプログラムの説明を続けます.

2007年10月04日

x86入門(3) 関数の呼び出し規約

Cからasm,逆にasmからCを呼び出すために必要な手続きを呼び出し規約といいます.たとえば前回puts()を使う際に,文字列のポインタをpushしましたが,そういう手続きのことです.
x86での規約は大きく分けて3種類あり,細かい部分についてはコンパイラ依存であることも多く,注意が必要です.しかし,ほぼすべてのコンパイラで共通である規約は簡単で覚えやすく,しかも移植性が高くなります.計算処理などOSに依存しにくい部分ではその規約のみに従って記述すれば,Windows/Linux/Macで同一のソースとすることができメンテナンスもしやすくなります.

まずは基本の規約(cdecl)をしっかりと使いこなし,必要になってから他のその他の規約を学べばよいでしょう.詳細は呼出規約(Wikipedia)などを参考にしてください.拙文でも多少触れています.

汎用性の高い規約

Cとasmとの間で呼び出しあう関数は次の規約に従ってください.
  • C++での関数宣言にはextern "C"をつける.
  • 引数の型はvoid, int(32bit),またはポインタのみとする.構造体,浮動小数は扱わない.
  • 返り値はvoid, int,またはポインタのみとする.
このルールさえ守って記述すれば大抵のコンパイラやOSで問題なく動作させることができます.

たとえば

double func(double a, double b);

とするよりは

void func(double *out, const double *a, const double *b);

とすれば移植性が高くなるということです(もちろんasmとのやりとりが必要な部分のみです).原理的に後者の方がやや遅くなる可能性がありますが,高速化を目的とした処理では通常,関数内に大きなループが存在するため,この二つの差が問題になるケースはまずありません.逆に,問題になるぐらいならその前後の処理も含めてasmの対象したほうがよい可能性が高いです.

呼び出す側の規約

次に上記規約を満たす関数(func)があったときに,funcをasmから呼び出す手続きについて述べます.asmでは次の規約に従って関数を呼び出してください.
  • funcの引数を右側からpushする.
  • call funcする(Xbyakではcall(int(func));).
  • 引数の数 x 4だけespを大きくする.
  • 関数の返り値はeaxに入っている.void型の場合はeaxの値は不定.
たとえば
int func0(int a, char *b, double *c);

の場合は

push(ポインタcの値);
push(ポインタbの値);
push(aの値);
call(int(func0));
add(esp, 3 * 4); /* 引数が3個なので3 * 4 */

とします.引数がなければpushとaddは省略できます.たとえば

void func();

の場合は

call(int(func));

となります.

呼び出される側の規約

Cから呼び出される関数は次の規約に従ってください.
  • 返り値はeaxに代入する(返り値がvoidならeaxの値は不定).
  • 汎用レジスタのうちebx, esi, edi, ebp, espの値は関数を抜けるときに呼び出されたときの状態に戻す.ecx, edxの値を保存する必要はない.
  • ret();で関数から戻る.
このとき,関数の引数は「esp + 左から引数が何番目にあるか(1オリジン) * 4」のアドレスに格納されています.

関数の引数についてもう少し詳しく説明します.例としてfunc0()を考えましょう.

func0の呼び出し手続きに従ってfunc0が呼ばれたときのスタックの状況を考えます.c, b, aと順にpushされたのでスタックには大きい方から小さい方へc, b, aと並んでいるはずです.ということは

esp + 8 : c
esp + 4 : b
esp + 0 : a

なのでしょうか.実はちょっと違いまして,callを実行したとき,こっそりと実行していた命令の次の命令の先頭アドレスがスタックに格納されています.つまり正解は

esp + 12: c
esp + 8 : b
esp + 4 : a
esp + 0 : 次の命令のアドレス

となります.デバッガで確認してみましょう.

extern "C" void func(int a, int b, int c)
{
}
 
int main()
{
    func(1, 2, 3);
}

をfuncのところでステップ実行してみます.下記はcallを実行する直前でのレジスタとスタックの状況です.スタック領域に1, 2, 3が格納されていることを確認してください.

<asm>
00401B97   push        3
00401B99   push        2
00401B9B   push        1
00401B9D   call        @ILT+1400(func) (0040157d)
00401BA2   add         esp,0Ch
 
<レジスタ>
 EAX = CCCCCCCC EBX = 7FFDF000 ECX = 00000000 EDX = 00371538
 ESI = 00000000 EDI = 0012FF70
 EIP = 00401B9D ESP = 0012FADC EBP = 0012FF80 EFL = 00000212
 
<スタック>
0012FADC  01 00 00 00 02 00 00 00 03 00 00 00 00 00 00 00
0012FAEC  00 00 00 00 00 F0 FD 7F CC CC CC CC CC CC CC CC
0012FAFC  CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC

ここでcallを実行します.

<レジスタ>
 EAX = CCCCCCCC EBX = 7FFDF000 ECX = 00000000 EDX = 00371538
 ESI = 00000000 EDI = 0012FF70
 EIP = 00401B30 ESP = 0012FAD8 EBP = 0012FF80 EFL = 00000212
 
<スタック>
0012FAD8  A2 1B 40 00 01 00 00 00 02 00 00 00 03 00 00 00
0012FAE8  00 00 00 00 00 00 00 00 00 F0 FD 7F CC CC CC CC
0012FAF8  CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC

espの値が4減ってスタック領域にcallの次の命令のアドレス0x00401ba2が格納されたことがわかります.ret()でfunc()を抜けるときに,この値を参照して帰るべき場所を決定します.そのとき自動的にespも4増えています.

今回は関数の呼び出し規約について説明しました.納得するまで何度もデバッガで確認するのがよいと思います.

2008年03月28日

ηTペアリングの高速な実装

ペアリング暗号とは2000年頃に登場し,近年盛んに研究されている暗号で,ペアリングとはその暗号で使われる演算の名前です.
この暗号は現在まだ普及していませんが,既存のブロック暗号や,公開鍵暗号ではできないことも実現可能になるため,今後いろいろな分野で実用化されると想像されます.

ただ当初は演算処理が極めて重く,私も以前高速な実装を試みたりしていましたが,なかなか大変なものでした.
ところが数年前からかなり効率のよいアルゴリズムが考え出され,未踏ユースで挑戦してる人も現れ,今ではRSAと同じかそれ以上の速度が出るようにもなってきているようです.

とはいえ,ソースが公開されているものは殆ど見かけません.
というわけで,今回自分で一から開発した実装を公開しました.
速度優先のため,あまりきれいなものではありませんが,何かの参考になればと思います.
コードはESSE3を利用しているためCore 2 Duo専用ですが,Core 2 Duo 1.8GHzで288usecと世界最速レベルだと思います(今までの最速は400usec程度? 違ったらすいません).

実装の詳細についてはSEA & FSIJ 合同フォーラムでも多少触れる予定です.

2008年04月17日

SEA & FSIJ 合同フォーラムでの発表資料

2008/4/16にSEA & FSIJ 合同フォーラムでビット演算による最適化の妙味とJITアセンブラというタイトルで発表したときの資料を公開します(pptx, pdf).
鋭いご質問,ご指摘をいただきありがとうございました.
またいろいろ議論できたらと思います.

よろしくお願いします.

ビットリバースのところで,『ハッカーのたのしみ』(ヘンリー・S・ウォーレン,ジュニア)に短いアルゴリズムがあったよと教えていただいたのでチェックしました(昔にちらっと見ただけなので忘れてました).
それは

typedef unsigned int uint32;
 
#ifdef WIN32
uint32 bsr(uint32 x)
{
    __asm {
        bsr eax, x
    }
}
#else
 
#define bsr(x) __builtin_clz(x)
 
#endif
 
void put(uint32 x)
{
    for (int i = 0; i < 32; i++) {
        putchar((x & (1 << (31 - i))) ? '1' : '0');
    }
    printf("\n");
}
 
int main()
{
    uint32 x = 0;
    int len = 31;
    for (int i = 0; i < 32; i++) {
        put(x);
        uint32 s = 31 - bsr(~x);
        x = ((x << s) + (1 << len)) >> s;
    }
}

という感じのコードで,これはこれで面白いです.ただ今回のケースに限っては資料に書いた方法の方が命令数が短くて速いですね.

toyVMで遊ぶ

SEA & FSIJ 合同フォーラムでビット演算による最適化の妙味とJITアセンブラの中でデモに使ったVMを紹介します.
JITの紹介のために前日に2時間ででっちあげたVMなので本当に小さい(200行程度)ですが,エッセンスは楽しめるかなと思います.

ソースはXbyak.zipです.この中のxbyak/sample/toyvm.cppが今回作ったVMです(Win, Linuxと多分Intel Macでも動きます).
このサンプルはフィボナッチ数列を計算して表示するだけのものです.
ここではどのように作ったかの説明をします.一つ前のエントリの資料も参考にしてください.

話の流れ


  1. toyVMのスペック,命令セットと命令フォーマットを決める
  2. toyVMのアセンブラを作る
  3. toyVMの実行部分を作る
  4. toyVM用のフィボナッチ数列プログラムを作って実行する
  5. toyVMのマシン語をx86に変換するリコンパイラを作る
  6. パフォーマンスを見る
  7. リコンパイラを改良する


1. toyVMのスペックを決める


高性能なVMを作りたいかもしれませんが,そこは本質ではないのでざくっと簡単なものを考えます.
スタックベースかレジスタベースかなどの議論もあるのでしょうが,なんとなくレジスタベースにしました.

  • 32bitレジスタA, Bの二つ.あとPC(program counter)
  • メモリは4byte単位のみでのアクセス.4byte x 65536
  • すべての命令は4byte固定
  • 即値は全て16bit

スタックはばっさり捨てました.また命令長を4byte固定にすることで実行部が簡単になります.
またその結果必然的に即値は32bit未満となり,四つ目の条件をつけました.

命令群は次のものを用意しました.









命令(R = A or B)意味
vldiR, immR = imm
vldR, idx / vstR, idxR = mem[idx] / mem[idx] = R
vaddiR, imm / vsubiR immR += imm / R -= imm
vaddR, idx / vsubR, idxR += mem[idx] / R -= mem[idx]
vputRprint R
vjnzR, offsetif (R != 0) then jmp(PC += offset(signed))

メモリとレジスタの間の転送命令と加算,減算命令にレジスタの内容を出力する命令と,分岐命令だけです.
スタックが無いのでcall/retもありません.興味があれば自分で作ってみるのもよいかもしれません.
命令は4byteのうち先頭1byteに命令種別(code),次の1byteにレジスタ種別(r),最後の2byteに即値(imm)を入れることにします.
使われない場合は全て0にします.(code, r, imm)のペアと4byteデータの変換方法は次のようにします.簡単ですね.

void decode(uint32& code, uint32& r, uint32& imm, uint32 x)
{
    code = x >> 24;
    r = (x >> 16) & 0xff;
    imm = x & 0xffff;
}
void encode(Code code, Reg r, uint16 imm = 0)
{
    uint32 x = (code << 24) | (r << 16) | imm;
    code_.push_back(x);
}


2. toyVMのアセンブラを作る


アセンブラを作るといっても,外部ファイルに書いたものを読み込んでパースして,というのはまた大変なのでCの関数として作って関数を呼び出すことがアセンブルすること,としました.
そうすると,パーサをざっくりCコンパイラに任せられるので極めて簡単になります.上で定義したencode()を呼び出す関数を作れば終わりです.

void vldi(Reg r, uint16 imm) { encode(LDI, r, imm); }
void vld(Reg r, uint16 idx) { encode(LD, r, idx); }
void vst(Reg r, uint16 idx) { encode(ST, r, idx); }
void vadd(Reg r, uint16 idx) { encode(ADD, r, idx); }
void vaddi(Reg r, uint16 imm) { encode(ADDI, r, imm); }
void vsub(Reg r, uint16 idx) { encode(SUB, r, idx); }
void vsubi(Reg r, uint16 imm) { encode(SUBI, r, imm); }
void vjnz(Reg r, int offset) { encode(JNZ, r, static_cast(offset)); }
void vput(Reg r) { encode(PUT, r); }


3. toyVMの実行部分を作る


上で書き忘れましたが,アセンブルした結果はstd::vector code_;に格納させる実装にしました.
実行部というのはこのcode_内に入っている命令セットを順次呼び出して実行するだけのものになります.

void run()
{
    uint32 reg[2] = { 0, 0 }; // A, B
    const uint32 end = code_.size();
    uint32 pc = 0;
    for (;;) {
        uint32 code, r, imm;
        decode(code, r, imm, code_[pc]);
        switch (code) {
           ...
        }
        pc++;
        if (pc >= end) break;
    } // for (;;)
}

基本構造は上記のようになります.
pc(プログラムカウンタ)を0から順に増やしつつ,4byteずつcode_からデータを読みます.
読んだデータをdecode()でパラメータに分解し,codeに従って各命令を実行させるswitch文に突入します.
そのあとpcを一つ増やして繰り返します.

switch文の中身は各命令に対して実際行う処理を書きます.

switch (code) {
case LDI:
    reg[r] = imm;
    break;
case LD:
    reg[r] = mem_[imm];
    break;
case ST:
    mem_[imm] = reg[r];
    break;
case ADD:
    reg[r] += mem_[imm];
    break;
case ADDI:
    reg[r] += imm;
    break;
case SUB:
    reg[r] -= mem_[imm];
    break;
case SUBI:
    reg[r] -= imm;
    break;
case PUT:
    printf("%c %8d(0x%08x)\n", 'A' + r, reg[r], reg[r]);
    break;
case JNZ:
    if (reg[r] != 0) pc += static_cast<signed short>(imm);
    break;
default:
    assert(0);
    break;
}

とくに難しいところは無いでしょう.これでVM自体は完成です.なんと簡単.


4. toyVM用のフィボナッチ数列プログラムを作って実行する


VMを作ったのでその上で動かすプログラムを作ります.

フィボナッチと言えばちまたではやる再帰ですが,このVMにはスタックが無いのでそんなことはやってられません(苦笑).
#一応メモリはあるので,このスタックを実装することは可能かもしれませんが….
素直にループで書きます.for()を使うと分かりにくくなるのでgotoを使います.
まずCで書いてみましょう.

void fibC(uint32 n)
{
    uint32 p, c, t;
    p = 1;
    c = 1;
lp:
    t = c;
    c += p;
    p = t;
    n--;
    if (n != 0) goto lp;
    printf("c=%d(0x%08x)\n", c, c);
}

このコードが正しく動作することを確認したら,これをtoyVMのアセンブリ言語で書きます.
その前に変数をどう扱うかを決めておく必要があります.
toyVMにレジスタは二つありますが,両方をフィボナッチで使う変数に割り当てると困るのでとりあえずcをAレジスタに割り当てることにします.
Bはテンポラリに残しておきましょう.
あと,fibCにはp, t, nという変数があるのでこれらはtoyVMのメモリ上に置くことにします.
ここではmem[0] : p, mem[1] : t, mem[2] : nとしました.

ではfibCのアセンブリ言語版を書きます.

Fib(int n)
{
    vldi(A, 1); // c = 1
    vst(A, 0); // p = 1
    vldi(B, n);
    vst(B, 2); // n
// lp
    vst(A, 1); // t = c
    vadd(A, 0); // c += p
    vld(B, 1); // mem[1]の値をBを経由してmem[2]に移動する
    vst(B, 0); // p = t
    vld(B, 2);
    vsubi(B, 1);
    vst(B, 2); // n--
    vjnz(B, -8); // PCを8減らせばlpのところにもどる.
    vput(A);
}

ちょっと分かりにくいかもしれませんが,1行ずつfibCと比べれば同じ処理をしようとしていることがわかるでしょう.
実行してみます.

Fib fib(10);
fib.run();
>A      144(0x00000090)

正しく動作しているようです.


5. toyVMのマシン語をx86に変換するリコンパイラを作る


ここからが昨日の本題です.上記のFib(1000)を実行するとcode_上にVM用のマシン語が展開されて,run()で実行しているわけですが,そのマシン語をx86ネイティブなものに変換しましょう.
そうすればきっと高速に動作するようになるはずです.
recompile()のためにXbyakを使います.
まずtoyVMをx86上でどのように実装するかを考えます.レジスタはA, Bの二つなので適当なレジスタに割り当てましょう.
ここではA = esi, B = ediとしました.またmem_へのアクセスに使うレジスタをebxにします.
リコンパイルはcode_を読み込んでdeocde()し,switch()して順次実行するというrun()とほぼ同じ形をとります.

void recompile()
{
    push(ebx);
    push(esi);
    push(edi);
 
    const Reg32 reg[2] = { esi, edi };
    const Reg32 mem(ebx);
 
    xor(reg[0], reg[0]);
    xor(reg[1], reg[1]);
    mov(mem, (int)mem_);
    const uint32 end = code_.size();
    uint32 pc = 0;
    uint32 labelNum = 0;
    for (;;) {
        uint32 code, r, imm;
        decode(code, r, imm, code_[pc]);
    L(toStr(labelNum++));
        switch (code) {
        ...
        pc++;
        if (pc >= end) break;
    } // for (;;)
 
    pop(edi);
    pop(esi);
    pop(ebx);
    ret();

違うのはx86用のコードを生成させるところです.と言っても見た目はそれほど変わりません.

    switch (code) {
    case LDI:
        mov(reg[r], imm);
        break;
    case LD:
        mov(reg[r], ptr[mem + imm * 4]);
        break;
    case ST:
        mov(ptr [mem + imm * 4], reg[r]);
        break;
    case ADD:
        add(reg[r], ptr [mem + imm * 4]);
        break;
    case ADDI:
        add(reg[r], imm);
        break;
    case SUB:
        sub(reg[r], ptr [mem + imm * 4]);
        break;
    case SUBI:
        sub(reg[r], imm);
        break;

概ねrun()と一対一に対応していることがわかるでしょう.分岐のみちょっと変わったことをする必要があります.
toyVMでは命令長が4byte固定だったので命令数だけポインタを減らせば分岐ができたのですが,x86ではそうではありません.
ここでは簡単にすませるために,一命令毎に数値のラベルを生成させて,そのラベルへ分岐するようにしました.

    L(toStr(labelNum++));
        switch (code) {
        ...
        case JNZ:
            test(reg[r], reg[r]);
            jnz(toStr(labelNum + static_cast<signed short>(imm)));
            break;

以下は実行時にrecompile()して得たx86のコードです.

.lp:
  mov   dword ptr [ebx+4],esi 
  add   esi,dword ptr [ebx] 
  mov   edi,dword ptr [ebx+4] 
  mov   dword ptr [ebx],edi 
  mov   edi,dword ptr [ebx+8] 
  sub   edi,1 
  mov   dword ptr [ebx+8],edi 
  test  edi,edi 
  jne   .lp

問題なさそうです.


6. パフォーマンスを見る


ではどの程度改善されたのか見てみましょう.

n = 10000のときにかかった時間を測定しました.マシンはCore2Duo 2.6GHz + Visual Studio 2005です.

通常VMJITnative C(fibC)
1216K136K84K

通常のVMではnative Cに比べて10倍以上遅かったのが一気に肩を並べる速度にまで向上しました.
これは通常のVMでの本質である,switch + jmpがパイプラインを乱すため最近のCPUではコストが大きいためです.
ネイティブなコードへの変換が如何に重要であるかがわかります.

注意:gcc 4.3.0でfibCをコンパイルするともっとよいコードが生成されていました.その場合は上記よりもnative Cが何割か性能がよくなります.


7. リコンパイラを改良する


せっかくですので少しだけrecompileを改良してみましょう.
上記のx86のコードでは不要なメモリアクセスが目立ちます.
不要な命令を減らすことはJITの重要な課題ですが,それは難しいので,メモリアクセスではなくレジスタアクセスをさせるようにしましょう.
幸いフィボナッチでは三つしかmemを使わないのでそれらをレジスタに割り当てることにします.
VMに対して,memの先頭12byteだけが特別に速いメモリになったかのように思わせるということです.

そのためのレジスタをeax, ecx, edxとしました.それらを初期化するコードを追加します.

const Reg32 memTbl[] = { eax, ecx, edx };
const size_t memTblNum = NUM_OF_ARRAY(memTbl);
for (size_t i = 0; i < memTblNum; i++) xor(memTbl[i], memTbl[i]);

そしてrecompileでメモリにアクセスする部分を変更します.

case ADD:
    if (imm < memTblNum) {        //
        add(reg[r], memTbl[imm]);    // 追加部分
    } else {                         //
        add(reg[r], ptr [mem + imm * 4]);
    }
    break;
mem[]の先頭にアクセスするときのみレジスタへのアクセスに変更しました. これによりリコンパイルで生成されるコードは以下のようになりました.
.lp:
   mov         ecx,esi 
   add         esi,eax 
   mov         edi,ecx 
   mov         eax,edi 
   mov         edi,edx 
   sub         edi,1 
   mov         edx,edi 
   test        edi,edi 
   jne         .lp

すっきりしました.
ベンチマークをとってみます.

通常VMJIT改良版JITnative C(fibC)
1216K136K101K84K

3割ほど速度が向上しました.
このサンプルを基にいろいろVMをいじってみるのも面白いかと思います.

2008年04月30日

x64(64bit)対応JITアセンブラXbyakリリース

最近,Visual C++ のことを高機能なマクロアセンブラだと思っている光成です.
その考えを64bit Windows/Linuxにも押し進めるため,64bitに対応したJITアセンブラXbyakを公開しました.
64bit Visual Studioではインラインアセンブラが廃止されたため,何かと便利になるのではないかと思います.

ところでWikipediaのAMD64などには64bit Windowsに関して


64ビットアプリケーションではx87命令・MMX命令及び3DNow!命令をサポートしない(x87レジスタをコンテキストスイッチの際にセーブしない)

という記述があるのですが,試したところちゃんとセーブされているようです.

テスト方法
test_mmx.cppをコンパイルして(binary)
コマンドプロンプトを二つ開いて

test_mmx 1
test_mmx 3

と実行します.それぞれスレッドを起動して,1, 2, 3, 4という数値が表示され続けました.

もしMMXレジスタがコンテクストスイッチで保存されていなければ全て同じものになるはずなので,保存されていると推測されます.

Legacy Floating-Point Supportにも

The MMX and floating-point stack registers (MM0-MM7/ST0-ST7) are preserved across context switches

とあるので保存されているように思われます.

一つ気になるのは私がテストしたのは32bit WindowsのVMware上の64bit Xpだったという点です.素の64bit環境でテストした場合どうなるかどなたか試していただけますでしょうか.

追記
さんから英語版Wikipediaには使えると書いてますよという情報をいただきました.
#見るの忘れてました.迂闊だった….

About xbyak

ブログ「mitsunari@cybozu labs」のカテゴリ「xbyak」に投稿されたすべてのエントリーのアーカイブのページです。過去のものから新しいものへ順番に並んでいます。

前のカテゴリはx86(IA-32)です。

他にも多くのエントリーがあります。メインページアーカイブページも見てください。