仮想レジスタを持つスタックマシンではA正規形は不利? - Scheme VM を書く

さてあれこれ考える時間です。少ない脳みそをこねましょう。
A正規形にすると let が増え、bind が増え速度的に不利になるかもしれないという話です。

A正規形のパターンごとに考える

A正規形では登場人物が限定されます。これを利用しうまく解析すれば、コンパイル時に let を減らせないでしょうか。


例えば

(let (x V) M) (bind)

Vは値。instruction としては CONSTANT か CLOSURE が該当する。
これらの命令は結果を accumulator に置く。その後 x に bind して M を実行というのが通常の流れ。


仮に M が (func x) だとすれば、bind は必要なくて accumulator の値を PUSH 、APPLY ですむ。
こういうパターンをうまくコンパイルできれば良い。


逆に、bind が必要な場合を考えてみよう。
M が (let (y 0) (+ x y)) だとすると。

(let (x V)
  (let (y 0)
     (+ x y)))

となる。
この場合は (let (y 0) ...) の右辺が 0 で x を参照していないから bind を省略できるのか。
擬似コード CONSTANT V, PUSH, CONSTANT 0, NUM_ADD となる。
うーん。ちょっと A正規形のパターンごとに考えるのは複雑かな。一休みしよう。

Gauche のスタックフレームに学ぶ

A正規化で let が増加することは許容する。そのかわり最大限スタックフレーム周りを高速化するのはどうだろうか。
Gauche の実装を見て比較する。


まずは let1 (bind) のフレームをみてみよう。
disasm すると LOCAL-ENV がフレーム作成であることが分かる。

gosh> (disasm (lambda () (let1 x z (+ x y))))
main_code (name=#f, code=0x8158de0, size=8, const=3, stack=9):
args: #f
     0 GREF-PUSH #<identifier user#z>; z
     2 LOCAL-ENV(1)             ; (let ((x z)) (+ x y))
     3 LREF0-PUSH-GREF #<identifier user#y>; y
     5 PUSH-GREF-TAIL-CALL(2) #<identifier user#+>; (+ x y)
     7 RET 
CASE(SCM_VM_LOCAL_ENV) {
    CHECK_STACK_PARANOIA(ENV_SIZE(0));
    FINISH_ENV(SCM_FALSE, ENV);
    NEXT;
}

CHECK_STACK_PARANOIA は無視して良い。

/* push environment header to finish the environment frame.
   env, sp, argp is updated. */
#define FINISH_ENV(info_, up_)                  \
    do {                                        \
        ScmEnvFrame *e__ = (ScmEnvFrame*)SP;    \
        e__->up = up_;                          \
        e__->info = info_;                      \
        e__->size = SP - ARGP;                  \
        SP += ENV_HDR_SIZE;                     \
        ARGP = SP;                              \
        ENV = e__;                              \
    } while (0)

/*
 * Environment frame
 *
 *   :        :
 *   +--------+
 *   | size=N |
 *   |  info  |
 *   |...up...|<--- ScmEnvFrame* envp
 *   |arg[N-1]|
 *   |arg[N-2]|
 *   :        :
 *   | arg[0] |
 *   +--------+
 *   :        :
 */

typedef struct ScmEnvFrameRec {
    struct ScmEnvFrameRec *up;  /* static link */
    ScmObj info;                /* reserved */
    ScmWord size;               /* size of the frame (excluding header) */
} ScmEnvFrame;

SP は vm->sp、ENV は vm->env 。
FINISH_ENV はちと名前に惑わされそうになるのだけど、スタックトップにフレームを作成する。
作成したフレームにあわせて sp, argp, env などを更新している。
フレームに積まれた、arg[] は env ポインタからのオフセットでアクセスできる。


env から引数がアクセス可能ならば、argp は何に使われるだろうか。

    ScmObj *argp;               /* Current argument pointer.  Points
                                   to the incomplete environment frame
                                   being accumulated.  This is a part of
                                   continuation.                             */

どこまで引数を積んだかを保持しているっぽい。


スタックフレーム破棄タイミングを見てみよう。

gosh> (disasm (lambda () (let1 a b (let1 c d (+ a c)) a)))
main_code (name=#f, code=0x80f9d58, size=14, const=3, stack=19):
args: #f
     0 GREF-PUSH #<identifier user#b>; b
     2 LOCAL-ENV(1)             ; (let ((a b)) (let1 c d (+ a c)) a)
     3 GREF-PUSH #<identifier user#d>; d
     5 LOCAL-ENV(1)             ; (let ((c d)) (+ a c))
     6 PRE-CALL(2) 11
     8 LREF10-PUSH              ; a
     9 LREF0-PUSH-GREF-CALL(2) #<identifier user#+>; (+ a c)
    11 POP-LOCAL-ENV 
    12 LREF0                    ; a
    13 RET 

POP-LOCAL-ENV を見てみましょう。

CASE(SCM_VM_POP_LOCAL_ENV) {
    ENV = ENV->up;
    NEXT;
}

一つ上のフレームに上がるだけ。
ふむ。元々Gaucheのフレームを参考にさせていただいているのでこのあたりに大きな違いはない。

考える

(+ (+ a b) c) のA正規形の変換前後での実行コストをまじめに考えよう。以下擬似コードなので注意。
仮想レジスタ(accumulator)が1つあることを前提にしています。

変換前

(+ (+ a b) c)
=>
ref a
push
ref b
add
push
ref c
add


変換後

(let1 t (+ a b) (+ t c))
=>
ref a
push
ref b
add
push
enter
lref t
push
ref c
add
leave

増えたのは enter/lref/leave の3つ。
うーん。変換後の方が明らかに遅くなるよなあ。

Gauche の IForm

Gauche の IForm において上記の例はどのように表現されるだろうか?。もし bind で表現されているようであれば、後続の処理が参考になるかもしれない。
src/compile.scm の compile 手続きで pass1 の結果を出力する様にGaucheに直接手を入れた。


その結果以下のようになった。

#(14 (+ (+ a b) c) (149) (#(14 (+ a b) (149) (#(3 #<identifier user#a>) #(3 #<identifier user#b>))) #(3 #<identifier user#c>)))


定数の意味を調べて当てはめ、デバッグ用のソースコード情報を除くと以下のようになる。(あとから考えたら pp-iform を使って pretty print すれば良かった。)

#($ASM (NUMADD2) (#($ASM (NUMADD2) (#($GREF a) #($GREF b)))) #($GREF c))


あら bind になってないですね。やっぱりそうだよなあ。


オペレータ適用ではなく、手続き呼び出しではどうだろうか?

(func (func a b) c)
=>
#($CALL #($GREF func)
       (#($CALL #($GREF func) (#($GREF a) #($GREF b)) #f ())
        #($GREF c))
        #f ())

計算の途中経過に一時変数を割り当てるといったことは pass1 ではやってないみたい。

現時点でのまとめ

  • スタックフレームの工夫による高速化の余地はあまりなさそう
  • A正規形にすると余計な bind が増え VM での実行速度が遅くなる
  • A正規形をうまく解析して余計な bind を削除できるかは微妙
  • Gauche はA正規形の一時変数割り当てはやってないみたい


「仮想レジスタを持つスタックマシンでは A正規形にしないほうが良い」という結論になるのかな?