sljit の使い方
はじめに
こんにちは。皆さん、お元気でしょうか。
今日は JIT Compiler の話です。Just In Time Compiler 技術です。技術の紹介をして皆の役に立とうという気持ちもありますが、半分は 自分のための備忘録 の意味もあります。なにせ、情報が少なくて元のソースコードを解析しながら使っているので。
Kinx では native
関数を定義できる機能があり、いわゆる JIT 実行させています。ここで Kinx って何?な方は下記をご参照ください。**「見た目は JavaScript、頭脳(中身)は Ruby、(安定感は AC/DC)」**で頑張っているアレです。
- 参考
- 最初の動機 ... スクリプト言語 KINX(ご紹介)
- 個別記事へのリンクは全てここに集約してあります。
- リポジトリ ... https://github.com/Kray-G/kinx
- Pull Request 等お待ちしております。
- 最初の動機 ... スクリプト言語 KINX(ご紹介)
さて JIT です。JIT したいですよね。
そう、JIT はみんなやりたがるのですが、ここで 1 つ大きな問題があります。それは アセンブラは CPU ごとに違う ということです。
それを解決するのが sljit です。ここではその sljit が便利だったのでご紹介します。
...という趣旨で下書きしていたのですが、実は現在 MIR というものに夢中です。これに関してはまた別途記事にしていこうと思います。今回ひとまず、備忘録として sljit に関する本記事を残しておきます。
sljit とは
公式? 説明文書
私が知る限り、参考になる文書は以下程度しか見つかりませんでした。
- https://zherczeg.github.io/sljit/
- http://ftp.jaist.ac.jp/pub/NetBSD/NetBSD-current/src/sys/external/bsd/sljit/dist/doc/tutorial/sljit_tutorial.html
参考にはなります。
GitHub のリポジトリは以下です。
サポート状況
上記によれば、以下のプラットフォームをサポートしているようです。
- Intel-x86 32
- AMD-x86 64
- ARM 32 (ARM-v5, ARM-v7 and Thumb2 instruction sets)
- ARM 64
- PowerPC 32
- PowerPC 64
- MIPS 32 (III, R1)
- MIPS 64 (III, R1)
- SPARC 32
アセンブラを抽象化
sljit とは一言でいえば 抽象化アセンブラ であり、sljit 形式で書くことによってバックエンドで各 CPU 命令にトランスレートしてくれるのです。いやぁありがたい。
つまり、x86 だけ、とか x64 だけなら頑張ってできることも、ARM でも使いたいとか SPARC でも使いたいとか、うちは PowerPC メインなんで、とか言われたりすると、全部違う実装しなくてはなりません。ただでさえ面倒な JIT の実装なのに、やってられませんよね。それを吸収してくれるのが sljit です。
ということで、Kinx では sljit を使ってアーキテクチャの違いを吸収しようと試みているのでした。
ただし、x64 でしか試していません。
sljit の使い方(基本編)
ソースコード
ソースコードはここから入手できます。
$ git clone https://github.com/zherczeg/sljit.git
必要なのは、このリポジトリの sljit_src
です。このフォルダ内のファイルだけマルっとコピーすれば OK です。
ビルド方法
ビルドも上記のファイルの中の sljitLir.h
をインクルードして、sljitLir.c
を一緒にコンパイルすれば OK です。その時、SLJIT_CONFIG_AUTO
マクロを 1 に指定してください。アーキテクチャを自動的に選択してくれます。
つまり、sljit フォルダに先ほどのファイル一式を展開(コピー)した場合、以下のようになります。
$ cl /DSLJIT_CONFIG_AUTO=1 /I sljit ソースファイル.c sljit\sljitLir.c
gcc の場合は以下の通り。
$ gcc -DSLJIT_CONFIG_AUTO=1 -I sljit ソースファイル.c sljit/sljitLir.c
レジスタの制約
sljit レジスタの種類
レジスタには汎用レジスタと浮動小数演算レジスタの 2 種類があり、それぞれに Scratch レジスタ と Saved レジスタ というものがあります。
下記のソースコードの説明に従うと、Scratch レジスタとは一時的に使うレジスタで関数呼び出し後に保存されていない可能性があるもの、Saved レジスタとは関数呼び出し後であっても値が保持されているもの、となります。
/*
Scratch (R) registers: registers whose may not preserve their values
across function calls.
Saved (S) registers: registers whose preserve their values across
function calls.
*/
それぞれ、scratch
、saved
、fscratch
、fsaved
と区別されて名前付けされています。表にすると以下の通り。
- | Scratch | Saved |
---|---|---|
汎用レジスタ |
scratch SLJIT_R0, SLJIT_R1, ... |
saved SLJIT_S0, SLJIT_S1, ... |
浮動小数演算レジスタ |
fscratch SLJIT_FR0, SLJIT_FR1, ... |
fsaved SLJIT_FS0, SLJIT_FS1, ... |
ちなみに引数は SLJIT_S0
、SLJIT_S1
、SLJIT_S2
で渡されてくる決まりですが、後述する様々なアーキテクチャに対応するため、そしてそれぞれの呼び出し規約に対応するため、引数は 3 つまでしか対応していない とのことです。引数は 3 つまで。
Kinx では、ローカル変数領域に引数領域を取っておき、そこへのポインタを引数をとして渡すようにしています。これで複数の引数を疑似的に渡せます(ただし現状最大 32 個まで)。
様々なアーキへの対応
様々なアーキに対応させるには、最大公約数的な使い方をしなくてはなりません。つまり、使うレジスタの数は全てのプラットフォームで許されてる数までに制限して使います。
幸い、ソースコード上に以下のコメントがあるのでどこまで使えるかが分かります。
/*
...
Note: On all supported architectures SLJIT_NUMBER_OF_REGISTERS >= 12
and SLJIT_NUMBER_OF_SAVED_REGISTERS >= 6.
...
*/
/*
...
Note: the following conditions must met:
0 <= scratches <= SLJIT_NUMBER_OF_REGISTERS
0 <= saveds <= SLJIT_NUMBER_OF_REGISTERS
scratches + saveds <= SLJIT_NUMBER_OF_REGISTERS
0 <= fscratches <= SLJIT_NUMBER_OF_FLOAT_REGISTERS
0 <= fsaveds <= SLJIT_NUMBER_OF_FLOAT_REGISTERS
fscratches + fsaveds <= SLJIT_NUMBER_OF_FLOAT_REGISTERS
...
*/
SLJIT_NUMBER_OF_SCRATCH_REGISTERS == SLJIT_NUMBER_OF_REGISTERS - SLJIT_NUMBER_OF_SAVED_REGISTERS
なので、Scratch レジスタ x 6 個、Saved レジスタ x 6 個の 12 個までなら全てのプラットフォームで使える、ということです。
浮動小数演算レジスタのほう(float/double)のレジスタは、色々調べたところ、SLJIT_NUMBER_OF_FLOAT_REGISTERS
が 6 で Saved が 0 or 1 だった(というか x64)ので、fscratch x 5、fsaved x 0 で考えておくのが良さそうです。
sljit を使うソースコードの構造
sljit でのコードジェネレーションでは、基本的に関数ポインタとして復帰してきます。つまり、関数のエントリポイントとリターンのコードを最初と最後につけます。テンプレートとしては、以下の通り。
sljit_create_compiler
でコンパイラ・オブジェクトを作成し、sljit_emit_enter
で関数の入り口を作成します。ここでは引数が 1 つ(SW は Signed Word)、ローカル変数領域は 0 バイトとしています(使わない例)。sljit_generate_code
で実際のコードを作成して、先頭アドレスへのポインタを返します。
#include "sljitLir.h"
int main(void)
{
/* コンパイラ・オブジェクトの作成 */
struct sljit_compiler *C = sljit_create_compiler(NULL);
/* 関数のエントリポイント */
sljit_emit_enter(C, 0,
SLJIT_ARG1(SW), /* argument type */
6, /* scratch : temporary R0-R5 */
6, /* saved : safety S0-S5 */
5, /* fscratch : temporary FR0-FR4 */
0, /* fsaved : safety - */
0 /* local : */
);
/* コード作成部分 */
/*
ここに sljit でのアセンブラコードを書いていく
尚、関数の引数は、SLJIT_S0, SLJIT_S1, ... と saved レジスタが使われる。
*/
/* 復帰コード */
sljit_emit_return(C, SLJIT_MOV, SLJIT_IMM, 0);
/* コード生成 */
void *code = sljit_generate_code(C);
int (*func)(sljit_sw) = (int (*)(sljit_sw))code;
/* 実際の関数呼び出し */
int r = func(100);
/* 後始末 */
sljit_free_compiler(C);
sljit_free_code(code);
return 0;
}
簡単なサンプル
ここまでの前提知識と、いくつかの sljit のインターフェースを使って簡単なサンプルを作ってみましょう。さっきのテンプレートにコード作成部分を追加します。
サンプル(足し算プログラム)
ソースコード
3 つの引数を受け取って、全部足し合わせて結果を返します。やっていることは以下の通り。
-
SLJIT_R0
レジスタにSLJIT_S0
レジスタとSLJIT_S1
レジスタを加えた値を代入します。 -
SLJIT_R0
レジスタに、さらにSLJIT_S2
レジスタを加えた値を代入します。 -
sljit_emit_return
でSLJIT_R0
レジスタの値を返します。
また、以下の変更をしています。
-
sljit_emit_enter
の指定で、関数内で使うレジスタ数を最小に(Scratch x 1、Saved x 3)。 -
sljit_emit_enter
の指定で、引数を 3 つ受け取る(SLJIT_ARG1(SW) | SLJIT_ARG2(SW) | SLJIT_ARG3(SW)
)ように変更。 - 関数プロトタイプも引数を 3 つ受け取る(
int (*)(sljit_sw,sljit_sw,sljit_sw)
)ように変更。
#include "sljitLir.h"
int main(int ac, char **av)
{
if (ac != 4) {
return 1;
}
/* 計算に使う数値の入力 */
int a = atoi(av[1]), b = atoi(av[2]), c = atoi(av[3]);
/* コンパイラ・オブジェクトの作成 */
struct sljit_compiler *C = sljit_create_compiler(NULL);
/* 関数のエントリポイント */
sljit_emit_enter(C, 0,
SLJIT_ARG1(SW) | SLJIT_ARG2(SW) | SLJIT_ARG3(SW), /* argument type */
1, /* scratch : temporary R0 */
3, /* saved : safety S0-S2 */
0, /* fscratch : temporary - */
0, /* fsaved : safety - */
0 /* local : */
);
/* コード作成部分 */
sljit_emit_op2(C, SLJIT_ADD, SLJIT_R0, 0, SLJIT_S0, 0, SLJIT_S1, 0);
sljit_emit_op2(C, SLJIT_ADD, SLJIT_R0, 0, SLJIT_R0, 0, SLJIT_S2, 0);
/* 復帰コード */
sljit_emit_return(C, SLJIT_MOV, SLJIT_R0, 0);
/* コード生成 */
void *code = sljit_generate_code(C);
int (*func)(sljit_sw,sljit_sw,sljit_sw) = (int (*)(sljit_sw,sljit_sw,sljit_sw))code;
/* 実際の関数呼び出し */
int r = func(a, b, c);
printf("r = %d\n", r);
/* 後始末 */
sljit_free_compiler(C);
sljit_free_code(code);
return 0;
}
sljit_add3.c
というファイル名で保存し、コンパイルして実行してみましょう。Visual Studio (cl.exe) でのコンパイルの仕方は以下の通り。
$ cl /DSLJIT_CONFIG_AUTO=1 /I sljit sljit_add3.c sljit\sljitLir.c
$ sljit_add3.exe 100 101 102
r = 303
出力コード
ディスアセンブラが無いので、出力コードが良くわかりませんね。一部細工して生成されたコードを逆アセンブルできるようにした上で出力してみると以下のようになりました。
0: 53 push rbx
1: 56 push rsi
2: 57 push rdi
3: 48 8b d9 mov rbx, rcx
6: 48 8b f2 mov rsi, rdx
9: 49 8b f8 mov rdi, r8
c: 4c 8b 4c 24 f0 mov r9, [rsp-0x10]
11: 48 83 ec 10 sub rsp, 0x10
15: 48 8d 04 33 lea rax, [rbx+rsi]
19: 48 03 c7 add rax, rdi
1c: 48 83 c4 10 add rsp, 0x10
20: 5f pop rdi
21: 5e pop rsi
22: 5b pop rbx
23: c3 ret
関数のプロローグとエピローグでいくつかの push/pop がありますが、先の Saved レジスタを実現するためでしょう。レジスタ数を最大まで使うようにすると、もっと増えます。
もう少し JIT っぽいアプローチ
ソースコード
JIT コンパイルを使う意義というのは色々ありますが、即値 というのも重要な要素でしょう。コンパイルコードに直接値を埋め込んでコンパイルしてしまいます。
尚、ここでは sljit_emit_enter
による関数定義とは別の方法を、紹介がてら使ってみます。
sljit には引数が無い場合、もっと簡単に関数定義するための sljit_emit_fast_enter
と SLJIT_FAST_RETURN
を使う手段も提供されています。これは、先ほどのプロローグやエピローグを一切出力しません。
sljit_emit_fast_enter
で指定したレジスタにリターン・アドレスを保存しておき、それを使ってリターンするようにするだけです。ただし、sljit_emit_enter
の代わりに sljit_set_context
する必要があります。使用するレジスタを明示するだけのようです。Saved レジスタの対処やローカル変数領域などは指定しても領域確保はしてくれませんが、sljit_set_context
で指定した数以上のレジスタを使おうとすると assertion が入ります。
主に、JIT <=> JIT 間での簡易関数コールなどに使えます。ここには呼出規約も何もないので、好きなレジスタに値を設定して、コールすれば良いだけです。最小限のコードを出力してくれます。
ただ、ここでやってる使い方は結構危険かもしれないですね。R1/R0 レジスタが呼び出し元レジスタとして割り付けられてた場合、最悪クラッシュします。今回のケースでは多分大丈夫(ちゃんと見てないけど直観)。通常は JIT <=> JIT 間で使うのが良いですね。
こんな風に書けます。
int main(int ac, char **av)
{
if (ac != 4) {
return 1;
}
int a = atoi(av[1]), b = atoi(av[2]), c = atoi(av[3]);
/* コンパイラ・オブジェクトの作成 */
struct sljit_compiler *C = sljit_create_compiler(NULL);
/* 関数のエントリポイント */
sljit_set_context(C, 0, 0, 2, 0, 0, 0, 0); // SLJIT_R0 と SLJIT_R1 しか使わない.
sljit_emit_fast_enter(C, SLJIT_R1, 0); // SLJIT_R1 にリターン・アドレスを保存.
/* コード作成部分 */
sljit_emit_op2(C, SLJIT_ADD, SLJIT_R0, 0, SLJIT_IMM, a, SLJIT_IMM, b);
sljit_emit_op2(C, SLJIT_ADD, SLJIT_R0, 0, SLJIT_R0, 0, SLJIT_IMM, c);
/* 戻り値は SLJIT_R0 */
/* 復帰コード */
sljit_emit_op_src(C, SLJIT_FAST_RETURN, SLJIT_R1, 0); // SLJIT_R1 のアドレスにリターン
/* コード生成 */
void *code = sljit_generate_code(C);
int (*func)() = (int (*)())code;
/* 実際の関数呼び出し */
int r = func();
printf("r = %d\n", r);
/* 後始末 */
sljit_free_compiler(C);
sljit_free_code(code);
return 0;
}
動作は同じです。
$ cl /DSLJIT_CONFIG_AUTO=1 /I sljit sljit_add3.c sljit\sljitLir.c
$ sljit_add3.exe 100 101 102
r = 303
$ sljit_add3.exe 1000 1001 1002
r = 3003
なぜ 2 回やったかというと、動的コンパイルの良さがここに表れるからですね。次の逆アセンブルリストを見てみましょう。
出力コード
最初のコマンドラインではこうです。
0: 5a pop rdx
1: 48 c7 c0 64 00 00 00 mov rax, 0x64
8: 48 83 c0 65 add rax, 0x65
c: 48 83 c0 66 add rax, 0x66
10: 52 push rdx
11: c3 ret
関数のプロローグやエピローグが無いのはもちろんですが、0x64, 0x65, 0x66 という値が直接埋め込まれています。コール直後はスタックにリターンアドレスがあるので、rdx
レジスタに保存しておいて、最後にスタックにリターンアドレスをプッシュして ret
します。ret
命令はスタックトップの値をアドレスと見立ててそこにジャンプする命令です。
では 2 回目の実行ではどうなるでしょう。
0: 5a pop rdx
1: 48 c7 c0 e8 03 00 00 mov rax, 0x3e8
8: 48 81 c0 e9 03 00 00 add rax, 0x3e9
f: 48 05 ea 03 00 00 add rax, 0x3ea
15: 52 push rdx
16: c3 ret
コード自体が変わりました。こういうやり方ができるので、JIT は事前コンパイル(AOT)より速い場合があるのです。
おわりに
今回は sljit の関数の作り方でした。一部命令(ADD
)も使いましたが、他にも命令は色々あります。また、プログラムを作るうえでは条件分岐は欠かせません。そういうのも含めて、使い方の記録を残しておこうと思います。
では、また続き(あるのかなー)をお楽しみに。