1 of 61

ごみ集めのないLISP

2020年5月 第17回関西Lispユーザ会

@zick_minoh

頭の悪い

2 of 61

Ring Lisp

  • https://github.com/zick/RingLisp
  • ごみ集めのないLISP
  • Ring Lispの「リング」ってなんやの?
    • むかし、そんな映画があったのよ。

これの話をします

3 of 61

ここでの「ごみ集めのないLISP」とは

   線形論理を応用したごみ集めを必要としないLISP

みんな大好き線形論理

4 of 61

ここでの「ごみ集めのないLISP」とは

   線形論理を応用したごみ集めを必要としないLISP

   必要か知らんけど面倒なのでごみ集めのないLISP

頭の悪い処理系

5 of 61

自作LISP処理系の神話

  • メモリがたくさんあればごみ集めがなくても大丈夫
    • 「たくさん」ってどれだけ?
  • 小さいプログラムを動かすだけならごみ集めはいらない
    • 「小さい」ってどのくらい?
  • ごみ集めがついてる言語で作れば大丈夫
    • これは今回は扱わない

あいまいな主張

6 of 61

目標

ごみ集めを書かずに

LISP処理系を作れるか検証する

実は後付の目標

7 of 61

前提

  • コンスセルは連続した領域に割り当てられる
  • 領域のサイズは固定で実行時に変動しない
  • コンスセル以外のオブジェクトは考慮しない
    • 数はfixnumのみ
    • 関数・クロージャはリストで表現する
    • 環境は連想リストのリストで表現する
    • シンボルを動的に作る手段は提供しない
  • 最適化は頑張らない

つまり頑張らない

8 of 61

コンスセルの割り当て

使用済み

空き領域

head

Heap

左から右へ

9 of 61

コンスセルの割り当て(実装)

uint8_t* alloc_head;

void* alloc() {

void* ret = reinterpret_cast<void*>(alloc_head);

alloc_head += kWordSize;

return ret;

}

kWordSize == 16

10 of 61

終端問題

終端まできたら割り当てができなくなる!

使用済み

空き領域

head

右端で止まる

11 of 61

メモリが一杯になったら

  • 素直にごみ集め
  • 世を儚んでクラッシュ
  • ごみは集めたくないが死にたくもない

www.nichinoken.co.jp

わがままな要望

12 of 61

シカクいメモリをマルくする

new!

すでに空き領域は

残っていないが

終端がないので

どこまでも

割り当て可能

無限メモリーの幻想

13 of 61

シカクいメモリをマルくする(実装)

void* alloc() {

if (alloc_head >= cons_area_end) {

alloc_head = cons_area_begin;

}

void* ret = reinterpret_cast<void*>(alloc_head);

alloc_head += kWordSize;

return ret;

}

右端に行くと左端に戻る

14 of 61

コンスセルの上書き (1/3)

X = (cons 1 nil) ; => (1)

1

これは自明

15 of 61

コンスセルの上書き (2/3)

X = (cons 1 nil) ; => (1)

Y = (cons (something 2) X)) ; => (2 1)

1

2

すでにヒープが一杯に

16 of 61

コンスセルの上書き (3/3)

X = (cons 1 nil) ; => (1)

Y = (cons (something 2) X)) ; => (2 1)

Z = (cons 3 Y) ; => (3 2 1) ???

1

2

2

3

上書きされた

17 of 61

上書きは問題なのか (1/2)

2

3

X

Z

Y

問題のある変数とない変数

変数

想定

実際

X

(1 . nil)

(3 . Y)

Y

(2 . X)

(2 . Z)

Z

(3 . Y)

(3 . Y)

18 of 61

上書きは問題なのか (2/2)

  • 古いポインタで上書きされたコンスを操作するのが問題
  • 上書きの影響を受けないポインタは自由に使える
  • 古いポインタを使わないのはプログラマの責任とする
    • とはいえエラーは検出したい

2

3

X

Z

Y

プログラマが悪い

19 of 61

ポインタの先が上書きされたか判断する (1/2)

  • コンスの「世代」を導入する

未割当

第0世代

0周目

世代別GCの世代とは違う

第0世代

第1世代

1周目

20 of 61

ポインタの先が上書きされたか判断する (2/2)

  • ポインタにコンス割当時点の世代を埋め込む
  • 本当の世代はheadの位置と周回で分かる
  • ポインタの世代と本当の世代が違えばエラー

第0世代

第1世代

1周目

X(第0世代)

Z(第1世代)

Y (第0世代)

ポインタを触るときに判断

21 of 61

ポインタの先が上書きされたか判断する(実装1/2)

int generation;

uintptr_t makeCons(uintptr_t car, uintptr_t cdr) {

Cons* cons = reinterpret_cast<Cons*>(alloc());

cons->car = car;

cons->cdr = cdr;

uintptr_t ret = reinterpret_cast<uintptr_t>(cons);

ret |= (generation << 1);

return ret;

}

0

G

G

G

A

A

A

A

A

A

A

A

…...

アドレス(下位4bitは省略)

世代

コンスは16bytes

22 of 61

ポインタの先が上書きされたか判断する(実装2/2)

int gen(uintptr_t obj) {

return static_cast<int>((obj & (kWordSize - 1)) >> 1);

}

int current_gen(uintptr_t obj) {

void* head = reinterpret_cast<void*>(alloc_head);

if (ptr(obj) >= alloc_head)

return (generation - 1) & ((kWordSize - 1) >> 1);

return generation;

}

0

G

G

G

A

A

A

A

A

A

A

A

…...

世代

アドレス(下位4bitは省略)

genとcurrent_genを比較

23 of 61

ここまでの考察

  • リングバッファを使うことで無限にコンスが作れる
  • ごみ集めはオブジェクトが生存しているか事前に判断
  • 本手法はオブジェクトが生存していたか事後に判断
  • すべてが weak pointer ともいえる
    • ポインタを正しく扱うのはプログラマの責任

無理やりな考察

24 of 61

ここまでで何ができるか (1/2)

> (cdr '(a b c))

(b c)

> (cons 1 (cons 2 (cons 3 ())))

(1 2 3)

> (defun fact (n) (if (eq n 0) 1 (* n (fact (- n 1)))))

fact

> (fact 10)

3628800

突然のLISP

25 of 61

ここまでで何ができるか (2/2)

> (defun gen (n) (lambda (m) (setq n (+ n m))))

gen

> (setq x (gen 100))

expr

> (x 10)

110

> (x 90)

200

普通に動く

26 of 61

ここまでで何ができないか (1/2)

> (defun sum (n) (if (eq n 0) 0 (+ (sum (- n 1)) n)))

sum

> (sum 10)

55

> (sum 100)

... generation: 1

<stale value: 7fb340d00890>

上書きされてエラー

27 of 61

ここまでで何ができないか (2/2)

> (sum 50)

<error: sum has no value>

> (cdr '(a b c))

<error: cdr has no value>

くだらない

28 of 61

前提(再掲)

  • コンスセルは連続した領域に割り当てられる
  • 領域のサイズは固定で実行時に変動しない
  • コンスセル以外のオブジェクトは考慮しない
    • 数はfixnumのみ
    • 関数・クロージャはリストで表現する
    • 環境は連想リストのリストで表現する
    • シンボルを動的に作る手段は提供しない
  • 最適化は頑張らない

環境破壊

29 of 61

環境保全

  • ヒープの先頭にあるのは大域環境
    • 組み込み関数が真っ先に壊れる
  • 組み込み関数の入った大域環境をまるごと保護

組み込み関数

(上書き不可)

実はいらないけど便利

30 of 61

環境保全(実装)

void* alloc() {

if (alloc_head >= cons_area_end) {

alloc_head = saved_area_end;

generation = (generation + 1) & ((kWordSize - 1) >> 1);

}

void* ret = reinterpret_cast<void*>(alloc_head);

alloc_head += kWordSize;

return ret;

}

保護領域の直後に戻る

31 of 61

きちんと動く再帰関数を書く

  • 総和を求める関数sumの何が悪かったのか考察する
  • アロケータを変更することなくLISP側で動く関数を書く

主に頭が悪い

32 of 61

局所環境・一時データの上書き

(defun sum (n)

(if (eq n 0)

0

(+ (sum (- n 1))

n)))

再帰してるうちにnのことを忘れてしまう

(※) 環境は連想リストのリストで表現する

局所環境

一時データ

1回の関数呼出で

使うデータ

(フレーム)

過去を振り返ったらだめ

33 of 61

大域環境・関数本体の上書き

(defun sum (n acc)

(if (eq n 0)

acc

(sum (- n 1) (+ acc n))))

再帰してるうちにsumが壊れてしまう

(※) 関数・クロージャはリストで表現する

局所環境

一時データ

関数本体

大域環境(一部)

昔に作ったものは壊れる

一度しか作られない

毎回作るフレーム

34 of 61

評価規則の拡張

  • 先頭が lambda のリストは関数として扱う
  • クロージャと異なり環境への参照を持たない
  • ただのリストなのでコピー可能

(setq f '(lambda (x) (cons x x))) ; => ...

(f 3) ; => (3 . 3)

((copy f) 3) ; => (3 . 3)

; (eval `(,(copy-tree f) 3)) ; CL version

実のところEVALと同じ

35 of 61

これなら動く総和関数

(setq sum

'(lambda (fn n acc)

(if (eq n 0)

acc

(fn (copy fn) (- n 1) (+ acc n)))))

; (sum sum 100 0) => 5050

環境

一時データ

関数本体

壊れる前に作り直す

毎回作るフレーム

36 of 61

再帰関数の書き方

  • まず末尾再帰で書き直す
  • 再帰を「引数として受け取った関数」の呼び出しに直す
  • 引数として渡す関数はcopyで複製する
  • でも末尾再帰で書けない関数は?

末尾再帰は実質反復

37 of 61

末尾再帰で書けない関数

  • アッカーマン関数 Ack(m, n)
    • Ack(0, n) = n + 1�Ack(m, 0) = Ack(m - 1, 1)�Ack(m, m) = Ack(m - 1, Ack(m, n - 1))
    • 反復では書けないらしい。知らんけど。
  • 反復しか使えない言語で実質的に再帰をしたいときは?
    • スタックを使う
    • リストはスタックとして使える

証明は読者の演習課題

38 of 61

アッカーマン関数: 普通の定義(動かない)

(defun ack (m n)

(if (eq m 0)

(+ n 1)

(if (eq n 0)

(ack (- m 1) 1)

(ack (- m 1) (ack m (- n 1))))))

読みやすくしただけ

39 of 61

アッカーマン関数: スタックを使う(動かない)

(defun ack (m n s)

(if (eq m 0)

(if (eq s nil)

(+ n 1)

(ack (car s) (+ n 1) (cdr s)))

(if (eq n 0)

(ack (- m 1) 1 s)

(ack m (- n 1) (cons (- m 1) s)))))

外側のackを後で呼ぶ

40 of 61

アッカーマン関数: 自己複製(動く)

(setq ack '(lambda (fn m n s)

(if (eq m 0)

(if (eq s nil)

(+ n 1)

(fn (copy fn) (car s) (+ n 1) (copy (cdr s))))

(if (eq n 0)

(fn (copy fn) (- m 1) 1 (copy s))

(fn (copy fn) m (- n 1)

(cons (- m 1) (copy s)))))))

スタックもコピー

41 of 61

真・再帰関数の書き方

  • 必要ならスタックを使って)末尾再帰で書き直す
  • 再帰を「引数として受け取った関数」の呼び出しに直す
  • 引数として渡す関数及びスタックはcopyで複製する

複製したら死なない

42 of 61

よりLISPらしいプログラムを書く

  • スタックを管理するのはあまりLISPっぽくない [要出典]
  • 継続を渡すと少しはLISPっぽくなる [要出典]
  • 継続も先頭がlambdaのリストで表現できる
    • ただし外側の変数にはアクセスできない
    • 変数がimmutableなら値を埋め込める

個人の感想

43 of 61

アッカーマン関数: 継続渡し(動かない)

(defun ack (m n k)

(if (eq m 0)

(k (+ n 1))

(if (eq n 0)

(ack (- m 1) 1 k)

(ack m (- n 1) (lambda (n) (ack (- m 1) n k))))))

; (ack 1 2 (lambda (x) x))

継続のk

44 of 61

アッカーマン関数: 継続渡し+複製(大飯食らい)

(setq ack '(lambda (fn m n k)

(if (eq m 0)

(k (+ n 1))

(if (eq n 0)

(fn (copy fn) (- m 1) 1 (copy k))

(fn (copy fn) m (- n 1)

(list 'lambda '(n)

(list (copy fn) (list 'quote (copy fn))

(- m 1) 'n (list 'quote (copy k)))))))))

複製と継続と私

45 of 61

アッカーマン関数: 継続渡し+複製+最適化

(setq ack '(lambda (fn m n k)

(if (eq m 0)

(k (+ n 1) fn)

(if (eq n 0)

(fn (copy fn) (- m 1) 1 (copy k))

(fn (copy fn) m (- n 1)

(list 'lambda '(n f)

(list 'f 'f (- m 1) 'n

(list 'quote (copy k)))))))))

関数を注入

46 of 61

自作LISP処理系の神話(再掲)

  • メモリがたくさんあればごみ集めがなくても大丈夫
    • 「たくさん」ってどれだけ?
  • 小さいプログラムを動かすだけならごみ集めはいらない
    • 「小さい」ってどのくらい?
  • ごみ集めがついてる言語で作れば大丈夫
    • これは今回は扱わない

「たくさん」とは

47 of 61

必要なメモリの量を求める

  • 組み込み関数が(大域環境に)使う量は固定
  • ユーザが大域環境に使う量は少量
  • 関数呼び出しで使用するコンスが大部分を占める
    • 引数の値を覚えるための局所環境
    • 式を評価する際に作られる一時データ
    • 関数及びスタックなど
      • 正確にはこれも一時データ

フレームを考える

48 of 61

必要な関数本体の数

(setq sum

'(lambda (fn n acc)

(if (eq n 0)

acc

(fn (copy fn) (- n 1) (+ acc n)))))

呼び出された関数

渡された関数

複製された関数

3つの関数が同時に生存する必要がある

ひどい無駄

49 of 61

同一の関数の使用

(setq sum

'(lambda (fn n acc)

(if (eq n 0)

acc

((lambda (f) (f f (- n 1) (+ acc n)))

(copy fn)))))

2つの関数が同時に生存できればよい

まともになった

50 of 61

関数呼び出しに必要なメモリ量

  • 引数の値を覚えるための局所環境
    • 引数の数 × 2 くらい
  • 式を評価する際に作られる一時データ
    • 評価される実引数の数くらい(実装依存)
  • 関数及びスタックなど
    • 2倍の量が必要
    • 複雑なプログラムほどここが占める割合が増える

つまり2倍

51 of 61

一般的なプログラムを書くための考察

  • 必ず末尾再帰の形に直す
  • 必要なものは引数で引き回す
  • 必要なコンスは毎回コピーする
  • メモリは 引数で渡すコンスの倍量+α 必要

見覚えのある条件

52 of 61

一般的なプログラムを書くための考察

  • 必ず末尾再帰の形に直す
  • 必要なものは引数で引き回す
  • 必要なコンスは毎回コピーする
  • メモリは 引数で渡すコンスの倍量+α 必要

これって copying GC なのでは?

知ってた

53 of 61

目標(再掲)

ごみ集めを書かずに

LISP処理系を作れるか検証する

検証した

54 of 61

結論

ごみ集めを書かずに

LISP処理系を作ると

LISPでごみ集めを書くことになる

ごみのような結論

55 of 61

付録

56 of 61

超循環評価器 (1/4)

  • eval / apply など主要な関数を末尾再帰的に書く
    • スタックを活用する
  • すべての関数を引数で引き回す
    • 大域環境は(組み込み関数以外)壊れる
    • 複製は適切なタイミングで

EVALをかいてエバる

57 of 61

超循環評価器 (2/4)

(setq reval

'(lambda

(self-eval? cont appexpr search-bind rmbind update-env rapply

update-stack reval exp env stack)

(if (self-eval? exp)

(cont (copy self-eval?) (copy cont) (copy appexpr)

(copy search-bind) (copy rmbind) (copy update-env)

(copy rapply) (copy update-stack) (copy reval) (copy exp)

(copy stack))

;;; 中略

(if (eq (car exp) 'if)

(reval (copy ...) (copy (cadr exp)) (copy env)

(cons 'if

(cons (copy (cddr exp))

(copy (cons env stack)))))

;;; 後略

関数を引数で引き回す

58 of 61

超循環評価器 (3/4)

(reval self-eval? cont appexpr search-bind rmbind update-env

rapply update-stack reval

'((lambda()

(defun f(n acc)

(if (eq n 0)

acc

(f (- n 1) (+ n acc))))

(f 100 0)))

;;; 略

)

ごみを気にしない

なんと普通のLISPが書ける

59 of 61

超循環評価器 (4/4)

  • 処理系層 (C++)
    • コンスはリングバッファに割り当て
  • 超循環評価器層 (LISP)
    • 必要なコンスは適切に複製
    • 不要なコンスは放置
  • Meta LISP層 (LISP)
    • コンスの不要・必要を気にしない

LISPでメモリ管理

60 of 61

(関連分野)Linear Lisp

  • すべてのコンスセルの参照カウントが常に1
    • 最初はfree listに繋がれて、使ったらfree listに戻す
  • すべての局所変数はちょうど1回アクセスされる
    • 2回触りたいときは複製を作る
  • (普通の)Lisp評価器を書くと面白い
    • 評価する式も1回しかアクセスできない
    • 式も複製する必要がある
    • 環境さえも複製しないと壊れてしまう
    • これどこかで見たような......

比較するのが失礼

61 of 61

(関連分野)Cheney on the M.T.A.

  • Schemeを継続渡し形式のCに変換
  • オブジェクトをCのスタックに割り当て
  • スタックが長くなるとごみ集め
    • 生存するオブジェクトをヒープにコピー
    • スタックは巻き戻す
    • すべてが継続渡しなので戻り先アドレスは不要
  • 「決して戻らない」という点は共通
    • ただし生存するオブジェクトは自動でコピーされる

比較するのが無礼