この記事は言語実装アドベントカレンダー21日目、MLアドベントカレンダー21目の記事です
min-caml
のJVMバックエンドを作ったという話を前に書いたのですが、
それの詳細を書きたいと思います。
前の話はこの辺にかかれています。
JVMバックエンドに移植するのは大して難しくないというのは上の記事で書きました。 スタックマシンへのコンパイルの方針はほぼ説明尽くしたと思っていますが、 そのへんで説明していないものについて説明します。
基本方針
min-caml
のシンタックスというのは以下で表される非常にシンプルなものなのです。
移植する際はこれを直接扱わずに済みます。直接対象にするのはClosure.prog
です。
min-caml
では以下のように形式が変換されます。
min-caml
ではまずK正規化され(ここを見てください)、次にクロージャ変換されます。
次の中間言語Asm.prog
からはマシン依存の中間コードとなります。標準のmin-caml
のプロジェクトでは
x86
やSPARC
フォルダに入っているのがそれぞれの実装です。
今回はここにjvm
ファイルを加え、マシン依存の中間表現を書き換えるところから始まります。
扱う対象のClosure.prog
の定義を見てみましょう。これはK正規化・クロージャ変換後の言語で、
クロージャ変換が終わっているためトップレベルの関数定義(fundef list
)と実行部分(t
)に分かれています。
下の定義を見て下さい(ここのコピーです)。
type closure = { entry : Id.l; actual_fv : Id.t list } type t = | Unit | Int of int | Float of float | Neg of Id.t | Add of Id.t * Id.t | Sub of Id.t * Id.t | FNeg of Id.t | FAdd of Id.t * Id.t | FSub of Id.t * Id.t | FMul of Id.t * Id.t | FDiv of Id.t * Id.t | IfEq of Id.t * Id.t * t * t | IfLE of Id.t * Id.t * t * t | Let of (Id.t * Type.t) * t * t | Var of Id.t | MakeCls of (Id.t * Type.t) * closure * t | AppCls of Id.t * Id.t list | AppDir of Id.l * Id.t list | Tuple of Id.t list | LetTuple of (Id.t * Type.t) list * Id.t * t | Get of Id.t * Id.t | Put of Id.t * Id.t * Id.t | ExtArray of Id.l type fundef = { name : Id.l * Type.t; args : (Id.t * Type.t) list; formal_fv : (Id.t * Type.t) list; body : t } type prog = Prog of fundef list * t val fv : t -> S.t val f : KNormal.t -> prog
この段階ではlet rec id x = x in let x = let y = id 2 in 1 + y - 3 in print_int x
みたいなプログラムは以下のように
脱糖衣されています(id
関数を途中に噛ませているのは最適化で四則演算が消えないようにするためです)。
(* let rec id x = x in let x = let y = id 2 in 1 + y - 3 in print_int x をコンパイルする *) # Main.str_to_cls "let rec id x = x in let x = let y = id 2 in 1 + y - 3 in print_int x";; free variable print_int assumed as external iteration 1000 iteration 999 directly applying id__5 eliminating closure(s) id__5 - : Closure.prog = Prog ([{name = (Id.L "id__5", Type.Fun ([Type.Int], Type.Int)); args = [("x__6", Type.Int)]; formal_fv = []; body = Var "x__6"}], Let (("Ti1__7", Type.Int), Int 2, Let (("y__8", Type.Int), AppDir (Id.L "id__5", ["Ti1__7"]), Let (("Ti2__9", Type.Int), Int 1, Let (("Ti3__10", Type.Int), Add ("Ti2__9", "y__8"), Let (("Ti4__11", Type.Int), Int 3, Let (("x__12", Type.Int), Sub ("Ti3__10", "Ti4__11"), AppDir (Id.L "min_caml_print_int", ["x__12"]))))))))
これを見ると以下のことがわかります。正確なことはmin-caml
の説明文を読んでください。
- K正規化によって定数にも逐一名前が付いている
- すでに木構造は平たくなっており、
1+2+3
みたいな四則演算もlet x = 1 + 2 in let y = x + 3 in y
みたいに1つの計算(?)ごとに細かく分割されている - ネストしたlet式はすでに平たくなっている
前の記事で木構造を簡単にコンパイルできることがスタックマシンのいいところだ、みたいな 話を書きましたが木構造を対象にする必要はありません。 JVMバイトコードの性質を活かすならばコンパイラのパイプライン自体を変えたほうがいいでしょう。 今回はそこまでやりませんでした。
中間言語と上のことを踏まえると、タプルとクロージャくらいしか難しそうにないことがわかります。
変換の基本方針は前の記事のとおりで、
transl
関数を再帰的に定義して出力のコード列を実行すると1つだけ
スタック上に値が追加されるようにするというものです。
現実にはこれに引数をいくつか追加しなければなりません。
JVMバイトコードでは、そのメソッドでの最大のスタックサイズとローカル変数のサイズを
指定してやらねばなりません。またローカル変数を取り扱うためにどのローカル変数が何番地(?)に入っているのか
覚えておかねばなりません。そのためtransl
の型はtransl: int -> int -> table -> instruction list
とならねば
なりません。最初のint
が今のところのスタック使用最大サイズ、2番目のint
がローカル変数の数、3番目が何かしらのテーブルだという気持ちです。
実装詳細
あまり自明じゃない部分についてのみ説明します。
クロージャの実装
まだ実装してないのですが、基本的な方針は立っています。
1つしかメソッドのないクラスを新たに作り、コンストラクタでクロージャの環境に入る
要素を受け取りそのクラスのフィールドにセットし、呼び出すときにはcls.app(arg1, arg2, ...)
のように
関数呼び出しのためのメソッドを呼び出し普通に引数を渡せばOKです。
現在のmin-caml
には部分適用がないのでこんな実装で大丈夫だと思います。
ちなみにもし存在したとしたらフルのMLと同じような部分適用を扱うのはおそらく効率が悪いです(cf: Scala)。
タプル
タプルはポインターの配列にコンパイルされます。プリミティブ型の値はBoxingする必要があります。
例えばlet x = (1, 3.14) in print_tuple x
みたいなコードがあったとしたら
このタプルの生成はjava/lang/Object
型の配列を確保し、1
と3.14
を
Integer.valueOf
メソッドとDouble.valueOf
メソッドでInteger
、Double
型に変換し
それらをそれぞれ0, 1番地に代入するコードになります。
辛いところが、タプルをパターンマッチで破壊し中身を取り出す 時にBoxingした値をUnboxingする必要があります。
UnboxingのためにInteger
型をint
型にするためにはInteger.intValue
メソッドを
呼び出す必要がありますが、上で述べたようにタプルはObject
型の配列なので
取り出してメソッド呼び出しをするためには、なんとキャストが必要になります。
これをサボるとJVM
のバイトコード検証機が不正なバイトコードだと言って
コードを実行させてくれません(検証しないオプションをつければ実行できるのですが……)。
まぁ型付きのアセンブリみたいなものは面倒だという話です。
妄想:パラメータ多相性を加えると
min-caml
は単相型な型しかつけません。これに多相的な型を加えるとバイトコードがどうなるのか
考えてみましょう。
まず思いつく問題は、多相的な配列に対する操作です。
JVMでは生の配列というのは5種類に分かれます(int
, long
, float
, double
, ポインタの配列)。
例えば多相関数Array.get
ではどんな型の配列に対しても要素にアクセスできる必要がありますが、
配列の種類が型によって分かれていてはこれを行うことができません。
主に2つ解決策が考えられます
- 実行時に型をディスパッチして分岐する
- 配列の要素はすべてBox化する
1番めの解決策の実行時のディスパッチはプログラムを遅くしてしまいます。
特にmap
みたいに何回も繰り返す計算の中で毎回Array.get
みたいな関数が
型ディスパッチを行うと地獄です。
2番目の解決策を取るとするとアセンブリを出力することを考えると、 Uniformでない(データのサイズが1ワードに揃っていない) 値だけをBox化すればよかったのが、すべての要素をBox化しなければならなくなってしまいました。
多相性とJVMバイトコードというのは相性がよくなさそうだなぁという感じです。 Javaのジェネリクスの実装がああなっているのもうなずける話です。
おまけ: Mixed representation
多相性をうまく扱う研究は古くからたくさん行われてきました。 その1つとしてXavier LeroyのMixed Representationがあります。
研究の概略だけ説明すると、 伝統的なMLで実装されている全ての値をUniformにするのはやりすぎなので 単相的な関数に渡すときには単相的な表現を用いて、 多相関数に渡す時だけBox化などを用いた多相的な表現用いたら 効率的になりそうだから実験しましたというものです。
例えばlet make_pair x = (x, x) in print_tuple (make_pair 3.14)
をコンパイルすることを
考えてみましょう(補助関数の型はprint_tuple: float * float -> unit
です)。
mak_pair
は多相関数で仮引数のx
はBox化された表現だと思っているので、
make_pair
に渡すときに3.14
はBox化されている必要があります。
make_pair
の返り値のタプルは中身がBox化されているわけですが、
print_tuple
に渡すときにはフラットなタプルを渡したいので、返り値のタプルを
壊してフラットなタプルを作りなおす必要があります。
box
とunbox
という操作を加えてみると上のプログラムは以下のようになります。
let make_pair x = (x, y) in let a = make_pair(box 3.14) in let b = (unbox(fst a), unbox(snd a)) in print_tuple b
この研究ではもちろんアセンブリを対象にしているのですが、 同じ議論がJVMバイトコードにも当てはまります。
確かに単相的な配列を受け取る関数に対しては単相的な 配列を渡して、多相的な関数には多相的な表現をした配列を渡すというのは自然な気がします。 これで一部Box化をなくすことができ、パフォーマンスが大菊恒常するでしょうか?
この実装方法の問題はデータ構造を何回も作りなおさないといけないことです。
上の例ではタプルをmake_pair
で作ってから後で単相的な関数に渡すために
fst
とsnd
でそれを壊してまた作りなおしています。
これが長さが短いタプルならばOKだと思うのですが、長さ10000の配列に対して適用できるでしょうか?
実際上のスライドの論文でもlist
やarray
に対して
この表現を適用するのは諦めています。タプルも長さが3以下のものにのみ適用しています。
(フラットにできない理由がもうひとつあるのですがそれはスライドを見てください)