flisp, Stack Machine

VMってバーチャル・マシン、反対語はリアル・マシン。リアル・マシンって言い方は 余り聞かないけどね。

オイラーが何故VMに引かれるか? それはリアル・マシンへの反動があるから。糞石を 使ったパソコンは論外。だったら、500円で買えるというラズパイか?

ラズパイの石はARMだ。結構複雑。だったら、自分で半田コテを握って、エッチラ・オッチラと 石を組み立てたらどうだ。昔、TTLを組み合わせて自分流CPUを作る本が出てたな。 感化されたあの人が、暫くRuby界で話題になってた。

もし自分CPUを組むなら、i4004 世界初のワンチップマイコンに敬意を払って、4Bitマシン ですかね。そいつを使って、電卓を作るのさ。

ALUはどうする? ゲートを組み合わせてフルアダーとかを作るのは、かったるいな。 1970年代の記憶では、SN74181だったかの4Bit ALU が持て囃されていた。今でも 手に入りますか? >テキサス・インスツルメント殿。

きっと、フルピッチのDIP品は全て廃番になりましたって言われるだろうね。その代わり、 ハーフピッチのものなら、10万個単位で注文生産可能です、とか言ってくるかしら?

結局SN7400とかのゲートを組み合わせてALUを作る事になりそう。図面を引く前に、 論理の組み合わせを考え、ド・モルガンの定理をフル活用して、ハードのリファクタリング が必要だろうね。ゲート数をいかにケチるかが、ハード屋さんの腕の見せ所でしたから。

全体は、5相クロックぐらいの同期式かな。最初は緩く、サイクルタイムを500NSぐらい にしておき、後は、オーバークロックしてけばいいか。そうすると、プログラマブルな 発信器が必要になるな。それには、昔培ったVFOの技術が役立つだろう。夢想するのは 楽しい。

但し、実際にハードに触るようになると、 真剣勝負ですよ。逆配線とかしちゃうと、平気で煙が出たり、火を噴いたりする。 ICのチップがパンと小気味良い音を立てて割れたり、その破片が飛んできたり。後に 残るはいつまでも消えない、焼けたレジンの臭い。

コンデンサの逆配は、もう笑うしかない。ぱーとアルミ片が飛び散って、生臭い臭いが 充満しますから、即窓を全開にして、ありったけのファンを回し。。。。

そういう事からすると、パソコンの中に住まうバーチャル・マシンは、なんとも緩い やつだ。せいぜい暴走したって、外側のOSがブロックしてくれるし、楽でいいわい。 それに費用も今時嬉しい、0円ですから。

flisp builtins

flisp付属のsystem.lspをzappingしている。flispは組み込み用を狙ったみたいで、核に なる部分をC語で書き、それ以外をLisp語と言うかSchene語で書きましょって方針の ようだ。 そして、Scheme語で書いたものは、コンパイルして、flispとは別のflisp.bootに収めて おきましょ。こうすれば拡張は自在。よって、flispを動かすには、flisp.bootが必須に なる。もし、flisp.bootが無いと、

$ ./flisp
fatal error:
(io-error "file: could not open \"/usr/home/sakae/flisp.boot\"")

こんな具合に、実行を拒否される。まあ、それはそういうものとして、juliaの場合は どうしてる? flisp.bootにjyulia語のコンパイラーを追加し、それを、16進で文字列 に変換し、文字列の配列としてflispの為のC語に取り込んでいるのだった。

ああ、flispを単体で使うとreplに編集機能が無いのでちと不満。

$ rlwrap ./flisp
;  _
; |_ _ _ |_ _ |  . _ _
; | (-||||_(_)|__|_)|_)
;-------------------|----------------------------------------------------------

> (time.now)
1459637339.0704059600830078

rlwrapで包んで起動すると、行編集や実行履歴検索した上での再実行が出来て便利。

で、time.nowなんてのを発掘した。これ単体ではほとんど使い道が無い。どこで 有効活用されるかと言うと、

(define-macro (time expr)
  (let ((t0 (gensym)))
    `(let ((,t0 (time.now)))
       (prog1
        ,expr
        (princ "Elapsed time: " (- (time.now) ,t0) " seconds\n")))))

ご存知のやつである。これを実現する為に、プリミティブとして、time.nowを実装して いるはず。builtins.cに

static value_t fl_time_now(value_t *args, u_int32_t nargs)
{
    argcount("time.now", nargs, 0);
    (void)args;
    return mk_double(clock_now());
}

clock_now()は、llt/timefuncs.cに細かい定義が有った。

double tv2float(struct timeval *tv)
{
    return (double)tv->tv_sec + (double)tv->tv_usec/1.0e6;
}

double clock_now()
{
#ifdef WIN32...
#else
    struct timeval now;

    gettimeofday(&now, NULL);
    return tv2float(&now);
#endif
}

先のtime.nowはとんでもない精度で表示されたけど、鵜呑みにしないでねってな事が 判明した。

(time (fib 38))

通過儀礼です。

> (define (fib n) (if (< n 3) n (+ (fib (- n 1)) (fib (- n 2)))))
#fn("9000r1|b3X640|;e0|ax31e0|b2x31w;" [fib] fib)

> (time (fib 38))
Elapsed time: 15.276824951171875 seconds
63245986

上記は、Makefileのデフォルト設定である、-DUSE_COMPUTED_GOTO を外しての実行結果。 下記の一例だけ、計算型gotoをイネーブルにしてみた。いにしえのFortran時代からの 知恵が生きていますなあ。

> (time (fib 38))
Elapsed time: 12.3614029884338379 seconds
63245986

ここからは、VMにgdbプローブを当てて観測し易いように、計算型gotoはOFFにして おきます。技術者の錬度を推測するには、オシロの使い方を見れば良いと、昔から 言われていますから。

goshでは、11秒弱でしたよ。それはさておき、flispには、(- n 1) の代わりに (1- n) を 使えるようになってたので、試してみます。

> (define (fib n) (if (< n 3) n (+ (fib (1- n)) (fib (- n 2)))))
#fn("9000r1|b3X640|;e0e1|3131e0|b2x31w;" [fib 1-] fib)

> (time (fib 38))
Elapsed time: 18.6516690254211426 seconds
63245986

反って遅くなりました。何故? こういう時は、どんなコードを出しているか見るのが 正しい方法かな?

> (disassemble (define (fib n) (if (< n 3) n (+ (fib (- n 1)) (fib (- n 2))))))
maxstack 9
00000:  argc    1
00002:  loada0
00003:  loadi8  3
00005:  <
00006:  brf     @0000b
00009:  loada0
0000a:  ret
0000b:  loadg   fib
0000d:  loada0
0000e:  load1
0000f:  sub2
00010:  call    1
00012:  loadg   fib
00014:  loada0
00015:  loadi8  2
00017:  sub2
00018:  call    1
0001a:  add2
0001b:  ret

どう読むのかな? 推測してみる。左側の数字は、命令の置かれているアドレスだろうね。 1から3バイトまでの可変長命令だな。

アーギュメントは1個。loada0は最初の引数をスタックへロードしなさい。loadi8は8ビット幅の 即値(3)をロードしなさい。

そして比較せい。結果がfalseなら、b番地へ飛べ。loadgはグローバルfibの番地をロード しろ。load1は、プログラム中に頻出する即値である1をロードしろだな。そして引き算を 実行。

引数が1個だよとして、スタックに積んである(fib)を呼び出しって具合か。

> (disassemble (define (fib n) (if (< n 3) n (+ (fib (1- n)) (fib (- n 2))))))
maxstack 9
00000:  argc    1
00002:  loada0
00003:  loadi8  3
00005:  <
00006:  brf     @0000b
00009:  loada0
0000a:  ret
0000b:  loadg   fib
0000d:  loadg   1-
0000f:  loada0
00010:  call    1
00012:  call    1
00014:  loadg   fib
00016:  loada0
00017:  loadi8  2
00019:  sub2
0001a:  call    1
0001c:  add2
0001d:  ret

こちらは、先ほどよりも命令長が長い。1-を外部呼出ししてるんで、それのオーバーヘッドも 有って、遅いのだろうね。

それはそうと、勝手な推測の検証をしてみる。アーギュメントのロード命令が正しいかと言うのと、 引数の個数を持ってcallするという推測。

> (disassemble (define (hoge x y) (+ x y)))
maxstack 7
00000:  argc    2
00002:  loada0
00003:  loada1
00004:  add2
00005:  ret
 
> (disassemble (define (foo) (hoge x y)))
maxstack 8
00000:  argc    0
00002:  loadg   hoge
00004:  loadg   x
00006:  loadg   y
00008:  tcall   2
0000a:  ret

どうやら推測通りだ。

こういう、演算対象をstackに取るマシンを、そのままスタックマシンと言う。 どんな命令を備えるべき? その備え度で、オレオレマシンがそれぞれ出来上がる。 大学の講義では、7B.スタックマシン なんてのが有ったぞ。

スタックマシンで有名なものに、 SECDマシンpコードマシン とかForthが有る。Javaはもう知らない。

Forthと言えば、スレッデッドコード って、yarvな人が騒いでていたな。

compile

折角なのでcompile.lspを見ておく。コンパイラーなんだから、ソースのlisp語を マシン語(言うまでもないけどVMのね)に変換するのが目的。ならば、マシン語のニューモニックと相当するバイトコードが 有るはず。そして、バイトコードは、それを利用するVM側でも参照されるはず。

そういう観点で注意深く見ると、compiler.lspの冒頭に有るInstructionsがニューモニックと バイトコードの対応を保持している。

(define Instructions
  (let ((e (table))
        (keys
         [nop dup pop call tcall jmp brf brt jmp.l brf.l brt.l ret

          eq? eqv? equal? atom? not null? boolean? symbol?
          number? bound? pair? builtin? vector? fixnum? function?
               :
          dummy_t dummy_f dummy_nil]))
    (for 0 (1- (length keys))
         (lambda (i)
           (put! e (aref keys i) i)))))

やっている事は、辞書eを用意。ニューモニックのベクターkeysを用意。for関数で、 順番にベクターをアクセスしながら、ニューモニックを辞書のキーにして、ベクター 番号(これがバイトコードになる)を登録してる。

注: 上記で辞書の事を最初はhashって表現したんだけど、flispではhashを辞書とは(余り) 関係無い関数として登録してある。後で、hashを試してみれ。

> (get Instructions 'nop)
0

> (get Instructions 'cons)
27

そして、何時でもreplから対応を確認出来る。逆に、コードからニューモニックを 欲しければ

(define b2n (table.invert Instructions))

> (get b2n 27)
cons

キーとバリューをひっくり返したテーブルb2nを作っておいて、同様にその表を引いて あげれば良い。

ニューモニックの中はVMのためのアセンブラ名なんだけど、consとか一部がユーザーでも よく使うため、同名になっている。それらは、アリティが正しい事を要求される為、 コンパイラーの中に表を持っている。

(define arg-counts
  (table eq?      2      eqv?     2
         equal?   2      atom?    1
         not      1      null?    1
         boolean? 1      symbol?  1
         number?  1      bound?   1
         pair?    1      builtin? 1
         vector?  1      fixnum?  1
         cons     2      car      1
         cdr      1      set-car! 2
         set-cdr! 2      =        2
         <        2      compare  2
         aref     2      aset!    3
         div0     2))

今、VMの命令が幾つあるか調べたら、94個だった。まだまだ追加の余地は有るぞ。 なお、lisp側と言うかC語で書かれたVM用には、opcodes.hってのが使われている。 0から始まるenumが並んでいるだけだけどね。

コンパイラーの核の部分は、compile-f- のようだ。この最後の部分

          ; compile body and return
          (compile-in g (cons vars env) #t (lam:body f))
          (emit g 'ret)
          (values (function (encode-byte-code (bcode:code g))
                            (const-to-idx-vec g) name)
                  (aref g 3)))))))

bodyをコンパイルしてretを置いて、出来上がった成果を多値にして返してる。 細かい部分のコンパイルは、compile-in で振り分けされて下請け関数が呼び出されている。 なお、各関数の引数gは、結果をemitする為のportを持ち回る為、tail?は、末尾呼び出しへの 対応可能性フラグになる。

(define (expand-define x)
  (let ((form (cadr x))
        (body (if (pair? (cddr x))
                  (cddr x)
                  (if (symbol? (cadr x))
                      `(,(void))
                      (error "compile error: invalid syntax "
                             (print-to-string x))))))
    (if (symbol? form)
        `(set! ,form ,(car body))
        `(set! ,(car form)
               (lambda ,(cdr form) ,@body . ,(car form))))))

;;; in compile-in
         (case (car x)
            :
           (define   (compile-in g env tail?
                                 (expand-define x)))

一つ例を上げる。defineの解析。下請けのexpand-defineに回される。頭の部分formと 本体部bodyを取り出し、後は、頭がシンボルかどうかで場合分け。結果を更にしつこく 再分析。結果がどう展開されるか、確認してみる。

> (expand-define '(define hoge 123))
(set! hoge 123)

> (expand-define '(define (hoge x)(+ x x)))
(set! hoge (lambda (x)
             (+ x x) . hoge))

もう一つ if式の変換を見ておくかな。対応する下請けはcompile-if になる。得意のIT業界産業 構造がみてとれる。conpile-ifは、3次請けだな。ソースコードは、replから実行するには 適さないので、traceしてみます。

> (trace 'compile-if)
ok

> (disassemble (define (bar x y) (if (= x y) x y)))
(compile-if [(2 argc) #table() 0 +inf.0] ((x y) ()) #t  (if (= x y) x y))

maxstack 7
00000:  argc    2
00002:  loada0
00003:  loada1
00004:  =
00005:  brf     @0000a
00008:  loada0
00009:  ret
0000a:  loada1
0000b:  ret

conpile-ifの呼び出す部分は、下記の通り

(define (compile-if g env tail? x)
  (let ((elsel (make-label g))
        (endl  (make-label g))
        (test  (cadr x))
        (then  (caddr x))
        (else  (if (pair? (cdddr x))
                   (cadddr x)
                   (void))))

traceの結果のうち最初のベクターが g に対応。次のリストはenvに対応。#tってscheme風なのが、tail? に 対応。そして最後のS式が、コンパイルしたい内容。

let中でtest式はcadr、thenはcaddr、else部は有るかどうかで、場合分け。じゃ、carを取ると 何か出て来る? それは勿論、if ってのが出てくるけど、ここに至っては、そんなの 上位の関数で確認してるんで不要。なおelselとかは、ジャンプ先のラベルとして必要 なので、事前に作成してる。

このように、問題を小さくして行くと、行う事が明確になり、書き易い、読み易いコード が出来るんだな。(IT業界に取っては、仕事を発注し易い)

問題は、どう問題を分割して行くかが問題であります。(これぞ、再帰的 定義ですよ。)ITゼネコンの一次受けには、頭の切れる人が必要って訳だな。

trace

さらっとtraceなんてのを使ってしまったけど、どう動く?

> (define (hoge x) (* x x))
#fn("7000r1||T2;" [] hoge)

> (define (foo x y)(+ (hoge x) y))
#fn("7000r2e0|31}w;" [hoge] foo)

fooの中からhogeを呼び出す。簡単なやつ。hogeがどんな引数で呼ばれるか調べたかったら、 hogeをトレースする。

> (trace 'hoge)
ok

> (foo 3 1)
(hoge 3)
10

まあ、この例では余り有り難味がないけど、コンパイラー所の例だと、中くらいに嬉しいぞ。(By 一茶) もうトレースが不要ってなったら、untraceします。事前にtraceがイネーブルになってるか 確認しておいた方がよいかな

> (traced? hoge)
#t

> (traced? foo)
#f

> (untrace 'hoge)
newline

> (foo 3 1)
type error: apply: expected function, got newline
#0 (foo 3 1)

と、おかしな事が発生。どうやって調べてくれよう。先にtrace系のソースを見ておくか。

(define (trace sym)
  (let* ((func (top-level-value sym))
         (args (gensym)))
    (if (not (traced? func))
        (set-top-level-value! sym
                              (eval
                               `(lambda ,args
                                  (begin (write (cons ',sym ,args))
                                         (newline)
                                         (apply ',func ,args)))))))
  'ok)

(define (untrace sym)
  (let ((func (top-level-value sym)))
    (if (traced? func)
        (set-top-level-value! sym
                              (aref (function:vals func) 2)))))

マクロくずれが出てきて、脳味噌が沸騰しそうなので、現実に立ち返ってみます。

> (disassemble hoge)
maxstack 7
00000:  argc    1
00002:  loada0
00003:  loada0
00004:  *       2
00006:  ret

平凡なhogeが有ります。

> (trace 'hoge)
ok

> (disassemble hoge)
maxstack 10
00000:  vargc   0
00002:  loadv   #fn(write)
00004:  loadv   hoge
00006:  loada0
00007:  cons
00008:  call    1
0000a:  pop
0000b:  loadg   newline
0000d:  call    0
0000f:  pop
00010:  loadv
        maxstack 7
        00000:  argc    1
        00002:  loada0
        00003:  loada0
        00004:  *       2
        00006:  ret
00012:  loada0
00013:  tapply  2
00015:  ret

トレースを掛けると、何やら挟みこまれました。untraceのコードを抜き出してきて実験 。

> (function:vals hoge)
[#fn(write) hoge newline #fn("7000r1||T2;" [] hoge)]

どうやら、戻すエレメントを間違えているようです。untraceの最後の行にある2を3に 変更したら、正常動作しました。

ああ、正式な修正方法は、system.lspにあるuntrace部分を変更。次に、付属のbootstrap.shを 走らせて、再コンパイル、flisp.bootへの焼付けって手順になります。

C語で書かれたflispがVM(とcons領域を割り当てられたRAM)とすれば、flisp.bootは、VMの 実行コードが収納されたROMに相当します。ROMを焼くなんて、かの昔にH8内蔵のフラッシュを 相手にした以来で、懐かしいな。

juliaに内蔵のflispマシンの更新は、ちと面倒ですから、実施は見送る事にします。 (影では、バイナリーパッチで1箇所変更って言ってますけどね。。。)

でこの虫は、作者さんに SEND PRですかね。ああ、今風に言うと、pull request って言うんでしたっけ? 若しオイラーの要求を受理してくれたら、juliaまで波及するかなあ? juliaの隠れlispは、世に余り知られていないはずだから、無視を決め込む? だったら、要求をパラレル・リクエストすれば良いのか。