SICP 読み (188) 4.2.2 遅延評価の解釈系

直前エントリな reverse は手動 eval が面倒なんで繰り返しなソレではなく、再帰版で検討してみる。

(define (reverse l)
  (if (null? (cdr l))
      (list (car l))
      (append (reverse (cdr l)) (list (car l)))))

l がやたらに出てくる。これはこれで微妙。とりあえず上記の式を eval (というか actual-value か) すると、(procedure (l) (if 式) env) みたいな式が reverse に束縛される、はず。で、(reverse (list 1 2 3)) な式を評価器に吸わせるトコから話を始めてみます。
この式は application 認定なので、reverse を actual-value したものと引数が apply に渡されます。こんな感じ。

(apply (procedure (l) (if 式) env) ((list 1 2 3)) env)

apply においては compound-procedure 認定で以下が適用。

(eval-sequence
 ((if (null? (cdr l)) 
      (list (car l)) 
      (append (reverse (cdr l)) (list (car l)))))
 (extend-environment
  (l)
  (list-of-delayed-args ((list 1 2 3)) env)
  env))

l に delay された (list 1 2 3) が束縛された環境で if 式が eval されます。正確には (thunk (list 1 2 3) env) か。その環境で eval-if がナニ。という事でまず if-predicate な (null? (cdr l)) が actual-value に渡る。式全体が eval されるのですが、この式は application 認定なソレ。null? は the-global-environment に手続きが束縛されておりますので、primitive な式が apply に渡す actual-value から戻ります。
apply においては primitive-procedure 認定なので、引数リストはそれぞれ actual-value されるんですが、ここでも eval の中で application 認定されて、cdr も primitive 認定。で、引数の l が cdr に引数リストを actual-value する時に eval から戻った (thunk (list 1 2 3) env) を force-it する事になります。
ここで memoize な force-it であれば中身の (list 1 2 3) を eval したナニと共に l の束縛を (evaluated-thunk (1 2 3)) に書き換えてしまいます。で、どちらの force-it も '(1 2 3) を戻して cdr に渡る、と。cdr は (2 3) というリストを戻して null? に渡しますが、null? はそこで #f を戻しますので eval-if では if-alternative な (append (reverse (cdr l)) (list (car l))) を eval する事になります。

二周目直前

と言いつつ、append は gauche で用意されているソレを使っているので、そのまま二周目に入ってしまいそう。eval では application 認定されるので apply に append が eval された primitive な式と引数リストが渡されます。それを受け取った apply においては primitive-procedure 認定なんで引数リストは list-of-arg-values に渡されます。

(apply-primitive-procedure
 (primitive 略)
 (list-of-arg-values ((reverse (cdr l)) (list (car l))) env)
 env)

引数リストは先頭から順に actual-value に渡されます。ちなみにこの時点で l は memoize 版であれば (evaluated-thunk (1 2 3)) で、そうでない版であれば (thunk (list 1 2 3) env) に束縛されているはず。

二周目

まず最初の引数 (reverse (cdr l)) を actual-value します。これは途中をはしょって以下のソレになるはず。

(eval-sequence
 ((if (null? (cdr l)) 
      (list (car l)) 
      (append (reverse (cdr l)) (list (car l)))))
 (extend-environment
  (l)
  (list-of-delayed-args ((cdr l)) env)
  env))

eval-sequence な環境は l に (thunk (cdr l) env) が束縛されたナニ。ちなみに thunk の中の env では l が (evaluated-thunk (1 2 3)) 又は (thunk (list 1 2 3) env) が束縛されているはずです。これを前提に二周目。
まず、if 式が eval されて eval-if が適用。まず、if-predicate な式を actual-value します。

(actual-value (null? (cdr l)) env)

ちなみに上の式の環境は直前に書いてある通り。とは言えとりあえず eval されて application 認定な後に null? が actual-value されて primitive な式が戻って apply の中で apply-primitive-procedure 認定 (ぎゃー)。引数リスト ((cdr l)) が list-of-arg-values ですぐに評価。ちょっと微妙に中略なんですが、l が actual-value で 束縛されている (thunk (cdr l) env) を (3) なリストにして戻します。
ちょっと中略し杉。上記の式における l は (thunk (cdr l) env) が束縛されていて、thunk な式の中の l は (evaluated-thunk (1 2 3)) 又は (thunk (list 1 2 3) env) が束縛されているのは上述の通り。で、カワを剥いで eval されたものがさらに cdr された結果、(3) というリストになる、という理解でビンゴかなぁ。
てか、上記 actual-value 式の l は (evaluated-thunk (2 3)) 又は (thunk (cdr l) env) に束縛、という事になるのかな。memoize でない版はこの状態で処理が進むと非常に面倒臭そげ。というのも上述の通り、(thunk (cdr l) env) の中の l は (thunk (list 1 2 3) env) が束縛されている。このネストはこのケースで言えば reverse が終わるまでこのまんまだと思われます。

つづき

で、上の式 (null? (cdr l)) は #f 認定なので if-alternative な式が再度評価、となります。上記で言えば_二周目直前_な部分と言えば良いでしょうか。そろそろリキが無くなってきた。ただ、再帰の中で使いマワシ、といのは O(n) が O(n^2) になる程のインパクトではないけれど、件数が多ければ影響は大きいと言えるのではないかな、と。
で、実機で確認してみました。リストの要素数が 10000 くらいになるとダンマリになるので 10 件から 1000 件くらいまでになっています。多分マシンリソースが最低なんだと思いますが。以下の手続きをわし的評価器に吸わせています。

(define (ll n end)
  (define (ll-iter n e result)
    (if (> n end)
	result
	(ll-iter (+ n 1) end (append result (list n)))))
  (ll-iter n end '()))
(define (reverse l)
  (if (null? (cdr l))
      (list (car l))
      (append (reverse (cdr l)) (list (car l)))))
(define l (ll 1 1000))
(reverse l)

上記は 1000 件版になっていますが、l を定義する時の ll の引数を 10 から順に 100、1000 としています。まず、memoize 版

$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m0.065s
user    0m0.048s
sys     0m0.012s
$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m0.090s
user    0m0.084s
sys     0m0.008s
$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m0.836s
user    0m0.564s
sys     0m0.052s
$

以下が memoize しない版。

$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m0.060s
user    0m0.056s
sys     0m0.004s
$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m0.086s
user    0m0.080s
sys     0m0.008s
$ time gosh lib/4.1.2.scm <./test.scm >/dev/null

real    0m2.530s
user    0m0.568s
sys     0m0.040s
$

うーん。大した差が無いように見えるんですが ...
# って処理時間自体は相当差はありますが。
guile でもやってみたんですが、差が無いな、と思ったら Stack overflow してやがる。1000 件な時点で駄目。gauche スゲぇな。てーか時間かけて色々検討してるんですが、相当な大ボケをぶちカマしてるんだろうな、この結果を見るに。
しかも gauche は連続して実行すると速度が上がるな。例えば 1000 件な memoize ナシの様子が以下。

$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m5.709s
user    0m0.564s
sys     0m0.068s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m2.075s
user    0m0.512s
sys     0m0.056s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m0.610s
user    0m0.524s
sys     0m0.028s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m0.779s
user    0m0.536s
sys     0m0.040s
$

あるいは memoize 版が以下。

$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m1.803s
user    0m0.556s
sys     0m0.036s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m0.622s
user    0m0.580s
sys     0m0.024s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m0.595s
user    0m0.572s
sys     0m0.016s
$ time gosh lib/4.1.2.scm < test.scm >/dev/null

real    0m0.604s
user    0m0.572s
sys     0m0.016s
$

gosh で何らかのキャッシュをしてるんだという事にすると (根拠ナシ) 数値的には差は出てはいるんですが、なんか釈然としないなぁ。