情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU...

134
II 1 最終目標 : これから する (Mini-Scheme いう Scheme サブセット) いし, した , CPU コンパイラ , , Ray Tracing かす. Ray Tracing ソースを する (~tau/comp/RT_nagano. 1995 による . にコピーして さい). する また コンパイラに ある , レポートにま める (1 ). , 「演 った」 いう があれ . Ray Tracing プログラム チューニング, ために データ . てが かった : コンパイラが した , assembler ray tracing かす. CPU した , コンパイラ machine ( , sparc) コード いて . 2 演習の進め方 (1 , しかし るか から ) てる. コンパイラ する. 1

Transcript of 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU...

Page 1: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

情報科学実験 II資料

田浦

1 最終目標• 班全体の達成目標:

– これから授業で説明する言語 (Mini-Schemeという Schemeのサブセット) ないし, 独自に設計した高級言語の, 自作CPU用コンパイラ実装し,

– その言語で, Ray Tracingを動かす.

– 演習で作る言語用のRay Tracingのソースを提供する (~tau/comp/RT_nagano.

1995年の長野君によるもの. 自由にコピーして下さい).

• 個人の達成目標

– 今後紹介する論文またはコンパイラに関係のある最近の論文を読んで, レポートにまとめる (一人 1編).

– その他, 何でも「演習をやった」という証があれば良い. 例えばRay Tracingプログラムのチューニング, 来年の人のためになる評価データなど.

以下は全てが達成できなかった場合の典型的な逃げ道:

• コンパイラが挫折した場合, assemblerで ray tracingを動かす.

• CPUが挫折した場合, コンパイラ担当者は既存 machine (例えば,

sparc)用のコード生成器を書いても良い.

2 演習の進め方毎回前半 (1時間程度の予定, しかし実際どうなるかは分からない)を説明に当てる. コンパイラ作成に必要なことは一応全て説明する.

1

Page 2: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

3 進度予定もちろんこんなものは一応書いてみただけで, このとおり進む可能性は低い.

1. 概要説明

2. 作成するコンパイラのおおまかな構造,構文解析

3. 中間コードContinuation Passing Style (CPS)とCPSへの変換

4. CPS上の最適化

5. Closure Conversion

6. Abstract Machineコード生成

7. マシンコード生成

8. Garbage Collection

9. 予備日

10. 予備日

11. 予備日

12. 予備日

4 CPU Architecture設計上の注意書このコンパイラの実験では, できるだけ単純に, 楽にコンパイラを作ることを目標にする. 逆に, 話しをできるだけ単純にするために犠牲にしている, あるいはさぼる予定である項目をあげる. この犠牲, あるいはさぼりが全体に甚大な影響を与えないために, 自作CPUに対して仮定している事項を書いておく. もちろん CPUを全くこれに沿って作らなくても,

逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすので, どうするべきかは微妙な問題で, 個々の班の決断に委ねる. ここではこれから授業で説明する予定の compilation方法に関する情報を判断材料として与える.

これから説明するコンパイル技法は,

2

Page 3: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• 全ての変数の sizeが同じで

• 数多くの registerを持ち,

• 3 operand register間演算が基本となっている

言語および architectureを, もっとも自然に supportするように作られている. まず, 最初の項目の具体的に意味するところは, 浮動小数点も整数もことごとく同じ大きさであるということで, 特に ray tracingのために浮動小数点は 32 bitなくてはいけないから,結果として全て 32 bitになる.

2番目の項目の詳しい内容は以下の通り. コンパイラは最終的に中間変数の値を保持すべき場所を registerに割り当て, ある時点でもういらない (今後使われない)registerの値は他の変数に割り当てるようにする. ここでややこしいのは, もし同時に必要な registerの数がmachine register

の数を越えたらどうするかということである. 実際にはこれはmachine

registerの数が多くあれば (たとえあ 32個)非常に稀になるが, それでもおきてしまった場合は, 何らかの形でそれをmemoryに退避する必要がある(これを register spillingと呼ぶ). そのmemroyをどのように確保し, またコンパイラが現在どの値は registerに載っておりどの値がmemoryに退避されているかを扱わなくてはいけないのはそれなりに大変な作業である.

去年は, spillingは授業で扱っていない.

注意としては, 例えば, 全ての変数を同じ sizeにして, かつ 1 registerが16 bitの場合一つの変数につき registerを二つ消費し, 結果として register

の数は半分になったように見えるということである. だから 16 bit x 32

の場合, 同時に保持できる変数の数は 16個ということになる.

最後の項目は, コンパイルに使用する中間言語での演算がこの形をとるということである. Accumulator machineで3 operand machineをemulate

できるが, naiveな emulateでは遅くなるため, 無駄なmoveの除去など,

新たな最適化をする必要が生ずる.

5 参考文献[1]は伝統的なPascal,C等の言語 (しばしばAlgol-likeと呼ばれる)のコンパイル法について詳しく述べている.中間言語しては, いわゆるQuad

(4つ組)を用いており, その上での optimization (dataflow analysis)やregister allocationなどが詳しく述べられている. SchemeやMLなどの

3

Page 4: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

modernな言語については詳しく述べられていないが,例えば compilerの全体の構成法なども述べられており, compilerに関する予備知識が少ない場合でも楽に読める.

講義の内容は,[3]をもとにしている.これはおもに SML/NJのコンパイラについて書かれた本で,もちろん話の大部分は Schemeにも当てはまる. Closure, call/ccなどの実現法が詳しく述べられている. また中間言語としてはCPSを使っている. 講義ではこの本の内容を,対象言語を単純化してできるだけ分かりやすくして説明する.また, Cなどの伝統的なcompilerに関する知識を仮定してはいないが, それらを知っていた方が理解はしやすい.

Garbage Collectionに関する surveyは, [2]がよい文献である.

6 作成する言語演習で実装する言語は Schemeの subsetで, 大体 Schemeから以下のものを取り除いたものである.1

• set!

• call-with-current-continuation

• いくつかの data type

実際には, とりあえず上のようなあまり powerfulとはいえない仕様にしながらも, 授業で扱う枠組 (CPS)は, 少しの変更で set!, さらには call-

with-current-continuationまでをも扱えてしまうものである. とりあえず ray tracingを動かすための「さぼり」を極限までつきつめたものである. より advanceな featureの扱い方に関しては, 授業で適宜触れることにする.

さて, 実装する言語は二つの layerからなる. 一つは compilerを極力単純化するための本当に必要最小限の機能を持つ, “Min-Scheme”(または“Min-Scheme Fix”という layerで, その上にもう少しだけ richな構文を備えた “Mini-Scheme”(または “Mini-Scheme Fix”)である. Mini-Schemeは簡単な構文上の置き換えによって, Min-Schemeに変換可能な言語である.

Fixのあるなしは, それが自由変数を持つ関数 (局所関数の定義)をゆるすかどうかの違いで, Fixを入れると言語は非常に richになる.

1もちろん標準的なライブラリ関数群 (string関係など)を全てまじめに実装する必要はない. ここではあくまで言語の本質的な構文で, 取り除かれているものをあげた.

4

Page 5: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

7 文法Expr = Id

| Constant

| (if Expr Expr Expr)

| (let (Bind*) Expr)

| (Primsym Expr*) ; primitive call

| (Expr Expr*) ; user-defined functions

Bind = (Id Expr) ; let-bind

Definition = (define Id Expr) | (define (Id Id*) Expr*) ; toplevel definition

Constant = Integer | Float | Boolean | ’()

Primsym = +-two | --two | >>-two | <<-two

| <-two | >-two | <=-two | >=-two | =-two | eq?

| heap | rref | rset!

+-two, --twoなどはそれぞれ 2引数の足し算や引き算を行なうprimitive

である. ただし, 何を primitiveとするかはコンパイラによって異なっていて良いので, Primsymに関してはあくまで例.

説明が必要なことがあるとすれば, heap, rref, rset!の各 primitive

で, それぞれ heapからの memoryの割り当て, heapに割り当てられたrecordの参照 (vector-refのようなもの), rset!は recordの更新である.

例えば,

(let ((r (heap 0 1 2 3)))

(rset! r 0 10)

(+-two (rref r 0) (rref r 1)))

は, 11を返す. 最初に “0, 1, 2, 3”という 4つの fieldを持つ recordが作られ, 次にその第 0 fieldを 10に更新する.

8 Mini-Scheme (Min-Scheme + Syntax Sug-

ars)

さて, Min-Schemeの仕様は本当に必要最小限なものであり, 不便な点が多い. 例えば良く使うものとしては, condがない. 実際にはこれは if

の入れ子として実現できる. 他にも任意個の引数を受けとる足し算: +が

5

Page 6: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

使いたければ, これもまた 2引数の足し算+-twoを入れ子にすれば良い.

このように, 多くの構文が簡単な source → sourceの構文的な変換で実現できる. このような, 本質的ではないがプログラマにとっての便利を良くする構文を, 通常, syntax sugarと呼んでいる. それに対して, そうでないものをとりあえず, essential syntaxと呼んでおこう. Compilerを作る際に essential syntaxの数をできるだけ少なくして, 多くの機能を syntax

sugarを使って実現すれば, compilerが多くの機能を真面目に supportする必要はないことになる.

以下では,上で定義したprimitiveを利用しながら,多くの言語construct

を syntax sugarとして実現して, 言語を richにしていく.

8.1 Let*

Let*は,letと似ているが,導入される変数のスコープ (使用可能な場所)が違う. letで導入された変数のスコープは let式の body部であるのに対し, let*で導入された変数のスコープは, body部および後続の変数の初期化式である. つまり,

( l e t ( ( x i n i t \ x )

(y i n i t \ y )

( z i n i t \ z ) )

body )

において, xのスコープはbody内,一方,もしこれが let*であったら, init y,

init z, および bodyとなる.

例えば,

( d e f i n e ( f x )

( l e t ( ( x 1)

( y x ) )

. . . ) )

において,

( y x )

の xは, 引数の xを参照する. 一方これが let*だと, すぐ上で導入されたx(値は 1)を参照することになる.

Let*の実現は簡単で, ただの nestした let式である. 例えば,

6

Page 7: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( l e t ∗ ( ( x 1)

( y x ) )

body )

は,

( l e t ( ( x 1 ) )

( l e t ( ( y x ) )

body ) )

と等価.

8.2 begin

逐次実行も実に簡単で, 容易に等価な let*に変形できる. 例えば,

( begin

exp0

exp1

exp2 )

は,

( l e t ∗ ( ( a0 exp0 )

( a1 exp1 )

( a2 exp2 ) )

a2 )

に等価. ここで, a0, a1, a2は exp0, exp1, exp2のどれにも現れない変数.

8.3 cond

Condが if文の羅列に変換できることは自明であろう. 例だけを示すと,

( cond ( a0 e0 )

( a1 e1 )

( a2 e2 )

( e l s e e ) )

は,

7

Page 8: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( i f a0 ( begin e0 ) ( i f a1 ( begin e1 ) ( i f a2 ( begin e2 ) ( begin e ) ) ) )

と等価 (ここで各 e ?は一つまたは複数の式の並びを表す).

8.4 任意個引数の算術演算

簡単な話しで, (+ x y z)を (+-two (+-two x y) z)のようにすれば良い.

8.5 Cons, car, cdrなど

これらはそれぞれ, heap, rref, rset!などを使って実現できる. もちろん heapは consセル以外のもの (例えば配列) にも使われるから, 単純に,

(cons x y) => (heap x y)

としてしまうのは厳密には正しい実現とはいえない. 厳密にいえば, cons

セルには consセルだとわかるための tagをつけるなどして, 他の dataと区別する, つまり,

(cons x y) => (heap CONS_TAG x y)

のようにする必要がある.2 しかし今回はこれには拘らないことにする.

9 何をprimitiveとするかの判断基準についてCompilerがどの演算を primitiveとして supportすべきは, 絶対的な解答があるわけではない. むしろそれは target machineの持つ命令 setに依存している. 例えばもし target machineがかけ算命令を supportしていれば, compilerがかけ算を primitiveとして持つことは十分に合理的である.

逆にもし, 任意 bit数の shift命令がなければ, shift命令すら primitiveとしないことも考えられる.

したがってその辺は, compiler担当者およびCPU担当者の間で十分情報交換および議論を行なった上で, 決めて欲しい.

2実際の Schemeの implementationでは cons セルなどの頻繁に使用される dataのための tagは pointerの中の 1 bitを使って表されているのが普通である.

8

Page 9: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

一般的な法則としていえるのは, primitive演算はmachine命令によって supportされる命令かまたは, 少数の中間 register のみを使用して実現できる命令に限られるということである. このことの事情を少し説明しておこう.

例えばかけ算命令を support しない machine で compiler がかけ算をprimitiveとして, 採用してしまったとする. すると compilerは最終的な code generation の段階になって各 primitiveを実際のmachine命令に展開することになる. かけ算命令は 1命令として supportされていないのでいくつかの命令列に展開される (このこと自体は全く悪いことではない)が, そこでおそらくいくらかの中間 registerを破壊/使用することになる. しかし code generation時にはすでに register allocationは終っていて,

もし registerを使いたければそれらの registerをどこかに saveして, 演算が終ったらまた元に戻すということをしなくてはならなくなる. それがいやならかけ算命令が必要とする registerの分だけは絶対に通常目的には使用しないように, あらかじめ予約しておくことである.

ここで, 後者の方法はもしその予約 register数が少なくて, かつ他の目的 (例えば割算)にも有効利用できるのであればそれなりに良い選択である (班毎に議論せよ). しかしそれらの registerがもったいないような気はする. 一方, 前者は, 一般的だが無駄な save/restoreが増える嫌いがある.

実際にその場面ではたまたま何の save/restoreもいらないかも知れないにも関わらず, 保守的に多くの registerを save/restoreしなくてはならない.

もっと aggressiveな方法として, compilerがその場その場で適切な空いている registerを選んで展開するという方法もあるが, compilerの作りとしてはかなりややこしくなる.

結局のところ, 前者をやるくらいであれば, それらの処理は「外部関数呼びだし」という形で実現するのがもっともすっきりとした解決方法であるように思える. つまり, compilerにとってそれはただの「unknownな」関数呼び出しにしか見えない. したがって compilerは関数から復帰するための手続きを行なった後, その関数への引数をmachine conventionから定まる regsiterに並べる. 後はその関数の bodyを, Schemeなり, もしSchemeからは accessしにくい機能があれば assemblyなりで実現すれば良いのである. 関数の先頭部分では, 基本的にはどの registerが使用中で,

どの regsiterは空いているかはmachine conventionにより定まっている.

以下はいくつかの微妙な (target machine毎に戦略が異なるかも知れない)部分の実現法の例である.

9

Page 10: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

9.1 任意bit数の shift

任意 bit数の shiftをCPUが直接 supportするのが大変であれば, 1 bit

shiftがあれば, 任意 bit数の shiftは, 以下のように実現できる.

(define (shift x y)

(if (= y 0) x (shift (shift1 x) (- y 1))))

また, shift数 (y)がたまたま定数であれば, loopをまわす必要はない.

そこで, 任意 bit数 shiftを supportしないmachineでは, それはmacro

として実現すると良い.

• もし yが小さな定数だったら, たとえば,

(shift x 3) => (let* ((xx (shift1 x))

(xxx (shift1 xx)))

(shift1 xxx))

• そうでなければ, 一般的な shift routineを呼ぶ.

9.2 かけ算, 割算

これらについても任意 bit数の shiftと似たような考察が当てはまる.

9.3 浮動小数点演算

おそらく, 外部関数呼び出しにしてしまうのが良いだろう.

9.4 動的に sizeが決まる配列

(cons x y)など, 割り当てる sizeが compile時にわかるものとそうでないものとでは, 多少複雑さが異なる. 例えば copying GCを採用する場合, size が compile時にわかる場合, heap limit checkは 1 compare命令ででき, record の初期化も, ただのmemory storeの連続である.

一方 sizeが動的に決まる場合, 例えば, (make-vector n 10)のような場合, これを heapから割り当てるのには, loopをまわしながら初期化せねばならないし, heap limit checkも, 少なくとも足し算をして compare

10

Page 11: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

をしなくてはならない. おそらく結構な数の中間 registerを必要とするだろう.

以上のような理由から, おそらく, これも外部関数呼び出しにしてしまうのが良いだろう.

10 Lambda closureの実現♠

10.1 概要

さて, Min-Schemeには lambda closureというものがない. 実際のところこれで “Scheme compilerの作り方”とするのはどうも気が引ける.

Compilerの実装, という観点から見て, lambda closureを supportする言語とそうでない言語の違いは何かというと, 前者は,

自由変数を持つ関数を許している

点にある. いいかえれば局所関数定義を許しすような programming言語があれば, 表面的に lambda closureを supportしていなくても, compiler

の作りという観点からは同じことである.

このことをもう少し詳しく説明してみよう. 例えば, 局所関数定義を許さない, C言語を例にとってみる. C言語では, local変数の値は全て今の関数が起動されてからその関数内で代入された値である. その関数の外で代入され, 現在も値がそこに残っていることが保証された local変数というものは存在しない. 一方, Scheme, ML, Smalltalkなどの言語ではそうではない. 例えば Schemeでは,

(define (make-adder x)

(lambda (y) (+ x y)))

の, 関数 (lambda (y) (+ x y))において, xという変数は, この lambda

式が起動されてから bindされるわけではない. xはこの lambda式が起動されるずっと前に bindされて以来, その値を未来永劫保持しているのである. MLでも lambda式と全く同様の, fnという constructが存在するが, 別の書き方として,

fun make_adder (x : int) =

let fun a y = x + y

in a

end;

11

Page 12: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

のような書き方もある. これは表面的には lambda closureを使っていないが, 中で局所的に定義される関数 aの中で, その関数が起動される以前から bindされている変数 xを読みにいくという点で, 同じ問題を抱えている. Implementationの観点からは, lambda式は, 上のように局所的な関数に名前をわざわざつける手間を省くための簡便な構文にすぎず, 本質は自由変数を持つ局所的な関数が定義できるところにある. Smalltalkにも, block式という, lambda式と同じことをする構文がある.

さて,このことが implementationに与える影響は何かというと, Scheme

やMLでは, 関数の canonicalな表現として,

関数のコード (の address) + 自由変数の値の組

をとらなくてはいけない, ということである. Cのように local変数の値が全て起動時およびその後に定まるような言語では, 関数は, 関数のコードの addressで表現しておけば足りる. 例えばCで,

int f (x)

{

printf ("f’s address is %x", f);

}

とした時にでてくるものは, fの命令列が書かれている codeの addressである.

int apply (f, x)

int (* f)(); int x;

{

return f (x);

}

とした時に, compilerは最終的に, fの addressに制御を移そうとする (gcc

-Sしてみよ).

しかし関数が自由変数を持つかも知れない言語ではそうはいかず, 関数は自由変数の値を保持しておかねばならない. つまり, 関数の正体はrecordであり, その recordの中身は, 先頭が codeの address, それ以降に自由変数の値が羅列されているものである.3

3code addressが先頭に書かれている必要はないが, 常に決まった offsetに書かれていなくてはならない.

12

Page 13: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(define (make-adder x)

(lambda (y) (+ x y)))

で起こることは大体以下のようなことである (文法はめちゃくちゃ).

int * make_adder_code (fv, x)

{

/* allocate new record on heap */

return new { anonymous_0, x };

}

int anonymous_0 (fv, y)

{

int x = fv [1];

return x + y;

}

10.2 言語および compilerの変更

さて, 今から言語に lambda closureを導入しよう. 前述したように,

lambda closureという構文自体は, 本質的ではなく, 自由変数を含んだ局所関数が定義できるところが本質的であった. そこで我々の言語にも局所関数が定義できる機構を, 新たな essential syntaxとして導入しよう. これが導入できれば, lambda closureはただの syntax sugarとして実現できる. これをMin-Scheme Fixと呼ぶことにする. Min-Scheme Fixの構文は,

Expr = Id

| Constant

| (if Expr Expr Expr)

| (let (Bind*) Expr)

| (fix (Fbind*) Expr)

| (Primsym Expr*) ; primitive call

| (Expr Expr*) ; user-defined functions

Bind = (Id Expr) ; let-bind

Fbind = (Id (Id*) Expr) ; fix-bind

Definition = (define Id Expr) | (define (Id Id*) Expr*) ; toplevel definition

Constant = Integer | Float | Boolean | ’()

13

Page 14: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

Primsym = +-two | --two | >>-two | <<-two

| <-two | >-two | <=-two | >=-two | =-two | eq?

| heap | rref | rset!

で, 加わったのは, fixという文である. Fixは局所関数を導入して bodyを実行するという意味で, letと似ているが, 導入されるのは関数に限られる(MLの let fun ...に非常に近い). 例えば,

(fix ((even? (x) (if (= x 0) #t (odd? (- x 1))))

(odd? (x) (if (= x 0) #f (even? (- x 1)))))

(even? 10))

は正しい fix式である.

10.3 Lambda closure and loops as syntax sugars

さて, なぜ lambda closureではなく, fixが primitiveなのかであるが, 上の例を見てもわかるように, fixは導入される関数に名前をつけることで,

再帰的な関数を自然に定義することを許している. そして lambda closure

は, 単に何か適当な名前を生成してやれば簡単に syntax sugarとして実現できる. その意味で fixは, lambda closureを包含している. 逆に, lambda

式を使って fixを簡単に実現できるかというと, 一見 fix 式で導入されるそれぞれの関数を lambda式として, それらを let を使って定義してやれば良いように思えるが, 実はそれだと再帰的な関数の定義はできない. 例えば上の例を,

(let ((even? (lambda (x) (if (= x 0) #t (odd? (- x 1)))))

(odd? (lambda (x) (if (= x 0) #f (even? (- x 1))))))

(even? 10))

としてしまうと, letの scope ruleにより, これらは odd?, even? を再帰的な関数として実現したことにはならない.

再帰的な関数を局所的に定義できることで, lambda式のみならず, 数々loopが syntax sugarとして実現できる. 以下, これを見ていく.

10.3.1 Lambda式

Lambda式は fixを使って次のように定義できる.

14

Page 15: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(lambda (param*) body) = (fix ((f (param*) body)) f)

ここで fは bodyおよび paramに現われない名前とする.つまり lambda

式は名前のない関数であるから,fix構文でその関数に決して使われない名前を付けてやればよい.例えば,

( lambda (x ) (+ x 1) ) = ( f i x ( ( f ( x ) (+ x 1 ) ) ) f )

10.3.2 Let (revisit)

Letは我々の現在の settingでは essenial syntaxだったが, 実は局所関数定義があると essential syntaxとする必要はない. なぜならば,

( l e t ( ( var\ 0 exp\ 0 )

. . .

( var\ n exp\ n ) )

body )

=

( f i x ( ( $ f$ ( var\ 0 . . . var\ n ) body ) ) ( $ f$ exp\ 0 . . . exp\ n ) )

だから (ここで f は exp_iや, body中に現れない名前). つまり, let式はbody部を実行するような局所的な関数を定義して, それに初期値を apply

しているに過ぎない.

注: 我々の課題では必ずしも fix式があることを前提としないので, let

を essential syntaxとする. また, 仮に fixがあっても, optimizationを容易にするという観点から, letを essential syntaxとして特別扱いした方が良いようである.

10.3.3 名前付き let

Schemeで繰り返しを行なうためにしばしば使われるのが, letを拡張した名前つき letと呼ばれるもので, 例えば,

( l e t loop ( ( x i n i t \ x )

(y i n i t \ y ) )

body )

という形をしている. ここで, body部で loopを 2引数関数

( loop exp\ x exp\ y )

15

Page 16: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

の形で呼び出すことができ, 呼ばれると, exp x, exp yを x, yの新しい値として body部の実行をやり直す. 例えば, 与えられたリストの全要素の積を求める名前つき letは,

( l e t loop ( ( r e s t l ) ( prod 1) )

( cond ( ( nu l l ? r e s t )

prod )

((= 0 ( car r e s t ) ) 0)

( e l s e ( loop ( cdr r e s t ) ) (∗ prod ( car r e s t ) ) ) ) )

になる. 名前つき letはややこしい,奇妙な構文という感じを与えるかも知れないが, 実は前に述べた,「let式は, 局所的な関数定義+apply」という見方をを自然に拡張したものになっていて, 単に letの場合無名であった局所的に定義される関数の名前を, プログラマが与えているに過ぎない.

その結果, その関数を再帰的な関数として使えるようになっている.

例えば, 上記の loopは,

( f i x ( ( loop ( r e s t prod )

( cond ( ( nu l l ? r e s t )

prod )

((= 0 ( car l ) ) 0)

( e l s e ( loop ( cdr r e s t ) ) (∗ prod ( car l ) ) ) ) ) )

( loop l 1 ) )

と等しい.

10.4 do

Schemeにはdoという単純なループのための強力な構文がある. Cの for

文と同じようなノリで読めば良い. 例えば, arrayの a[0] ... a[n-1]

までの要素をすべて, 0に初期化して, aを返したければ,

(do ((i 0 (+ i 1)))

((= i n) a)

(vector-set! a i 0))

のように書く.

for (i = 0; i < n; i++) a[i] = 0;

16

Page 17: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

というのと同じ.

これを先の名前つき letに変換することができる.

(let loop ((i 0))

(if (= i n)

a

(begin

(vector-set! a i 0)

(loop (+ i 1)))))

面白いことに, 上で実行されている doが, 一見 iを (set!を使って) update

しているように見えるにもかかわらず, 変換された後はそれは再帰呼びだしに過ぎず, set!を supportする必要はない.

一般に doは,

( do ( ( x i n i t \ x next\ x )

(y i n i t \ y next\ y ) )

( ex i t−check return−va lue s )

body )

という形をしており, 変数を, 初期化部の式 (init ?)で初期化して, ループの実行をはじめる. 各繰り返し毎に終了検査 (exit-check)を行ない,

#fでなかったららループ実行を終了し, return-valuesを評価して最後に評価された値を返す. (exit-check)が falseなら, body部を実行して各変数を next ?にしたがって更新する.

それと等価な名前つき letは,

( l e t loop−name ( ( x i n i t \ x )

(y i n i t \ y ) )

( i f ex i t−check

( begin return−va lue s )

( begin

body

( loop next\ x next\ y ) ) ) )

ここで, loop-nameは, この式内に現れない名前.

17

Page 18: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

11 構文解析器の作成言語の表層がが決まったところで, 構文解析ルーチン (parser)の製作にとりかかる. これからの説明では,

• 読み込むべきプログラムが Schemeの syntaxで書かれており,

• アルゴリズムの記述に Schemeを用いる

と仮定する.

以下に Schemeの「式」を構文解析するための関数を述べる. 実際にはこれに toplevelに現れる式 (define ...)を読み込むための関数を作らなくてはならないはずである.

式は, Schemeの (read)関数を使ってリストの形で読み込まれる. 構文解析器はこのリストを入力として受けとり, 結果として parse treeを返す.

1 : ( d e f i n e ( parse−expr e )

2 : ( i f ( t e rmina l ? e )

3 : ( parse−expr−t e rmina l e ) ; parse th ings l i k e 1 , 2 , a

4 : ( l e t ( ( expander ( lookup−par s e r ( car e ) syntax−sugar−t ab l e ) ) )

5 : ( i f expander

6 : ( parse−expr ( expander e ) ) ; expand E and then parse i t again

7 : ( l e t ( ( pa r s e r ( lookup−par s e r ( car e ) expr−parse−t ab l e ) ) )

8 : ( i f pa r s e r

9 : ( pa r s e r e ) ; i f or l e t

10 : ( parse−app e ) ) ) ) ) ) )

入力された式は 3つの場合がある.

• 変数, 定数などのこれ以上分解できない terminal

• Syntax sugar

• Primitive (+-twoなど)

• Essential Syntax (if, 関数呼びだし, let)

構文解析器はまず, 受けとった式が terminal (変数, 定数など, 中に式を再帰的に含まない式)であるかどうかを検査する. そうであれば terminal

を parseする関数を呼ぶ. なければ, 次にそれが syntax sugarとして定義されている構文かどうかを (car部を見ることにより)検査する. もし

18

Page 19: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

syntax sugarであれば適切な展開関数 (各 syntax sugarごとに定義する)

を呼び, 展開された後の式を再び parserに入力する. それ以外は, 組み込みの primitiveまたは, essential syntaxであり, それぞれ適切な parser(各syntaxごとに用意する)を呼ぶ.

Syntax sugar を展開する関数の例として, cond 式を展開するを示す(match構文を使っている).

( d e f i n e ( expand−cond expr )

(match expr

( ( ’ cond ( ’ e l s e . exprs ) ) ‘ ( begin , @exprs ) )

( ( ’ cond ( cond i t i on . exprs ) )

‘ ( i n t e rna l− i f , c ond i t i on ( begin , @exprs ) #f ) )

( ( ’ cond ( cond i t i on . exprs ) . o the r s )

‘ ( i n t e rna l− i f , c ond i t i on ( begin , @exprs ) ( cond , @others ) ) ) ) )

このような関数 (展開関数)が各 syntax sugarごとにある.

Essential syntaxを parseする関数の例として, ifを展開する式を示す.

( d e f i n e ( parse−min−scheme− i f expr )

(match expr

( ( ’ i n t e rna l− i f c ond i t i on then−expr e l s e−expr )

‘ ( i f , ( parse−min−scheme−expr cond i t i on )

, ( parse−min−scheme−expr then−expr )

, ( parse−min−scheme−expr e l s e−expr ) ) ) ) )

同様の関数が, applyおよび primitiveに関してもある.

12 実験環境について

12.1 Which Scheme to Use?

さて, compilerを実装するのにSchemeを使う必要はないが, S式をparse

するのなら, Lispまたは Schemeが楽ではある. ここでは, compilerを書くためにお勧めの Schemeを紹介しておく.

多くの人が使いなれていると思われる, xschemeは interpreterなので決して速くはない処理系である. その結果, compilerが出来上がったものの,

やたらと compileが遅くて苦労するようである. 結論をいうと,

Chez Scheme

19

Page 20: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

という売り物のSchemeを使うことをお勧めする (command名は, chezscheme).

これは speedが他の処理系に比べて段違いに速く, しかも interactive環境から loadするだけで, 勝手に compileしてくれる. オンラインマニュアルは http://www.scheme.com/csug/index.htmlにあるので, デバッガの使い方など, 一通り学んでおくことをすすめる.

その他に, compilerが実装されている処理系としては,

• Gambit-C

• Scheme->C

があるがいずれも, compileは standaloneをはき出すためのもので, inter-

preterと同じようなノリでは使えない. 速度も chezschemeにはかなわないので, 目下のところ, chezschemeを選ばない理由は何もない.

注: Schemeの処理系を作るのでなくても, Schemeが好きな人は, lex/y-

accなどでプログラム→S式というパーザーを作ってしまえば,後はScheme

にそれを読み込ませることができる. また, MLで処理系を作りたければ,

ML-yacc というものもあるそうです.

12.2 matchについて

/usr/local/src/lang/matchにmatch.ssというSchemeでpattern match

を行なうためのライブラリがある. これを使うには, 次のおまじない:

(current-expand eps-expand)

を実行してから,

(load "/usr/local/src/lang/match/match.ss")

として loadして下さい.

¥section¥protect¥minschemefix実現の概要さて先週は, Schemeの多くの部分を包含する十分強力な言語が,究極的にはたったの4つの essential

syntaxから成る言語に還元できることを見た. その 4つの essential syntax

とは, ¥beginitemize ¥item If (条件分岐) ¥item Apply (関数呼び出し)

¥item Let (局所変数定義) ¥item Fix (局所関数定義) ¥item Primitive

(組み込みの primitive) ¥enditemize ここで primitiveは, target machine

20

Page 21: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

に依存した, machine命令またはその簡単な組合せで,多くの registerを破壊せずに実現可能な機能の集合である. 特に fixは強力さの源であり, これを元に lambda式, loopなどの数々の有用な構文が実現できたのであった. また, letは意味的には fixと applyの組に還元することができるが, 次の二つの理由で, essential syntaxとしてある. ¥beginitemize ¥item Fix

がないMin-schemeでも letを使用可能にするため¥item 最適化を簡略化するため¥footnoteもし letを syntax sugarとして実現したとすると, let

文を一度実行する毎に, 関数呼び出しが一回起こる. さらにいえば, begin

文は最終的には, それが含む文の数の分だけ, let文が重なった式に展開され, その数だけ関数呼び出しが起こることになる. もしこの方法をとるのならばそのようにして生成されたたくさんの関数呼び出しを¥bf inline

¥dg 展開によって取り除くという最適化が必須になる. それを認識し,

取り除くことは概念的には単純な作業で, compilerは, 「ただ一度だけ呼ばれ, それ以外に使用されない関数を見つけ, その呼びだしを inline展開し, 呼ばれた関数の定義を取り除く」という最適化を行なえば良い (letをsyntx sugarとして展開した時に局所的に定義される関数はたった一度だけ呼ばれ, それ以外には使われない関数であることに注意せよ). そして一度だけ呼ばれる関数を見つけることは容易にでき, inline展開自体はただの substitutionに過ぎない. しかもこの最適化 routine を実装すれば, let

以外で同様の性質を持つ関数も自動的に最適化できる, というおまけがつく. この方法は letを essential syntaxとする方法よりも美しく, かつ得られる compilerの性能も良い. しかしこの方法は compilation speedにかなり大きな影響を及ぼすようである. Naiveな実装では大きな CPS式のtraverseおよびmodificationを何回も繰り返すはめになる. ¥enditemize

さて,構文解析が終った後の compilerのphaseは, Min-Schemeの source

を, まず中間言語であるところの¥bf Continuation Passing Style (CPS)

といわれる形式に変換し, その上で必要な解析を行ない, 実際にコードを生成する. Algorithmicには, Min-Schemeから CPSへの変換規則を理解することが最初の stepになる. 変換規則を述べる前に, 最終的にどのような機械語に変換されるべきなのかを説明しておく.

¥subsection変数および定数変数は最終的に register allocation phase

において, その変数の値を保持すべき registerが割り当てられる. 定数は通常machine命令にそのまま埋め込められるが, 命令に入り切らない大きな定数は一度 registerに loadされる.

21

Page 22: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

¥subsection組み込みの primitive 例として次のような式を実行した時に何を起こすべきかを考える. ¥beginquote ¥tt (E (+ E0 E1)) ¥endquote E0, E1は足し算を受ける operandであり, Eは足された結果を「使う」式で, 上の式全体で, 足し算を行なう状況の雛型 (template)を示している.

Min-Schemeと assembly言語の大きな gapの一つは, Min-Schemeはあらゆる operationは任意の複雑な式を operandとしてとれるのに対し,

assembly言語では operation (例えば足し算)の operandは registerや定数などの単純なものでなくてはいけないということである. つまり上の式を評価する時の問題は, 足し算を行なう前に何とかしてE0やE1の値を (それらが小さな定数でない限り)registerの上に載せて, その後に実際に足し算を行ない, その結果を何とかして, Eに教えてやる, ということである.

したがって生成するmachine 語の列は以下のような感じになれば良い.

...

E0 −→ t0 とする命令列......

E1 −→ t1 とする命令列...

t0 + t1 −→ t

(+ E0 E1) = t の元でEを実行

その他の多くの primitiveも大体同じような構造を持つと思って良い.

12.3 条件分岐

(E (if C T F))

を実行させた時, 何を起こせば良いかを考えよう. Assembly言語の制限:

「操作を施すための値は registerに載っているか, constantでなくてはならない」を思いだすと, まずCを評価した結果をどこかの registerに載せ,

その後にその registerの値に基づいて適切な方向へ分岐をする. 分岐した先ではそれぞれ, T または F の評価した値をどこかの registerに載せ, そ

22

Page 23: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

れをEに渡せば良い. つまり,

...

C −→ tc とする命令列...

tc ̸= #f なら LT へ, そうでなければ LF へLT : T −→ t とする命令列

...

J へLF : F −→ t とする命令列

...

J へJ : (if ...) = t としてEを実行

のような感じの命令列を出すことができれば良い.

12.4 局所変数定義

例えば 1変数の束縛を行なう let式である,

( l e t ( ( $x$ $E$ ) )

$B$)

を実行する時にすべきことは,

• Eを評価して, register tに代入する

• B中の自由な xが tに入っているとしてBを実行

ということである. つまり,

...

E −→ t とする命令列......

x = t の元でEを実行

23

Page 24: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

12.5 局所関数定義

さて, Min-Scheme Fixに存在している fix式, つまり局所的な, 自由変数を持つような関数を定義した時に何を起こせば良いか. Fixの例は,

(fix ((f (x) (+ x y))) ...)

で, この場合, yが自由変数となる.

前回にも少し紹介したように, fの正体は fを実現するコードの番地および, 自由変数である yの値の組 (レコード)である (このレコードをclosureという). したがって生成すべきコードの列は以下のようなかんじにすれば良い.

fの closureを heapに作り, それ (への pointer)を fとする...部を実行

注意: Min-Schemeにおいては関数は必ず toplevelで定義されたものだが, Min-Scheme Fixにおいては関数は, toplevelの defineで定義されたものかも知れないし, fixで局所的に定義されたものかも知れない. ここではfixで定義された関数の表現が, closureであると述べたが, fixを support

する以上, toplevelも便宜上そうしておく必要がある. Toplevelで定義された関数は固有の自由変数を持たないため, もし世の中に toplevelで定義された関数しか存在しないのなら, 関数の正体 (表現)はその関数のアドレスだけでこと足りる (これが実際, C compilerで行なわれていることであるし, Min-Schemeではそうすることが可能である). しかし二つの表現が混ざっているのは話しがややこしくなる. そうしてしまうと, 例えば以下のような関数において,

(define (map f l)

(if (null? l) ’()

(cons (f (car l)) (map f (cdr l)))))

fを呼び出す時に fが toplevelで定義された関数であるかどうかで異なった呼び出し方法を用いなければならない (具体的には, fixで定義されたものであれば, コード番地を closureから取り出す必要があるが, toplevel であれば fそのものがコード番地になっている). したがって toplevel関数も fixとの統一をはかって, closureとして表現しておく (実際にはコード番地 1 wordしか中身のない recordができる). 単純な物の見方は, toplevel

の関数定義の羅列全体が一つの大きな fix式になっているとみなすことである.

24

Page 25: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

12.6 関数呼び出しおよび復帰

ここが一番自明でないところである (気合いを入れて聞いて下さい). 例えば,

(define (f x y z)

(+ z (g x y)))

の様な関数内において, (g x)への呼び出しを実行することを考える. ここで gは, Min-Schemeの場合 toplevelで, Min-Scheme Fixの場合どこかの fixで定義された関数である. まず行なわなくてはいけないのは,

1. パラメータの setup

である. Machine毎に関数呼び出しの慣例というのが決まっており, そこでは関数の第○ parameterはどの registerにおけ, などということが決まっている. (関数がどこから呼び出されるかわからない以上, あらゆる関数に共通の決まりをつくっておかなくてはならない.) ここでは, 第 i

parameterを第 i registerにおくとする. したがって呼び出す時には, xやyの値をそれぞれ第 1, 2 registerに動かす必要がある. その後, gを実行するコード列が格納されている番地に jumpする (Min-Scheme Fixにおいては gはレコードを指しているため, その番地を closureから取り出す).

これで無事, gは実行を開始することができる · · ·とここで非常に大事なことを一つ忘れている. それは, どうやって gから戻って, fの続きを実行するかということである. 一般には, gは自分の実行が終了した後どこへ戻って続きを実行すればよいかを compile時に知っているわけではないので, 呼出時に何らかの形でその情報を渡してやらなければいけない.

ではそれも関数呼び出しの conventionに含めることにする. つまり, 第 0

registerには, 呼びだし側が制御を戻して欲しい場所をおいた上で, 目標となる関数を呼び出すことにしよう. つまり,

2. fが, gの終了後, 実行を再開する場所を setup

さて, それだけで足りるだろうか. 実はまだ足りない. 上のように gに呼びだし側の戻り番地を渡してやり, 首尾良く gから制御が戻ってきたとしよう. この時またまたmachine conventionで, 第 1 registerに返り値が返っているものとしよう. では, 次に fは何をするかというと, 返ってきた戻り値と zを足し算しなくてはならない. しかしその時 zはどこにある???? という話しになる. fが呼ばれた時点では zは第 3 registerにあっ

25

Page 26: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

たのに違いない. しかし gが復帰した時点でそれが残っていると期待できるはずがない (これはどんな慣例を定めたところで解決できる問題ではない. 例えば gが引数を 3つ以上持つ関数を呼び出すとするとその時, gは第 3 registerを上書きする).

一番単純な解決方法は, 関数を呼び出す側は, 復帰後に必要な値を, g

に制御を移す前にmemoryに退避するというものである. そのmemory

への pointerもどこかにおいておかなくてはいけないから, それもまたconventionによって定める (第−1 register, とでも呼んでおく). この,

復帰後に必要な値を格納した recordを作り, setup

この recordを, をその呼び出しの, continuation recordと呼ぶ.

· · ·以上でようやくきちんと動く方式ができた. 詳細は後日, 若干変更した形で (特に, Min-SchemeとMin-Scheme Fixとの違いにも触れながら)

また紹介することにして, 今は概念的にまとめておくと, 関数呼び出しは,

continuation recordを作り, 所定の registerに格納戻り番地を所定の registerに格納引数を第 1 register以降に並べる

宛先に jump

で, 実行され, 関数から戻る時は,

渡された continuation recordを所定の registerにおく返り値を第 1 registerにおく

戻り番地に jump

となる.

13 Continuation Passing Style

さて, いよいよ我々が採用する中間言語である, Continuation Passing

Style (CPS)について説明する. Min-Schemeと少し似ているが違うもう一つの新しいプログラム言語を覚えるつもりで理解して欲しい. 今回の目標は, 複雑に組合わさった式の評価や, 関数呼びだしなどが, CPS上でどのように表現されるかを見ることで, Min-SchemeをどのようにCPSに変換するかの algorithmは, 来週説明する. また, CPSそのものをどのようにmachine命令に translateするかはそれ以降のテーマになる (もちろんそれ以前に一定のイメージを頭に持っておかなくてはならないが).

26

Page 27: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

13.1 文法

\rmOP : := ID $ | $ CONSTANT

CPS : := (PRIMSYM [OP∗ ] [ ID ∗ ] [CPS∗ ] ) ; p r im i t i v e s

$ | $ (FIXSYM [BIND∗ ] CPS) ; l o c a l fun d e f i n i t i o n

$ | $ (APPSYM OP (OP∗ ) ) ; apply

BIND : := [ ID ( ID∗) CPS]

PRIMSYM ::= {\ t t +} $ | $ {\ t t −} $ | $ {\ t t >>} $ | $ {\ t t <<}$ | $ {\ t t <} $ | $ {\ t t >} $ | $ {\ t t <} $ | $ {\ t t >} $ | $ {\ t t =}$ | $ {\ t t HEAP} $ | $ {\ t t STACK} $ | $ {\ t t POP}$ | $ {\ t t RREF} $ | $ {\ t t RSET!}

FIXSYM ::= {\ t t FIXH} $ | $ {\ t t FIXS}APPSYM ::= {\ t t APPF} $ | $ {\ t t APPB}

(注) 記法上の慣習として, CPS関係の文法クラスおよび命令名は大文字で, Min-Schemeのそれは小文字で書くとする.

OPは命令 (例えば足し算)の operandとして使われる対象を指している. まず最初に注目すべきなのは, OPが IDまたは CONSTANTしかないという点で, ここがMin-Schemeの, 「任意の式が値を持ちそれらを組み合わせてまた式ができる」というのと大きく違うところである (その点でCPSは一歩, assemblyに近い).

文法クラスCPSがCPSの命令を表しており, それには大きく分けて 3

種類ある. まず注意しておくと, CPS自体はMin-Schemeの式のように値を持たない. 値を持つものは IDまたはCONSTANTだけであり, 複雑な式の評価は, OPに演算を施して変数に bindし, 次の命令を実行して · · ·という風に行なわれる. 一つ一つ順に見ていく.

13.1.1 Primitive

まず, primitiveは次の文法をしている.

\rm(PRIMSYM [OP∗ ] [ ID ∗ ] [CPS∗ ] )

ここで, X*は Xの任意個の並びを表す. PRIMSYMが実際の命令の種類を規定し, それらには, +, -などの命令や, RECORD-REFなどの命令が含まれる (これらもMin-Schemeの primitive同様 machine依存である).

PRIMSYMのすぐ後に続くのがその命令への引数である. 実際の引数の

27

Page 28: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

個数は命令毎に決まっている (任意個の引数をとれるものもある). その次の [ID*]は, 命令の結果を格納する変数名を指定する. 実際にはこの field

には 0個または 1個の変数しか入らない. 最後の fieldは再び任意のCPS

命令の列を格納できるようになっている. これは, この命令の実行後, 実行すべきCPS命令列を格納したものであり,その命令の continuationと呼ぶ. 実際にはこの continuationの数は 1個または 2個で, branch命令の場合だけ 2個 (それぞれ成立, 不成立に対応), それ以外は 1個である. 例えば以下は, Xを 2倍して, tに bindし, それが 10より大きいかどうかで分岐するCPS programの断片である.

(* [x 2] [t]

[(< [t 10] []

[(... ) ; then part

(... )])]) ; else part

CPSが, 制御の流れをただの命令の「並び」としてではなく, CPSの再帰的な式の構造によって明示的に表している点に注目せよ. このことはcompilerにとって必要な情報 (例えばプログラムのある時点での, “今後使用する変数の集合”) を求める algorithmを, 式の再帰的 traverseにより単純に美しく書くことを可能にしている.

13.1.2 APP

次に, APPについて見る. 2種類 (APPF とAPPB)あって, 文法はそれぞれ,

\rm ({\ t t $ \ ! \ ! \ ! $APPF} OP (OP∗ ) )

または,

\rm ({\ t t $ \ ! \ ! \ ! $APPB} OP (OP) )

である.

(APPF f (x y z))

は, x, y, zを引数として, fを呼びだす. また,

(APPB k (r))

28

Page 29: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

もやはり, rを引数として, kを呼び出す. ただしAPPBはMin-SchemeまたはMin-Scheme Fixの関数から返るために使われ, APPF は関数を呼び出すために使われる (F , Bはそれぞれ f orward, backwordの意味).

これらがMin-Schemeの関数呼び出しと大きく違うのは, APPそれ自体はあくまで, 「片道の jump」であり, Schemeの関数のようにそれ自体で値を返して続きを実行するわけではない, という点である. それはAPP

の文法にも現れており, APPの後で実行すべき「続き」に相当するものはない.

13.1.3 FIX

さて,最後にFIXである. FIXにも 2種類あって,それぞれ, FIXS, FIXH

と書くが, 違いは後で説明する. 文法はともに,

({\rm FIX} [BIND∗ ] CPS)

で, BIND*にしたがって局所関数を定義し, CPS以降を実行する. FIXもprimitive同様, 再帰的な構造によって次に行なうべき命令を指定している. 例えば,

(FIXH [(f (x) (+ [x 1] [y] [(...)]))]

...)

は, fという関数を定義して, 以降を実行する. fは引数 x を受けとり, それに 1を足して · · ·という動作をする.

ここで FIXで定義される関数は自由変数を含んでいて良い. 我々はまだ CPSの自由変数を定義していないが, つまり, FIX式の外側で定義され, FIX式で定義される関数の内側で参照される変数があっても構わないということである.

さて, FIXHとFIXSの違いはちょうどAPPF とAPPBの違いに対応している. まず FIXH はMin-Scheme Fixの fixを実現することに使う. つまりFIXHによって定義される関数は元はといえばMin-Scheme Fixの関数である. 一方 FIXSによって定義されるものは実はMin-SchemeまたはMin-Scheme Fixの関数呼び出しのための continuationである. いいかえれば, FIXHで定義された関数はAPPF で呼び出され, FIXSで定義されたcontinuationはAPPBで呼び出される.

実際に「continuationを作る」という作業がどのようにCPSで書かれるか, 疑問になると思うが, これは次の例を見た方がはやいので, 例を使って説明する. 今,

29

Page 30: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(define (g x y)

(+ x (f y)))

という文脈で, fを呼び出すことを考える. それをCPSでは, FIXとAPP

の組合せにより以下のように書く (便宜上 gもFIXを使って書いておく).

(FIX [(g (k x y)

(FIXS [(c (v) (+ [x v] [t] [(APPB k (t))]))]

(APPF f (c y))))]

..)

2行目の FIXSが, fから戻った後の continuationを作っている部分である. 作られているのは, vという 1引数の関数である. これは fの返り値を vに受けとり, それを xに足して · · ·という動作をする. fはここには書かれていないが, 返り値を戻す時に,

(APP c (v))

という式を実行して, 関数から復帰する.

さて, continuationを作ったら, 実際に fをAPPを使って呼び出す. それは

(APP f (c y))

である. 注目すべきは, fが後できちんと cを呼び出すことができるように, 作られた cが追加引数として渡されている点である.

FIXS, FIXHのmachine code上での動作 CPS命令の中で, FIXはやや抽象的で, 実際の動作を imageしにくい operatorである. CPSの FIX

も自由変数を含み得るので,

({\rm FIX} [ ( f ( x y ) E) ]

B)

を実行した時, fixの定義部 (この場合 E)に含まれる自由変数および, fを実現するコード番地からなるレコードを作る動作をする. それを fとして, Bを実行する, というのが動作である.

したがって, FIXを一度実行するたびにどこかにmemoryが割り当てられなくてはいけない. 当然そのためのmemory管理の方式が必要になる.

概念的に一番単純なのは, garbage collected heapを実装してしまい, そこ

30

Page 31: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

からmemoryを割り当て, 必要になったらGCをするというものである.4

しかしその方法だと, とりあえず動くものを作るのにも必ずGCが必要になる.

妥協案として, FIXを 2種類用意してある. 一つはFIXHでこれは heap

からレコードを割り当てる. もう一つはFIXSでこれは, stackからレコードを割り当て, 定義された関数は一度しか呼ばれないという前提の元に,

一度呼ばれたらそのmemoryを stackから popする, というものである.

Continuation recordが stackから割り当て/解放できるのはすぐにわかる.

また,すでに stack上に格納されている変数は何度もpush/popを繰り返さないような最適化を解こす余地を残しておくためにも, その両者は区別しておこう. 5 結果として得られる二つの FIXは, memory managementの方法を除けば全く同じものであり, 特にCPS levelでの意味の違いはない.

注: CPSレベルにおけるMin-SchemeとMin-Scheme Fix の違い これは, Min-SchemeにはMin-Scheme Fixにおける fixがないので, 当然のことながら, Min-Schemeを変換して得られたCPSにはFIXHがでてこない, というのがその答である. ただし, toplevelの関数定義を書く syntax

としてFIXHという記号が使えると便宜上都合が良いので, FIXHはMin-

Schemeにおいても使うことにする. それは唯一 toplevelに並んだ関数を変換するために使う. つまり,

Min-Schemeを変換して得られた CPSにおいては, FIXH はtoplevelにしか出現しない

と覚えておく.

4この方法は実際, SML/NJで使われている.5この方式を採用するのはひとえに, assemblyで GCを書きたくない, という動機に

基づいている. Continuation recordを stackを使って割り当て/解放するのが常に得策ではないということはすでに認識されている事実である. 自作 CPU上に完全な systemを構築するための stepは,

• GCなしMin-Schemeの runtimeを構築

• GCなしMin-Schemeで GCを implement

• Min-Scheme Fix を GCつき runtimeで構築

という手順になるだろう.

31

Page 32: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

14 CPSの特徴中間言語に望まれる性質は,

• Compilerが code生成/最適化に必要な情報を簡単な text上の操作で抽出できる

• 最適化を簡単な text上の操作として定式化できる

ことである. ここでCPSが compilerにとってなぜ扱いやすい言語なのかをいくつかの例を用いて示す.

Inlining as Substitution SchemeやMLのような言語では関数呼びだしの inline展開が重要な最適化となる. 例えば,

(define (g x)

(fix ((f (y) (+ y y)))

(+ (f (+ x 10)) 1)))

において, fの呼び出しを最適化したいと思ったとする (今は, fixを使っているが, fが toplevelの関数でも, 定義がわかっていれば同じことである). これは以下のような CPSに変換される (便宜上, gも FIXを使って定義しておく).

(FIXH ([g (k x)

(FIXH ([f (c y) (+ [y y] [t] [(APPB c (t))])])

(+ [x 10] [s]

[(FIX [(d (t) (+ [t 1] [r] [(APPB k (r))]))]

(APPF f (d s)))]))])

...)

ここで, fの呼び出しを inline展開することは,

(APPF f (d s))

を, FIXHで定義された fの body, つまり

(+ [y y] [t] [(APP c (t))])

において, freeな parameterの出現をを対応する argumentで置き換えれば良い. この例でいえば, freeな cを dで, freeな yを sで, 置き換えれば良い. つまり,

32

Page 33: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(FIXH [(f (c y) (+ [y y] [t] [(APP c (t))]))]

(+ [x 10] [s]

[(FIXS [(d (t) (+ [t 1] [r] [(APP k (r))]))]

(+ [s s] [t] [(APP d (t))]))]))

である. ここで, この最適化を施した後, fがもはやどこでも使われないとわかったならば, fの定義そのものを消去してしまって良い.

そしてさらに, この例の場合,

(APPB d (t))

も inline展開できる. これを行なってさらに不必要な定義を取り除くと,

(+ [x 10] [s]

[(+ [s s] [t]

[(+ [t 1] [r]

[(APP k (r))])])])

となる. そして, substitionは非常にやさしい操作である.

ところで, substitionは Schemeにおいても同様に優しい操作であるが,

残念ながら Schemeでは,

Inlining = Substition

とはならない (なぜか?)

“Live” Variables as “Free” Variables プログラム中のある時点で,

「生きている変数」とは, その時点で値が定義されていて, 今後使われる可能性がある変数のことである. これは compilerにとってもっとも重要な情報の一つであり, ある関数呼び出しを行なう場合に, memoryに退避すべきなのはその時点で生きている変数でありまた, register allocationを行なう際にも, その時点での演算結果の保持場所として再利用可能なのは, もはや生きていない変数の registerである. これはあらゆる言語で, 何らかの形で定義されなくてはいけない概念である. 逆にこの情報が簡単な symbolicな操作で計算できる言語は compilerにとって扱い良い言語である.

CPSはある命令の free variable (自由変数)がそのまま live variableになっているという優れた特徴を持つ. Free variableの定義はまだきちんと述べていないが, 例からは容易に納得することができる. 例えば先の例である,

33

Page 34: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(define (g x)

(fix ((f (y) (+ y y)))

(+ (f (+ x 10)) 1)))

を, CPS変換した,

(FIXH [(g (k x)

(FIXH [(f (c y) (+ [y y] [t] [(APPB c (t))]))]

(+ [x 10] [s]

[(FIXS [(d (t) (+ [t 1] [r] [(APPB k (r))]))]

(APPF f (d s)))]))])

...)

の, dの定義

[d (t) (+ [t 1] [r] [(APP k (r))])]

に注目する. tは parameterなので束縛変数. rは式の中で定義されるのでやはり束縛変数. 結局自由変数は kである. kはもともと gに渡されたcontinuationであった.

実際これは, (f (+ x 10))の呼び出しを行なう際にしなくてはならないことを良く表している. 実現の概要でも述べたように, 関数呼出時に行なわなくてはならないことは, 関数復帰後に必要な変数をmemoryに退避することであり, この場合関数復帰後に行なうことは返り値に 1を足してそれを g自身の callerに返すことだけだから, 結局 g自身が戻るために必要な情報だけを退避しておけば良い. この dの自由変数が kのみからなる集合であるということがこのことを表している. そして自由変数の計算はやはり簡単な記号操作である (今までに見つかった, 束縛変数と自由変数の集合を保持しながら式を再帰的にたどっていき, 束縛変数の集合に表れない変数が使われた時点で, それを自由変数に加えていけば良い).

比較として, Schemeを見てみよう.

(define (app f x g y)

(+ (f x) (g y)))

において, (f x)呼出時の生きている変数 6は何か? 実はそれは答えようがない. 実際それは足し算の operandをどの順番で評価するかに依存する. (f x)を先に評価するのであれば, 自由変数は,

{g, y, (fの continuation)}6変数というよりは “値”というべきである

34

Page 35: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

となるであろうし, 逆であれば,

{(g y)の評価結果, (fの continuation) }

となるであろう. Schemeは何らかの evaluation orderを仮定しない限り,

生きている変数が定義できない言語である.

次に “traditionalな”中間言語である 4つ組を見てみる. 例えば,

s = 0;

for (i = 0; i < n; i++) s += f (i);

から生成されるであろう,

s := 0

i := 0

CHECK:

if (i < n) goto END

r := f (i)

s := s + r

goto CHECK

END:

のような中間コードを見てみる. ここで f (i)の呼出時に saveすべき変数, つまりその時点で生きている変数の集合を求めたい. もちろん我々は生きている変数を定義していないが, その目的を思い出せば, 以降の命令を scanして, 必要とされる変数を追加していけば良いことがわかる. 例えば直後の命令を見ると, sが使われているので, sは生きている. rはその命令で定義されるので生きていない (生まれていない, というのが雰囲気を良く表している). このように後ろの instructionを見ながら使われている変数を加えていってすむのなら良いのだが, 残念ながら話しはそう簡単ではなく, goto CHECKがあるために, 我々は命令列を後ろに戻らなくてはならない. すると, iや nも実は生きているということがわかる. そしてまたたどっていって元の場所に戻ってきて · · ·ここではややこしいということだけを指摘するにとどめる (さて, 結局

algorithmとしてはどのようにすれば良いのか?)

No “Hidden” State 上の関数 dの自由変数を求めた例で, 結局自由変数は kであった. 結論は dを定義する FIXを実行する際に, memoryに k

35

Page 36: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

および d のコード番地をmemoryに退避せよということであった. これが実に自然に関数呼び出しに伴う setupを行なっていることに注意して欲しい.

実現の概要のところで, Min-Schemeの関数呼び出しを実行する際に何をmemoryに退避したら良いかについて, CPSを用いずに自然言語で複雑な議論を行なったことを思いだして欲しい. その際の複雑さの要因の一つに, 関数呼び出しに伴って「暗黙に」渡されていながらもプログラムの表面に表れていない情報 (戻り番地 etc.)があったことがあげられる.

(define (g x)

(fix ((f (y) (+ y y)))

(+ (f (+ x 10)) 1)))

において, gの continuationを fを呼び出す際に退避すべきだということは, 気をつけて実行の様子を imageしてみないとわからない. CPSではその情報が自然に programの表面に表れている.

15 実現の概要まとめ以下に, Min-Scheme Fixの各 essential syntaxがどのようなCPSで実現され, それがどのようなmachine codeになるのかについて表にまとめる.

Min-Scheme CPS machine code

apply FIXS+ APPF continuation record creation

+ arguments marshaling + jump

return APPR argument (return value) marshaling + jump

fix FIXH closure record creation

if (eq? [#f C] [] (T E)) eval cond + compare it with #f + branch

let depends eval bond forms and execute body

逆に,各CPS primitiveが何のためにあるか,という観点からまとめると,

CPS Min-Scheme

APPF applyの最後APPB return

FIXS continuation record生成FIXH fix

36

Page 37: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

16 CPS変換CPS変換はMin-Schemeの関数定義を, 対応するCPSの関数定義に変換する.

その変換のアルゴリズムがどのように働くべきかを見るために複合式:

(+ (* a x) (* b y))

の例を見る. これを例にあげたのはわかりやすさのためで, 後には引数の数がいくつであってもいいような一般的なアルゴリズムを開発する. 目標は, これが以下のようなCPS式に変換されることである.

(* [a x] [p]

[(* [b y] [q]

[(+ [p q] [r]

[(... use r ...)])])])

ここで, (... use r ...)というのは (+ (* a x) (* b y))の値を使う式であり, program text の上では, (+ (* a x) (* b y))の外側の式になる. もしそのような式がなければ, rの値を関数の返り値として返すCPS式である.

一般に,

(+ E0 E1)

を評価しようと思ったら,

( <E0を p0に bindする式>

( <E1を p1に bindする式>

[(+ [p0 p1] [r]

[( ... use r ...)])]))

のようになるべきである. これは primitive呼び出しに限らず, if文などの評価にも当てはまる原則で, 必要な部分をまず評価する式を並べて, その後にそれを使う式が継続として現れる.

( ... use r ...)の具体的な形は (+ E0 E1)からは決まらず, むしろそれを取り囲んでいる式の形から決まるので, これに関する情報は変換関数に余分な引数として与えられる. したがってMin-Schemeの式をCPS

変換する関数は, Min-Schemeの式の他に, (1)最終結果を bindすべき変

37

Page 38: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

数名と, (2)継続 c, を余分な引数として受けとる. cは CPS命令で, 上の例の ( ... use r ...)に相当する部分である. <E0を p0に bindする式>や<E1を p1に bindする式>などは, E0や E1に変換関数を再帰的に適用することで作られる. その際にも適切な継続を引数として渡してやる.

与えられたMin-Schemeの式をCPSに変換する関数の名前をFnaiveと書く. ここで, naiveをつけたのは後でこれを refineするためである. すると, (+ E0 E1)を上のように変換するアルゴリズムは, 大体次のように書ける.

Fnaive (+ E0 E1) r c = Fnaive E0 p0 c′

where c′ = Fnaive E1 p1 c′′

where c′′ = (+ [p0 p1] [r] [c])

ここで, p0や p1は新しく生成した変数名である. このやり方で問題になるのは, base case, つまり, 定数や変数の場合で, 与えられた変数 rに, その定数や変数をmoveしなくてはならず, 効率が悪い. 今, idという名前の, 値をそのまま copyするだけの 1引数の CPS primitiveがあったと仮定すると, Fnaiveの base caseである, 変数や定数の部分は,

Fnaive v r c = (id [v] [r] [c])

のようになる.

このようにした場合, 例えば,

(+ (* a x) b)

のCPS変換は,

(id [a] [a’]

[(id [x] [x’]

[(* [a’ x’] [p]

[(id [b] [b’]

[(+ [p b’] [r]

[( ... use r ...)])])])])])

のような姿になってしまう. つまり, 余分なmove命令が連発してしまう.

何が悪かったのか反省してみると, CPS変換関数に渡すべき引数 cの中で, E0やE1の計算結果が必ず新しい変数名 (p0, p1)に bindされるものと

38

Page 39: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

決め打ちしてしまったからで, その結果必要のない時 (つまり E0などがすでに変数や定数だったりした時)まで, 無理矢理その変数にmoveしなくてはならなかった. したがってこの状況を改善するには, E0やE1の結果がおかれる場所を「後から (E0や E1を変換している時に)」決められるようにしておかなくてはならない.

そのためには, cをただのCPS式とするのではなく, E0やE1の部分が穴 (hole)になっているようなCPS式としておいてE0やE1に渡してやり,

後から部分式をCPS変換する際に holeを適切な変数で埋めてやることである. Base caseである変数や定数の場合, その holeに自分自身を埋め込んでやるし, それが再び式であった場合, 新しく作られた一時変数の名前を埋め込んでやる.

この holeと埋め込みは, holeを update可能なデータ構造としておき,

埋め込む際に値を書き込むことで実現することも可能だが, 最も elegant

な方法は, c を「CPS operand→CPS命令」という形の lambda式として渡してやる方法である. この versionを正式にFと名付ける. Holeとはその lambda式の引数であり, holeを埋めるには単にその lambda式を埋めるべき定数なり変数なりを argumentとして呼び出せば良い. 例えば,

F (+ E0 E1) c = F E0 c′

where c′ = λp0.(F E1 c′′)

where c′′ = λp1.(+ [p0 p1] [r] [(c r)])

となる. ここで, rは新しい一時名で, cは (+ E0 E1)の結果を受けとるべき変数の部分が holeになっているような lambda式である. (c r)によって, その holeを生成された新しい一時名で埋めている. Base caseは次のようになる.

F v c = c v

つまり単純に holeを埋めているだけである. Fでは結果を bindすべき変数名を引数として受けとらないことに注意.

以上で考え方と,アルゴリズムをきれいに記述するために重要な ideaを述べた. 以下, 個々の場合を見ていく.

39

Page 40: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

16.1 定数・変数

前述した通り,

F v c = c v

で良い.

16.2 If

(if C T E)

は,(⟨Cを評価 ⟩

[(neq? (p #f)

[(⟨T を評価 ⟩)(⟨Eを評価 ⟩)])])

のように変換されるべきである. ここで pは condの値を格納すべき変数または定数である (繰り返すがこれは C が再び変数や定数以外の式であるか否かによって新しい変数名かどうかが決まる).

F∗ (if C T E) c = F C c′

where c′ = λp.(neq? [p #f] [] [(F T c) (F E c)])

これは意味的には正しいのだが, 実はコード sizeの膨張を招く. 上の式で, T とEそれぞれに対して cが渡されているから, cは 2回 copyされることになる. 例えば例として,

(+ a (if x y z))

という codeの断片を CPS変換してみる. ここで, if文を評価した「後」の動作 (aを足す)が重複して生成されていることに注目.

F∗ (+ a (if x y z)) c = F a c′

where c′ = λp.F (if x y z) c′′

where c′′ = λq.(+ [p q] [r] [(c r)])

= F (if x y z) c′′

40

Page 41: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

where c′′ = λq.(+ [a q] [r] [(c r)])

= (neq? [x #f] [] [(c′′ y) (c′′z)])

where c′′ = λq.(+ [a q] [r] [(c r)])

= (neq? [x #f] []

[(+ [a y] [r] [(c r)])

(+ [a z] [r] [(c r)])

この例だけではあまり問題の深刻さを感じないが,

(+ x (if a

(if b

(if c ...))))

のように if文が n重に nestした時, 一番外側の xは, 2n回生成されてしまう.

解決方法は, 「if文全体の値を受けとって, 続きを実行するようなCPS

関数」を FIXSを使って定義しておいて, T,Eの中では, 評価した値をその関数に向かってAPPすることによって, 同じコードを共有する.

F (if C T E) c

= F C c′

where c′ = λx.(FIXS ([j (v) (c v)]) (neq? [x #f] [] [T ′ E ′]))

where T ′ = F T λx.(APP j (x))

where E ′ = F E λx.(APP j (x))

16.3 Fix

Min-Schemeの,

(fix (⃗b) E)

は,

(FIXH [⟨⃗b中の各定義を変換 ⟩] ⟨Eを変換 ⟩)

41

Page 42: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

のようになるべきである. 定義部を変換するため, ここで一つの関数定義:

(f (p⃗) E)

(ここで f は関数名, p⃗は引数のリスト, Eは本体)を変換する関数Ffbindを定義する.

Ffbind (f (p⃗) E) = (f (k p⃗) (F E λx.(APPB k (x))))

注目すべき点は二つで,

• 引数が一つ (k)増えている. これには後述する関数呼び出し時に作られる継続が渡される.

• 本体Eを変換するのに, cとして, λx.(APPB k (x))を渡している.

これは評価し終った値を, 受けとった継続 kに渡すことで関数からの returnを実現していることに相当する.

定義の変換法がわかれば fix式の変換は容易で,

F (fix (⃗b) E) c = (FIXH (Ffbinds b⃗) (F E c))

Ffbindsは, b⃗の各要素にFfbindを適用する. なおいうまでもなく, Ffbindは toplevelの関数定義を変換する際にも使われる.

16.4 Primitive呼び出し

Min-Schemeの primitiveをCPSの命令に変換する際の主な仕事は各々の引数を変換することである.

足し算や record creationなど, いくつかののMin-Scheme primitiveに対しては,

(p [x y · · ·] [v] [(· · ·)])

という形をした, [x y · · ·]を operandとして一つの操作を実行し, それを変数 vに bindし, 続きを実行するCPS primitiveがある. それらについては, 引数を評価し終った後どのような CPS命令を生成すればよいかは自明である.

42

Page 43: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

一方, 比較命令などの述語だけは例外で, Min-Schemeの述語は#tまたは#fを返すが, CPSのそれは, 値を返す代わりに比較の結果に応じて適切な方向へ分岐するという形をしている.7 その場合, 各引数を評価し終った後比較によって分岐し, 各枝で#tなり#fなりを比較結果として渡してやる必要が生ずる. この場合にも if文の時と同様, コード sizeの爆発を防ぐ工夫をしなくてはならない.

そこで以下, 比較 primitiveと通常の primitiveに分けて説明する.

16.4.1 通常の primitive

式 (p a⃗)を変換する. ここで pがMin-Schemeの primitiveであり, CPS

にも同名の 8 primitiveで,

(p [a⃗] [v] [(· · ·)])

の形で呼びだせる primitiveがあるものと仮定する. 算術演算や, レコード生成などがこの範疇に属する.

F (p a⃗) c

= F→ a⃗ λv⃗.(p [v⃗] [t] [(c t)])

ここで, F→はいわばF の list版であり,

F→ a⃗ λv⃗.C

は, 必要ならば新しい変数を生成しながら a⃗の各式を評価し, その後にC

を評価するようなCPS命令を生成するもので, 以下のように定義できる.9

F→ a⃗ c = g a⃗ () c

7これは後者の方がより普通のCPU命令に近いからで, もし自作CPUの比較命令が,cmp x y zのように 3引数で, 比較結果を zに格納するような形になっているのであれば, CPS自身もそのように設計すれば良く, 比較命令を特別扱いする必要はない.

8もちろん同名である必要はないが, ここではアルゴリズムの記述を簡潔にするためそのように仮定する.

9以下の定義では, 結果として引数を左から右に評価することになるが, これには選択の余地がある. 例えば, (+ (f x) (- a b c d e))において, 関数呼びだし (f x)を実行する前に, (- a b c d e)を評価しておけば, (f x)の呼び出しに当たって作るべきcontinuation recordには, (- a b c d e)の評価結果を格納することになり, その代わり各々の a, b, c, d, eは, (他の場所でさらに使われていなければ)格納する必要がなくなる.

43

Page 44: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

ここで補助関数 gは,

g () e⃗ c = c (reverse e⃗)

g (h :: r) e⃗ c = F h λx.(g r (x :: e⃗) c)

16.4.2 比較 primitive

CPSの比較 primitiveは,

(p [x · · ·] [] [(· · ·) (· · ·)])

という形をしているものとする. つまりいくつか (実際には一つまたは二つ)の operandを受けとって比較し, その結果に応じて適切な方を実行する. Scheme の比較 primitiveの意味はあくまで「結果に応じて#tまたは#f を返す」ものであり, CPS primitiveを使ってその意味を recoverしなくてはならない (比較操作は if文の条件部だけで行なわれるわけではないことに注意). それには分岐後, 適切な比較結果を生成すれば良い. つまり

F∗ (p a⃗) c

= F→ a⃗ λv⃗.(p [v⃗] [] [(c #t) (c #f)])

とすれば良い. しかしこれは if文の変換のところで述べたのと同じ問題,

つまり, cが重複されてコード sizeがふくれ上がるという問題を持っている. そこで if文の時と同様に, 評価結果を受けとる関数を FIXSを使って定義し, 評価結果はその関数に対して#tまたは#fをAPPによって渡すことで供給する.

F (p a⃗) c

= F→ a⃗ λv⃗.(FIXS ([j (x) (c x)])

(p [v⃗] []

[(APPB j (#t))

(APPB j (#f))]

特に最初に示した versionは, 後から示した方の二つの APPBを inline展開して不要になった FIXを取り除いたものに相当することに注意せよ.

44

Page 45: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

典型的な if文の ad-hocな最適化 今まで示したやり方で,

(if (= x y)

A

B)

のように, if文の条件部が比較 primitiveであるものがどのように変換されるかを考える. 結果は,

(fixs ([j (v)

(fixs ([l (w) (...)])

(neq? [v #f] [] [( A’ ) ( B’ )]))])

(= [x y] [] [(APPB j (#t)) (APPB j (#f))]))

という具合で,

• まずは, #t, #fを生成するために分岐をし (=),

• その後にそれを#fと比べる

という無駄なことをしている. 特にこの場合なぜこれが無駄かというと,

jに#tなり#fなりを適用した後, それがただちに#fと比較されており, しかもそれ以外の目的には一切使われないからである. その比較がどちらに分岐するかはAPPBを行なう時点ですでにわかっており, (APPB j (#t))

の代わりに A’を, (APPB j (#f))の代わりに B’を埋め込んでしまえば良いのである.

したがって我々はここでは if文を変換する際に, condition部を先読みして, それが比較 primitiveであった場合は以上の処理を実現する特別な変換を実行することにする.10

F (if (p a⃗) T E) c

= F→ a⃗ λv⃗.(FIXS ([j (v) (c v)]) (p [a⃗] [] [T ′ E ′]))

10上の説明でも明らかなように, 今回行なった特別な処理は, 普通の処理+inliningとみなすことができる. いいかえれば適切な inlining strategyに基づいて inliningを行なえば, CPS変換の段階で効率の悪いコードを一時的に生成しておいても, 後の inliningstage (というものがあったとして)において自動的に除去されてしまうはずである. そしてそのような一般的な inlining strategyを実装すれば, (let ((p (= x y))) (if p

...))のような多少ひねくれた書き方をしたプログラムについても同様にうまく働く可能性が高い. その一方, 一般的であってなお勝つ必要な場合をきちんと包含した inliningstrategyを定めることはそれほど易しい問題ではない.

45

Page 46: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

where T ′ = F T λx.(APPB j (x))

where E ′ = F E λx.(APPB j (x))

同様に以下のような場合にもうまくいくようにするにはどうしたらいいのか考えてみよ.

(if (and (= x y) (= y z))

A

B)

ここで, andは syntax sugarで,

(if (if (= x y) (= y z) #f)

A

B)

と展開されるものとする. これを naiveに変換すると結構悲惨なことになる.

16.5 関数呼び出し

最後にプログラマが定義した関数の呼びだし, つまり toplevel (Min-

Schemeの場合)または fix(Min-Scheme Fixの場合)で定義された関数を呼び出す CPS命令列を生成する方法を考える. 重要なのは, 関数の返り値を受けとった後の計算を FIXSを使って生成し, それを余分な引数 (継続引数) として渡してやることである. 呼ばれた側は返り値を計算したら受けとった継続引数にそれを適用 (APP)することで関数から復帰するのであった (Ffbind参照).

F (f a⃗) c = F→ a⃗ c′

where c′ = λv⃗.(F f c′′)

where c′′ = λx.(FIXS [(k (r) (c r))] (APPF x (k l⃗)))

16.6 Let

さて, 今まで散々述べてきた変換のアルゴリズムは letを導入する時に多少変更の必要がある (話しを最初からややこしくしないために隠してきた). 簡単のため bindされる変数は一つとすると,

46

Page 47: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(let ((x E)) B)

を変換する際にやりたいのは大体以下のようなことである.

F (let ((x E)) B) c = F E c′

where c′ = λt.(F B[t/x] c)

つまり, Eを評価した後, B中の xの自由な出現を, Eの評価結果の格納場所 (t)で置換する. 実際には,

B[t/x]

において, xはMin-Schemeの変数, tは CPSの変数または定数であるので, このような表記は違法である. 実際にやりたいことは,

F B[t/x] c

式全体で,

BをCPS変換せよ. ただし, B中の自由な xの出現は, tに変換せよ

ということである.

今までの CPS変換のアルゴリズムに対して必要な変更は, 引数をもう一つ増やし, 各変数を変換する時に, let bindされている変数の名前を正しく置き換えるための連想 listを渡してやることである. その連想 listは,

各場所 (let, fix, および関数の parameter)で bindされた変数に対して, もし letで bindされた変数であれば, その変数の使用が出現した時にそれをrenameすべき CPS operand, そうでなければただ単にそれが let以外でbindされていることを告げる印を連想させておく. 11 連想 listによって,

同じ名前の変数に対する nestした bind を処理していることに注意.

重要な変更は, 変数をCPS変換する時にはその連想 listを探して, その変数を適切に renameしてやることである. それ以外には, 引数を束縛する各操作 (fix)で, その連想 listに要素を正しく付け加えてやる変更が必要になる.

11実現上の trickとしては let以外で bindされた変数は自分自身に連想させておけば良い

47

Page 48: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

以下は最終 versionの抜粋である. まずは let:

Ffinal(let (⃗b) E) c a

= F→ f⃗ c′ a

where c′ = λv⃗.Ffinal E c (pusha n⃗ v⃗ a)

ここで, n⃗は b⃗の中の変数名を順に取り出したもの, f⃗ は b⃗の中の式を順に取り出したもので,

(pusha n⃗ v⃗ a)

は, n⃗と v⃗の対応する場所にある要素同士の連想を連想 list aに追加した連想 listを返す関数である.

F の変更に伴って, F→なども同様の引数の追加が必要だが上ではいちいち区別していない. 重要なのは変数の場合で,

Ffinal v c a

= c (lookup-alist v a)

で, (lookup-alist v a)は, 連想 list aから vを探し, 見つかれば連想先を, なければ自分自身を返す.

その他の関数についての変更は, 自分で考えよ.

17 誤りについてfixのCPS変換規則に本質的な誤りがあることが分かっています. 具体的には Schemeの scope ruleを壊してしまうというもの.

F (fix (⃗b) E) c = (FIXH (Ffbinds b⃗) (F E c))

と述べた. 今, c = λx.Cとする.

問題は F E c = F E λx.C において, C 中で, b⃗が定義した名前を (たまたま)参照してしまった時で, 正しくはそららの参照は fix式の外側の定義を参照すべきである. しかし変換後は b⃗における定義 (あるいはさらにshadowされた定義)を参照してしまう. 例えば,

(+ (fix ((x () ...)) 0)

x)

48

Page 49: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

において, +の第 2引数の xは (fix ((x () ...)) 0)の xを参照するわけではないが, にも関わらず変換後は,

(fixh [(x (k) ...)]

(+ [0 x] [t]

[ ... ]))

のようになってしまうでしょう.

解決策があることはあきらかかだが, 今はさぼって先へ進む (当面のしのぎとしては, fixで定義された名前が他で使われないようにすれば大丈夫).

18 η reduction (または末尾呼び出しの最適化)

簡単にできて, なお勝つ非常に重要な最適化を取り上げる. 例として,

以下のような関数定義をCPS変換してみる.

(define (f x)

(g (+ x 1)))

得られるCPS関数定義は, 以下のようなものであろう.

[f (k6 x)

(+ [x 1] [v9]

[(fixs ([k7 (r8) (appb k6 (r8))])

(appb g (k7 v9)))])]

ここで, k7が, gから returnした後の計算を実行する継続である.

しかし, 見てもわかるように, k7が実際に実行する仕事は, 受けとった値 (つまり gの返り値)を, そのまま fの継続 (k6)に渡しているだけである. これはもともとの Schemeの programにおいて, gの返り値がそのまま fの返り値になっていることからもうなずける結果である. k7の定義を関数的に読むと (そしてML的に書くと),

fun k7 r8 = k6 r8

ということであって, まさに,

k7 = k6

49

Page 50: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

である, ということをしめしいている. そこで,

k7の使用をことごとく k6の使用に置き換えれば,

k7を作らずに済ますことができ, 結果として continuation recordの生成を省くことができる. 結果として,

[f (k6 x)

(+ [x 1] [v9]

[(appb g (k6 v9))])]

のような形にすることができる.

この最適化は簡単で,

• (FIXS ([f (x) (APPB g (x))]) E)という, 関数定義を見つけ,

• 見つけたら, E内における自由な f の使用をことごとく gで置き換え, それをE ′とし,

• もともとの FIXS式全体をE ′で置き換える

ということをすればいい. この変換を η reductionと呼ぶ. 12 この最適化はもちろん FIXHに対しても同じように行なうことができて,

• (FIXH ([f (x⃗) (APPF g (x⃗))]) E)という, 関数定義を見つけ,

• 見つけたら, E内における自由な f の使用をことごとく gで置き換え, それをE ′とし,

• もともとの FIXH式全体をE ′で置き換える

この最適化は, ある関数の呼びだし結果がそのままその外側の関数の呼びだし結果になっているあらゆる場合に適用できる. ということは,

あらゆる末尾再帰を使った loopに対して適用できる

といことになる. 例えば,

(define (fact n p)

(if (= n 0)

p

(fact (- n 1) (* p n))))

12名前はもちろん λ計算の η reduction: λx.(Mx) → M から来ている.

50

Page 51: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

という, “tail recursive factrial” programは,

[fact (k10 n p)

(fixs ([j11 (v12) (appb k10 (v12))])

(= [n 0] []

[(appb j11 (p))

(- [n 1] [v15]

[(* [p n] [v16]

[(fixs ([k13 (r14) (appb j11 (r14))])

(appf fact (k13 v15 v16)))])])]))]

のようにCPS変換される (かけ算はprimitiveであると仮定する). ここで,

k13が factの再帰呼び出しのために作られた継続で, 当然 η reductionの対象となる (これはoriginalのprogramを見ても明らか). その他にもif文の際に導入されたFIXでも同様の変換を行なうことができる. η reduction

後の結果は以下のようである.

[fact (k10 n p)

(= [n 0] []

[(appb k10 (p))

(- [n 1] [v15]

[(* [p n] [v16]

[(appf fact (k10 v15 v16))])])])]

これは factが一度呼ばれてから一度もFIXを行なわずに計算できているという意味で,

Loop並に速い末尾再帰呼びだし

ということができる.

19 改善の余地CPSの実行モデルは, FIXをした瞬間にメモリに自由変数が退避されてAPPによって呼ばれたときに再びその自由変数が取り出されて実行が始まるというものである. したがって, FIXが無闇に多いコードは遅くなる. 現在のやり方では関数呼びだし毎に一回, そして if文があるたびに一

51

Page 52: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

回, その if文の条件部が比較 primitiveでない場合はさらに一回, というFIXSが行なわれる.

改善には少なくとも二つ戦略がある.

• FIXを取り除く

• FIXそのものの実行効率の改善

前者は inliningをして, 結果として使われなくなった関数を取り除けば良い. この場合の注意点は, 無闇にやるとコードが膨れ上がってしまうことである. 何らかの heuristicsが必要 (if文でわざわざFIXSを導入せざるを得なかったのもまさにこの点が問題だったからである).

一方のFIXそのものの実行効率の改善というのはもう少しややこしい.

それは近隣の FIXどうしは同じ自由変数を持つことが多いだろうというobsevertionに基づくもので, 例えば,

(define (f x y)

(g x y)

(h x y)

(i x y))

のような例は,

(f (k x y)

(fixs [(c (r)

(fixs [(d (s)

(APPF i (k x y)))]

(APPF h (d x y))))]

(APPF g (c x y))))

のように変換され, それぞれの自由変数を計算してみると,

• d — { k, x, y }

• c — { k, x, y }

とぴったり一致している (元のプログラムを見ても gの呼び出しと hの呼び出しでぴったり同じだけの変数を蓄えておかなくてはならないのはすぐにわかる).

52

Page 53: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

上のような場合, 一つ一つの FIXSに対して, k, x, y というレコードを割り当てているといかにも無駄が多い. コンパイラが 2個目のFIXSを見たときに「さて, k, x, yはもうメモリの上においてあるはずだから」と気を聞かせてくれれば, FIXSをヤッキになって減らそうとしなくても良くなるかも知れない. この最適化は本質的で, 例えば以下のようなコード:

(define (dist a x b y c)

(let ((m (+. (*. a x) (*. b y) c))

(n (+. (*. a a) (*. b b))))

(/. m (sqrt n))))

(ここで, +.などは浮動小数点演算を実現してある外部のアセンブラで書かれた関数であると想定する)を, CPS変換するとどうなるか? 答え:

(dist (k10 a x b y c)

(fixs ((k15 (r16)

(fixs ((k17 (r18)

(fixs ((k13 (r14)

(fixs ((k11 (r12)

(fixs ((k21 (r22)

(fixs ((k23 (r24)

(fixs ((k19 (r20)

(fixs ((k27 (r28) (appb /.-two (k10 r12 r28))))

(app sqrt (k27 r20)))))

(appf +.-two (k19 r22 r24)))))

(appf *.-two (k23 b b)))))

(appf *.-two (k21 a a)))))

(appf +.-two (k11 r14 c)))))

(appf +.-two (k13 r16 r18)))))

(appf *.-two (k17 b y)))))

(appf *.-two (k15 a x))))

「各」FIXSに対して独立に, そのすべての自由変数を割り当てるようにすると結構悲惨なことになるかも知れない.

しかし何事もものごとは単純にはいかないもので, 各最適化は他のいろいろな最適化と微妙な interactionをおこして, 常にやっていいとは限らない. 元凶は来週のClosure変換によって FIXSに対するメモリ管理の方

53

Page 54: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

法を規定するのだが, その時に使う stackという管理法である (といっても stack以外には, GCをする heapを持つしかなくなってしまうのだが).

20 Benchmark

CPS変換が正しくできているかどうかを検査するために, いくつかの簡単な例題と変換例を見せる (説明の間は, かけ算なども適宜 primitiveとする).

20.1 Nestしたprimitive

(define (f a x b y)

(+ (* a x) (* b y)))

結果は,

[f (k25 a x b y)

(* [a x] [v27]

[(* [b y] [v28]

[(+ [v27 v28] [v26]

[(appb k25 (v26))])])])]

のようになるべき.

20.2 If文

たくさんの足し算の codeが 2回生成されていないことを checkせよ.

(define (pitfall a b c d e f g h)

(+ (if a b c) d e f g h))

で, 結果は,

[pitfall (k17 a b c d e f g h)

(fixs ([j23 (v24)

(+ [v24 d] [v22]

[(+ [v22 e] [v21]

54

Page 55: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

[(+ [v21 f] [v20]

[(+ [v20 g] [v19]

[(+ [v19 h] [v18]

[(appb k17 (v18))])])])])])])

(neq? [a #f] []

[(appb j23 (b))

(appb j23 (c))]))]

20.3 関数呼びだし

(define (f x)

(+ (g x) (h x)))

結果は,

[f (k32 x)

(fixs ([k34 (r35)

(fixs ([k36 (r37)

(+ [r35 r37] [v33]

[(appb k32 (v33))])])

(appf h (k36 x)))])

(appf g (k34 x)))]

となるはず.

20.4 Let文

Scopeを正しく取り扱っているか, 非常に間違えやすいところである.

まずは簡単な例:

(define (f x)

(let ((y (+ x 1)))

(* y 2)))

は,

55

Page 56: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

[f (k73 x)

(+ [x 1] [v74]

[(* [v74 2] [v75]

[(appb k73 (v75))])])]

間違いやすい例: 最初の yは renameされてはいけないことに注意. 結果として, (+ x y)を計算していれば正解.

(define (g x y)

(+ y (let ((y x)) y)))

[g (k79 x y)

(+ [y x] [v80]

[(appb k79 (v80))])]

20.5 Loopなみに速い末尾再帰

(define (loop i n)

(if (= i n)

0

(begin

(display i)

(loop (+ i 1) n))))

は,

[loop (k58 i n)

(= [i n] []

[(appb k58 (0))

(fixs ([k61 (r62) (+ [i 1] [v65]

[(appf loop (k58 v65 n))])])

(appf display (k61 i)))])]

となるべき. 注目は, loopの再帰呼出時に継続が作られずに, 最初に渡された k58が再利用されていること.

56

Page 57: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

20.6 If文の η reduction

(define (eta-if x)

(if (= x 0)

(f x)

(g x)))

η reductionを全く行なわなければ, ifのために一回, f, gそれぞれのために一回ずつ継続が生成されるが, それらは全て除去され, 以下のようになる.

[eta-if (k66 x)

(= [x 0] []

[(app f (k66 x))

(app g (k66 x))])]

21 中間レポートこのへんで落ちこぼれると後々まずい, という老婆心から, 中間レポートを提出していただく.

11月 21日

までに,

• parser,

• CPS変換,

• (できれば)η reduction

• (余力があれば)inlining

のアルゴリズムを完成させ, いくつか動作確認をした例とともに各班ごとに一つ提出せよ. レポートは, 電子mailで, プログラムと, 動作確認の例でよい. 詳しい説明は不要.

宛先は,

tau@...

まで.

57

Page 58: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

22 Closure変換の目的と概要Min-Schemeの関数定義を,対応するCPSの関数定義に変換した後,我々に残された仕事は, CPS関数を機械語の列に変換することである.

CPS命令 Iの中で, 変数 vが使われた時 (つまり primitiveの argument

かAPPの中に現れた時), この変数 vの出現は次の 3通りに分類できる.

束縛された出現 vが, Iを含む最内側の (FIXSまたはFIXHによる)関数定義の parameterであるか, またはその body内 (の primitiveまたは fix)で定義されている.13

内部自由な出現 vは, 束縛された出現ではないが, Iを含むCPS式のどこかで束縛されている

外部自由な出現 束縛された出現でも内部自由な出現でもないもの

内部自由な出現と外部自由な出現を総称して自由な出現という.

あるCPS関数定義や, CPS式に対して, その中で (内部/外部)自由な出現をしている変数の集合を, その CPS関数または CPS式の (内部/外部)

自由変数という. 外部自由変数を単に外部変数と呼ぶこともある. また,

内部自由変数と外部変数を総称して単に自由変数であると呼ぶ.

例えば以下のMin-Scheme programを考える.

(define (f x)

(+ x (g x)))

これは次のようなCPS関数へと変換される.

[f (k x)

(FIXS ([c (r)

(+ [x r] [t] (APPB k (t)))])

(APPF g (c x)))]

ここで, CPS関数 c(つまり呼びだし (g x)の continuation)の中で,

• r, tは束縛変数

• k, xは内部自由変数13vが, I を含む再内側の FIXによって定義される関数の名前そのものである場合, それを束縛されていると定義するかどうかは微妙であるが, ここでは便宜上束縛されていないと定義する.

58

Page 59: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• 外部変数は存在しない

また, fにおいては,

• c, xは束縛変数

• 内部自由変数は存在しない

• gは外部変数

となる.

CPS命令 Iで変数 vが使われる (つまり, primitiveの引数の位置に来るか, APPの関数または引数の位置に来る)時, vが上記のどの型の出現であるかに応じて, その参照の仕方には差がある. より詳しくは後で述べるが, 束縛された出現の場合, 値は定義された時点から使用の時点までずっと register上に存在するし, 内部自由な出現の場合, 値はそれを含む最内側のCPS関数が定義された時点で recordに格納され, 使用時にそこから取り出される. 最後に, 外部自由な出現の場合には, 値は常にある決まった番地におかれており, そこから取り出される.

このように変数の参照も一概には取り扱えず,実は結構複雑である. CPS

変換後, いきなり機械語生成をはじめるのはあまりりこうではなく, 内部自由変数, 外部変数への参照を明示的にするための phaseを機械語生成の前処理としてかませるのがすっきりとしたやり方である. この phaseをclosure変換と呼ぶ.

用語の確認 FIXにおいて作られる, コード番地および自由変数を格納した recordをその FIX の closure recordと呼ぶ. また, 特に FIXSにおいて作られる closure recordを continuation recordと呼ぶこともある.

Closure変換は実際には,一つまたは複数のCPS関数定義を受けとって,

それをやはりCPS関数定義に変換する. 雰囲気を知るために先に例を見せると, 上の例は結果的に以下のように変換される.

(f: (f k x)

(fixs [(c: (c r)

(rref [c 2] [x]

[(+ [x r] [t]

[(rref [c 1] [k]

[(rref [k 0] [k’]

59

Page 60: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

[(APPB k’ [k t])])])])]))]

(stack [k (label c:) k x] [c] ; allocate {(label c:), k, x} on k

[(rref [(label g) 0] [g]

[(rref [g 0] [g’]

[(APPF g’ (g c x))])])])))

注目すべき点は,

• CPS関数 cが, 名前を c:と変え, cを追加引数として受けとるようになった (cが closure record).

• c内で内部自由変数である kおよび xが, cから rrefを使って取り出されている.

• (APPB k (t))が変換されて,

– kの先頭から k’(コード番地)をとりだし,

– k’に kを追加引数として渡している (最初の項目と対応).

• cを定義した body部において, (label c:), k, xからなる contin-

uation recordを明示的に作っている.

このようにした場合, f:と c:は表面上束縛変数のみを含んでいるように見えることに注意せよ. むしろ以下のように flatに並べた方が, この変換の持つ意味がわかりやすいかも知れない (各々が独立した関数として「読める」ことを確認せよ).

(c: (c r)

(rref [c 2] [x]

[(+ [x r] [t]

[(rref [c 1] [k]

[(rref [k 0] [k’]

[(APPB k’ (k t))])])])]))

(f: (f k x)

(stack [k (label c:) k x] [c]

[(rref [(label g) 0] [g]

[(rref [g 0] [g’]

[(APPF g’ (g c x))])])]))

60

Page 61: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

このように closure変換は, 内部に nestした関数定義を含む CPSの関数定義 (群) を, 自由変数を含まないCPSの関数定義 (群)に変換する. また,

自由変数がどこから取り出され, そのためのメモリがどのように割り当てられるかも明示的になっている. Closure変換された後も CPS形式だが,

特に区別したい時は, CLO形式と呼ぶ.

23 自由変数, Closure RecordのFormat

自由変数や, closure recordの formatについては今までに何度か述べてきたが, ここで今一度はっきりさせておく.

23.1 自由変数

まずはじめにあるCPS命令, CPS関数,およびCPS関数群に対する,自由変数を定義しておく.

FV (APP{F,B} f (a⃗)) = V (f :: a⃗)

FV (p [a⃗] [d⃗] [C⃗]) = V a⃗+

(∪i

FV Ci − d⃗

)FV (FIX{S,H} (⃗b) C) =

(FVfbinds b⃗+ FV C

)− fixnames b⃗

FVが定義される関数で, 右辺は集合である. fixnames b⃗は b⃗において定義される関数の名前を並べたものである. V は CPS operandの listから変数だけを重複なく取り出す関数である. また,

FVfbinds b⃗ =∪i

FVfbind bi

FVfbind (f (p⃗) C) = FV C − V p⃗

である. このようにして求まる自由変数の集合は, 内部自由変数と, 外部変数の和であることに注意せよ.

自由変数は構文的には使用される変数の中で束縛されていないもの, というだけのことであるが, より実行モデルに密着した意味としては, 「その式を実行するに当たって仮定されている変数束縛の集合」という意味を持つ. すなわち, その式を実行するのに当たって, 保存しておかなくてはならない変数の集合を表す.

61

Page 62: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

23.2 Closure Record Format

FIX式を実行するたびに一つ closure recordが作られる. つまりそのFIXで定義されるいくつかの関数定義に対して, recordを一つ生成する.

単純には,あるFIXで定義される関数定義の列 b⃗に対する closure record

formatは,

CF b⃗ D = fixlabels b⃗ @((FVfbinds b⃗− fixnames b⃗) ∩D

)である. fixlabels b⃗は, b⃗で定義される関数のコード番地を順に並べたlistである. b⃗で関数 k1, · · · , knが定義されるとすると,

fixlabels b⃗ = ((label k1:) · · · (label kn:))

@は listの appendを表す (順序も重要). また, Dはその FIX の外側で定義される変数の集合で, ∩Dによって, 内部自由変数だけを格納するようにしている. 「単純には」と述べたのは, 後に見るように FIXSにおいては, 前に割り当てられた recordの一部を再利用する, ということをするので, その他の制約が加わるからである. 具体的にどうするのかは後に述べる.

言葉でいうならば, ある FIXに対する closure record formatとは,

そこで定義される関数群の自由変数のうち, そこで定義される関数群自身を取り除き, さらに外部変数を取り除いたものを作り, その先頭に定義される関数のコード番地を並べたもの

ということになる. そこで定義される関数群自身を取り除くのは, それらは closure recordに格納しなくても, その closure自身をずらす (offsetする)ことによって得られるからであり, また, 外部変数を取り除くのはそれらはいつでも決まった番地に格納されているからである.

例: 最初に単純な例として,

[f (k x)

(FIXS ([c (r)

(+ [x r] [t] (APPB k (t)))])

(APPF g (c x)))]

62

Page 63: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

を compile中だったとして, その中の FIXSを見る. すると,

CF {c} {f, k, x} = ((label c:)) @ (({k, x} − {c}) ∩ {f, k, x})= ((label c:) k x)

ということになる. ここで (label c:)が cのコード番地である. fの方は,

CF {f} {} = ((label f:)) @ (({g} − {f}) ∩ {})= ((label f:))

明らかに toplevelで定義された関数には, 自由変数は存在せず, したがって closure record formatは, コード番地ただ 1語から成る.

一つの FIXに複数の関数定義がなされる場合を見る.

(define (ten-is-odd?)

(fix ((odd? (y) (if (= y 0) #f (even? (- y 1))))

(even? (y) (if (= y 0) #t (odd? (- y 1)))))

(odd? 10)))

これは次のようなCPS関数定義に変換されるだろう.

[ten-is-odd? (k)

(FIXH ([odd? (c y) (= [y 0] []

[(APP c (#f))

(- [y 1] [t] [(APPF even? (c t))])])]

[even? (c y) (= [y 0] []

[(APP c (#t))

(- [y 1] [t] [(APPF odd? (c t))])])])

(APP odd? (k 10)))]

この FIXHにおいて作られる closure record formatは,

CF b⃗ {ten-is-odd?, k} = ((label odd?:) (label even:?))

@ (({odd?, even?} − {odd?, even?})∩ {ten-is-odd?, k})

= ((label odd?:) (label even?:))

63

Page 64: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

となる. 特に, odd?, even?はお互いの body部で自由であるにもかかわらず closure recordに格納されていないことに注意せよ. それらは一つのclosure recordを共有し, 一方の closure recordを offset(ずらす)することによってもう片方の closureが得られる.

24 Closure Recordの割り当て

24.1 heapおよび stack命令

heap命令は consセルの割り当てなど, 他の目的にも使われる命令で,

(heap [x1 · · · xn] [h] [C])

によって, (x1 · · · xn)をこの順に並べた recordを作り, そのポインタを h

に bindし, Cを実行する.

stack命令は, heap命令と似ているが, 第 1引数として, 割り当てるべき場所を指定する. つまり,

(stack [b x1 · · · xn] [s] [C])

によって, (x1 · · · xn)をこの順に並べた recordを, bで指されるレコードの(stackが下に伸びると仮定して)下に割り当てる.

24.2 roffs命令

関数群 (f1, · · · , fn)を定義するFIXにおいて, f1の正体はそこで生成される closure recordへの pointerとなる. f1は, heapまたは stack命令を使って割り当てられる. では残りの f2以降はどうなるのかというと, 前述したように, それぞれに別々な recordを割り当てるのではなく, f1を適切に offsetしたもの (ずらしたもの)になる. f2は f1を 1 word分, f3は f1を 2 word分 · · ·ずらしたものになる. 先の closure record formatと照らしあわせて, 常に closureの 0 word目を読み出すと対応する関数のコード番地が得られることに注意せよ. 例えば, f2の 0 word目を良み出すとf2のコード番地が得られる. これによって closureを呼び出す側はそれが実際に recordの途中を指していようといまいとに関わらず 0 word目を読みだして得られたコード番地に jumpすることによりその関数を実行することができる.

64

Page 65: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

Recordの offsetを実現する命令として, CPSの primitiveに roffs を追加する.

(roffs [r o] [s] [...])

のように用い, rを oだけ offsetさせた recordを sに束縛する. Closure変換後FIX式の body部は,先頭に recordの割当が,引続きいくつかの offset

が並び, その後にもともとの本体が実行されるように変換される.

このように, FIX時においては,

• 常に一つの recordが作られ,

• その recordのどこを指すかで複数の関数が区別される

のである.14

25 変数の参照方法各種変数の参照方法をまとめておく. 以下で, CPS命令 I中に現れる変数の出現 vをを考えているとする. fを, Iを含む最内側のCPS関数, s⃗をf と同じ FIXで同時に定義される関数の集合とする. また, その FIXのclosure formatを F , Iの外側かつ f の内側で束縛されている変数をBで与える.

S v I f F s⃗ B

は, vが Iにおいて束縛された出現であれば, そのまま Iを返すが, そうでない場合は, vを適切にとりだし (束縛し)た上で I を実行する CPS命令を返す.

補助関数として, リストとその中のある 2要素を与えられて, その 2要素のリスト中における相対位置を返す関数 relposを用いる. たとえば,

relpos a b (a b c) = 0− 1 = −1, relpos c a (a b c) = 2− 0 = 2

である.

14Min-Schemeにおいては, FIXはすなわち FIXSのことであり, そこでは CPS変換の結果から常に定義される関数は一つと決まっているので, offset命令は必要ない.

65

Page 66: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

S v I f F s⃗ B =

I—(v ∈ Bのとき)

(roffs [f o] [v] [I])—(v ∈ s⃗のとき)

where o = relpos (label v:)(label f:) F

(rref [f o] [v] [I])—(v ∈ F のとき)

where o = relpos v (label f:) F

(rref [(label v) 0] [v] [I])—(それ以外)

合計 4通りの場合分けがされていて, それぞれで行なわれていることを言葉で説明すると以下のようになる.

• vが束縛された出現の場合 (v ∈ B), 何も変更せずに元の Iを返す.

• vが f と同じ FIXで定義されている関数名である場合 (v ∈ s⃗), vはf を適切なだけずらす (offsetする)ことによって得られる.

• vが f が定義されている FIXの closureの中に入っている場合 (v ∈F ), vはその closureから取り出すことによって得られる.

• それ以外の場合, vは外部変数であって, vが格納されているべき番地 (label v)を読むことによって得られる.

たとえば,

[f (k x)

(FIXS ([c (r)

(+ [x r] [t] (APPB k (t)))])

(APPF g (c x)))]

の c内の最後のCPS命令において, kの出現は自由な出現であり, Sに渡すべきパラメータは,

• v = k,

• I = (APPB k (t)),

• f = c,

• F = ((label c:) k x),

• s⃗ = { c },

66

Page 67: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• B = { r, t }

であり, この時適用されるのは上の場合わけの第 3の場合であって,

relpos k (label c:) ((label c:) k x) = 1

より, 求めるものは,

(rref [c 1] [k] [(APP k (t))])

である.

次の例として,

[ten-is-odd? (k)

(FIXH ([odd? (c y) (= [y 0] []

[(APP c (#f))

(- [y 1] [t] [(APPF even? (c t))])])]

[even? (c y) (= [y 0] []

[(APP c (#t))

(- [y 1] [t] [(APPF odd? (c t))])])])

(APP odd? (k 10)))]

の odd?の中の最後の命令 (APPF even? (c t))における even?の参照を解決することを考えよう. つまり,

• v = even?,

• I = (APPF even? (c t)),

• f = odd?,

• F = ((label odd?:) (label even?:)),

• s⃗ = { (label odd?:) (label even?:) },

• B = { c, y, t }

である. 適用すべきは第 2の場合で,

relpos (label even?:) (label odd?:) ((label odd?:) (label even?:)) = 1

67

Page 68: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

より, 求めるべきものは,

(roffs [odd? 1] [even?] [(APPF even? (c t))])

である.

後のために, Sと同じ目的で, 複数の変数を入力とする,関数 S→を定義しておく. S→は, 与えられた変数のリスト中の各変数を順に束縛してから, 与えられた命令を実行する.

S→ [] I f F s⃗ B = I

S→ (h :: r⃗) I f F s⃗ B = S h I ′ f F s⃗ B

where I ′ = S→ r⃗ I f F s⃗ B

26 FIXのためのメモリ管理さて, closure recordを割り当てるにはメモリ管理が必要である. もっとも単純には, (完全な処理系にはもともと必須である)garbage-collected

heapを仮定して, 常にそこから割り当ててしまうというものである.15

ここでは, GCなしでも, Min-Scheme Fixの fixや明示的な heap割り当て (consなど)を行なわない限り, プログラムが動き続けることができるよう, FIXSに関してはGCなしで明示的に領域を再利用しながら実行する方式を考える. 実際には stackを使ってメモリを管理し, もういらないはずのところを書き潰しながら実行する.16 FIXHについては heapに割り当てる (そしてGCが実装されることを期待する). 17

したがってここで問題にするのは, FIXSの方だけで, そのために全体が以下のような不変条件を保ちながら動作する. 今 stackは下に伸びると仮定する.

1. FIXHによって定義された関数 (f (k · · ·) B) が呼ばれた時, kより下は使用可能.

2. FIXSによって定義された関数 (k (· · ·) B) が呼ばれた時, kより下は使用可能.

15実際に SML/NJはこの方法で実装されている.16したがって間違って使っているはずのところを書き潰すコードを生成してしまうと,プログラムが何でもありの挙動を示します.

17レイトレプログラムは GCがなくても動く.

68

Page 69: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

3. FIXS式 (FIXS [(k (· · ·) · · ·)] B)の本体 (B) を実行する時, kより下は利用可能.

これにともなって, 具体的な動作としては以下のような動作をする.

1. FIXHによって定義された関数 (f (k · · ·) B) において, Bで最初に実行される FIXSに対しては, kの下に recordを allocateする.

2. FIXSによって定義された関数 (k (· · ·) B) において, Bで最初に実行される FIXSに対しては, kの下に recordを allocateする.

3. FIXSの本体, つまり, (FIXS [(k (· · ·) · · ·)] B)のB内で最初に実行される FIXSに対しては, kの下に recordを allocateする.

18 以下で述べる closure変換アルゴリズムはこの stack管理を実現するために上の, kやその formatを引数として受けとらなくてはならない (以下の tおよび T ).

27 Closure変換アルゴリズムClosure変換アルゴリズムは以下のような呼び出し形式をしている.

C I f F s⃗ B D t T

ここで,

• I—変換するCPS命令

• f—Iが現れる最内側のCPS関数の名前 (つまり, 現在変換中のCPS

関数名)

• F—f が定義されている FIXの closure record format

• s⃗—f が定義されている FIXで, 同時に定義されているCPS関

• B—Iの外側かつ f 内で束縛されている変数の集合

• D—Iの外側で束縛されている変数の集合

18なぜこれで正しいのかをきちんと証明する仕事は完成していませんが, おそらく正しいと思われます. 力のあある人の貢献を期待します.

69

Page 70: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• t—I 中で次に現れる FIXSが, stack命令に与える baseとして使うcontinuation record名

• T—tの closure format

Cは再帰的に式をたどっていき, 順に変数参照を解決していく.

27.1 APP

まず leafである (APP g (a⃗))(ここでAPPは APPFまたは APPBのどちらか)においては,

• 変数参照をすべて解決し,

• gが closureであることを意識して, code番地を明示的に取り出し,

かつ g を追加 argumentとしてに渡す,

ように変換する.

C (APP g (a⃗)) f F s⃗ B D t T

= S→ V (g :: a⃗) (rref [g 0] [g′] [(APP g′ (g a⃗))]) f F s⃗ B

27.2 Primitive

Primitiveは, 使用されている自由変数の参照を解決し, B,Dを適切に拡張して, 再帰的に各部分式をたどる.

C (p [a⃗] [d⃗] [C⃗]) f F s⃗ B D t T

= S→ (V a⃗) I s⃗ f F B

where I ′ = (p [a⃗] [d⃗] [C→ C⃗ f F s⃗ (B + d⃗+ V a⃗) (D + d⃗) t T])

C→ C⃗ · · ·は C⃗の各要素Ciに C Ci · · ·を適用する.

ここで, 再帰呼出時にBに, V a⃗を加えているが, これは別に加えなくても良い. 加えることによって, 以降の命令で, V a⃗に含まれる変数を再び使用した時に, それを再びメモリから読み込んだりしなくて良いようになる (Sにおいて, v ∈ Bとなる機会が増える). 一方これを取り除くと, 同じ自由変数を繰り返し使用した時に, それらを毎回メモリから取り出したりすることになるが, 利点として, Closure変換後の各時点において保持

70

Page 71: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

しなくてはいけない自由変数の数が減るため, 使用するレジスタの数が少なくなる可能性がある 19. 演習として, 適当に少ない数のレジスタ数を仮定して, 両者で特および損をするMin-Scheme プログラムを作ってみよ.

一方のDについても, 実は V a⃗を加えても加えなくても良いが, ここでは加えていない. これは closure変換中, FIX命令に遭遇した時に, そのclosure formatを計算するために用いる. 具体的には, D中に含まれない変数を外部変数 (大域変数)であると仮定して, closure recordに入れなくて良いようにするために用いる. したがって, Dは小さく保っていた方が良い. 逆にいえば, もし何らかの理由で大域変数への参照に時間がかかる場合には入れても良いのである. 演習として, 両者で生成されるコードに差があるMin-Scheme プログラムを作ってみよ.

27.3 FIX

一番ややこしいのは FIXである. FIXH, FIXSどちらにおいてもながれは同じで,

• まず closure record formatを計算する.

• 定義される各関数を変換する. 具体的には, 各関数定義の parameter

に, その関数の closure recordを参照する parameterを追加し, 関数本体部における自由変数の参照を解決する.

• FIXの本体部を変換する. 具体的には, その先頭で closure recordを生成し, (ここで FIXSに対しては stackを, FIXHに対しては heap

を使う)本体内における自由変数の参照を解決する.

であるが, closure record formatを計算するときに, FIXHにおいてはそのまま CF を用い, それらすべての要素からなる recordを heap 命令を用いて割り当てるが, FIXSにおいては, T を包含する closure format を求めた上で, T との差分だけを stack命令を用いて割り当てるようにする. また, 本体部を変換する時の, tおよび T の更新のしかたも, FIXHと FIXS

では異なる. 両者に共通して,

C (fix [⃗b] I) f F s⃗ B D t T

19つまり最も良いのは, レジスタの数が足りるのであれば加え, そうでなければ加えない, あるいは良く使われるものは加える, などの使いわけである. . .

71

Page 72: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

= (fix [Cfbinds b⃗ F′ {k1, · · · , kn} D t′ T ′]

(C I ′ f F s⃗ (B + {k1, · · · , kn}) (D + {k1, · · · , kn}) t′ T ′))

である. ただし, b⃗で定義される関数の名前を順に, k1, · · · , knとした.

ここで,

F ′ =

CF b⃗ D—(FIXHの場合)

((CF b⃗ D)− T )@T—(FIXSの場合)

である. @は appendを表す. すなわち FIXSの場合は, T を suffixとして含む形で, この FIX命令の closure formatを定義する.

t′および T ′はそれぞれ,

t′ =

{t—(FIXHの場合)

k1—(FIXSの場合)

および,

T ′ =

{T—(FIXHの場合)

F ′—(FIXSの場合)

最後に, I ′は,

I ′ =

(heap [F ′] [k1]

(roffs [k1 1] [k2]

(roffs [k1 2] [k3]

· · ·I)))—(FIXHの場合)

(stack [t (F ′ − T )] [k1]

(roffs [k1 1] [k2]

(roffs [k1 2] [k3]

· · ·I)))—(FIXSの場合)

FIXSの場合, F ′ − T によって T との差分だけを割り当てている (tは拡張するための起点として指定されている). また, FIXSの場合, 我々のコンパイル方式では, 一つの関数しか同時には定義され得ないので, k2 以降の roffsは実際には現れないが, 統一性を重んじてあえて書いてある (原理的には複数のFIXSを一つにまとめることも可能であり, 上の式はその場合にも対応している).

72

Page 73: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

28 抽象機械コード生成の概要Closure変換後, 各CPS関数は自由変数を持たない. 全ての変数はその関数の引数であるか,その関数内の primitiveで束縛された変数である. 今や関数は, parameterを所定の registerに受けとり, 一連の primitiveを実行した後, 最後にAPPによって他の場所に制御を移すだけのものと考えることができる. 例えば,

(define (f x)

(+ x (g x)))

は, CPS変換後,

[f (c x)

(FIXS [(k (r) (+-two [x r] [v2] [(APPB c (v2))]))]

(APPF g (k x)))]

となり, これは closure変換によって,

(f: (f c x)

(FIXS [(k: (k r)

(rref [k 2] [x]

[(+-two [x r] [v]

[(rref [k 1] [c]

[(rref [c 0] [c’]

[(APPB c’ (c v))])])])]))]

(stack [c (label k:) c x] [k]

[(rref [(label g) 0] [g]

[(rref [g 0] [g’]

[(APPF g’ (g k x))])])])))

となる.20 もはや, kが, fの内部で定義されていたこと (つまり, fの中で定義される変数を使っていること)は忘れてよい. それらは明示的に, kという closureから取り出されているのである. さらに fにとっては, closure

変換以前は FIXSという魔法の operatorによって作られていた関数 kも,

もはや fの body部で stackという命令によって作られるただの record

に過ぎなくなっている. したがってコード生成器は, 各CPS関数を独立20gを呼ぶ時のパラメータの順番が微妙に先週までと違っていて, それについては後に説明する.

73

Page 74: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

した関数として, 内部のFIXを無視してコードを生成すれば良いことになる.

28.1 Register割り当て

抽象機械コード生成において行なう, 唯一の複雑な仕事は register割り当てである. 各変数がどの registerにあるか (register map, 以下M)を覚えておきながら, CLO式を再帰的にたどっていく. ある変数の使用が現れたら, その変数に割り当てられている registerをM から求めてコードを生成する. その後その変数が今後使用されていないとわかれば, M からそれを取り除く. また, ある変数を束縛する演算が現れたら, その場で空いている registerを見つけ, そのmappingを付け加えた新しい register

mapを以下の命令に渡す.

例えば上で, f:のコード生成を考えよう. 上でも述べたように内部のFIXSは別途考えれば良く, 今は無視して良い. また, 各式の右にそれぞれの式内で使われている変数 (つまり自由変数)を列挙した.

(f: (f c x) |

(stack [c (label k:) c x] [k] | c, x

[(rref [(label g) 0] [g] | k, x

[(rref [g 0] [g’] | k, x, g

[(APPF g’ (g k x))])])])) | k, x, g, g’

最初, M は conventionにより, register iに第 i引数を格納しているとする. つまり初期状態では, M は,

M = (f, c, x, X,X, · · ·)

となっている (X は空きを表す. この listの長さは使用可能な registerの数に依存する).

最初の命令のコード生成をはじめる前に, その命令の自由変数が {c, x}であることを使って, M から fを取り除いておく. 結果としてM は以下のような状態になる.

(X, c, x, X,X, · · ·).

この状態で,

(stack [c (label k:) c x] [k]

74

Page 75: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

のコード生成をする. オペランドである, (label k:), c, xをどのようにアクセスするかはわかっている. (label k:)は定数で, c, xはそれぞれ, レジスタ r1, r2に載っている. 後は kに対して空いている registerを割り当てれば良く, 今, r1を割り当てたとする (r1は現在 cが割り当てられているが以降では使用されないので, kのために使用可能である. 詳細は後述).

まとめると,

(stack [c (label k:) c x] [k]

に対しては,

(stack r1 (label k:) r1 r2 r1)

という抽象機械命令を出す.

28.2 Register割当の strategy: Register Targetting

このようにして再帰的に式をたどりながらコードを生成していくと, 最後に leafであるAPPにたどりつく. その際に行なうことは現在の register

mapを見て,引数を決まった registerに並び替えることである. N個の変数を配置するためには最悪 1個余分な temporary registerを用いて, N+1回のmoveが必要になる. これを減らすためには primitiveに対して register

を割り当てる時点である程度の「先読み」をすれば良いことになる. 二つの互いに補い合う戦略がある.

Targetting: 変数 vに registerを割り当てる時, vがこの先のAPP で第 i

引数として使われており, かつその時点で第 i registerが空いていたら, vにそれを割り当てる.

Anti-Targetting: 逆に, 変数 vに registerを割り当てる時, vがこの先のAPPで引数として使われていない場合, APPで使われる registerを避けて割り当てる.

ここで,「この先のAPP」には実は複数あり得るので,「もっともありそうなAPP」を選ぶ必要が生じ, それには branch predictionが必要である(この resumeでは, 真面目な branch predictionはさぼっている).

75

Page 76: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

28.3 Register Spilling (省略!)

Register spillingはないか, あっても稀だと仮定 (!)する.

どうしても必要な registerがCPUの register数を越えてしまったら, そこだけメモリを使って emulateする, というのがもっとも手っ取り早い方法.

29 抽象機械直接本物の target machineのコード生成をする代わりに, 複雑なma-

chine固有の制約がない抽象機械のコードを生成する. その後に抽象機械のコードをmachine固有の制約を加味しながらそれぞれの CPUの命令に変換する. 例えば, 命令長を固定した多くのmachineでは, 足し算のoperandとして, あまり大きな定数はとれない (これは命令長が固定されているから当然である). 抽象機械にはこの手の制約は差し当たってないものとする.

29.1 Operand

抽象機械の命令が operandとしてとれるものは以下の 3種類.

• register

• 定数 (整数, 文字, 論理値, 空リスト)

• label (global変数またはコードの番地)

Labelはmachineから見ればただの大きな整数だが, それが必ずしも com-

pile時には決まらない (link時に決まる)点が違う.

Registerの数は, 実際の機械から決まる. 実際にはいくつかの register

が特殊目的 (例えば stack pointer)に予約されるので, 抽象機械の register

の数は, 実際の register数よりも少なくなる.

29.2 命令セット

29.2.1 CPSに対応する命令

まずCPSにある各 primitiveに対応する命令があるとする. 例えば,

76

Page 77: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(+ [x y] [t] [...])

に対応して,

(+ op0 op1 dest)

という命令がありまた,

(heap [x y z ...] [t] [...])

に対応して,

(heap op0 op1 ... opn dest)

がある. どちらの場合も最後の destは registerである.

一般的に, 分岐以外のCPS primitive:

(p [a⃗] [d⃗] [C])

に対して,

(p a⃗ d⃗)

という, 対応する抽象機械命令がありまた, 分岐CPS primitive:

(p [a⃗] [] [C0 C1])

に対しては,

(p a⃗ l)

という, 比較が真であればラベル lに分岐する命令があると仮定する.

29.2.2 Move

最後のAPPで registerを並べかえる際に使う.

(move x y)

で, xの内容を yに移す. yは register.

77

Page 78: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

29.2.3 Jump

最後のAPPで制御を移す時に使う.

(jump x)

で, xに jumpする. xは任意の operand. 固定した番地ではなく, register

が示す番地に jumpできることが重要である.

29.2.4 Label擬似命令

命令中に,

f:

(+ r0 r1 r2)

...

のように埋め込むことで, (label f:)がその番地を示すようにする. Com-

pilerにとっては次の 3つの目的に使われる.

• Toplevelで定義された各変数の値をおく場所.

• Branch命令の label.

• 各関数のコードを開始する場所.

29.2.5 Live-reg擬似命令

各 CPS関数の直前においておき, その関数が試みる heap limit check

の sizeおよび, その関数がどの parameterを使うかを教える. Garbage

collectorが使う. 詳しくは後述するが, 文法は,

( l i v e−reg $s$ $B$)

のようで, sが heap limit checkを試みるword数, Bは各要素が 0または1の listで, 長さは parameter渡しに使われる registerの数である.

例えば, f:という関数が, 10 wordの heap limit checkをし, 第 1, 2 pa-

rameterを使うのであれば,

78

Page 79: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(live-reg 10 (0 1 1 0 0 ..))

f:

(+ r1 r2 r3)

...

のように書く. 実際に binary中に埋め込まれるものは, (live-reg 10 (0

1 1 0 0 ..))を encodeした 1ないし 2 wordである (これは一見して無意味な命令がそこに書かれているように見えることになるが, そこの命令を実行しようと試みるものがいない限り大丈夫である).

30 コード生成の前処理

30.1 各CPS式の自由変数

最初のコード生成の例で見たように, register mapを更新していく際に各命令の中で使われている変数 (自由変数)が必要になる. それを必要になるたびにいちいち木をたどって計算していたのでは, 非常にコード生成が遅くなる. ある節に対する自由変数の計算は木のノード数をN としてO(N)かかる. もし, CPS primitive一つに対して一回その木をたどって自由変数を計算していたら, 全体として, コード生成の計算量はO(N2)になってしまうだろう. そこで, あらかじめ木を一度たどって全ての木の節に「自由変数」を付加してしまうのが良い (これはO(N)で行なえる).

30.2 各CPS式の leafに現れるAPP

Register targettingを行なうために, 各 primitiveにたいして「その式のleaf に現れるAPP」が知りたくなる. これも各 primitiveに対する命令を生成する段階になって leafまでたどりにいくと, 全体でO(N2)になってしまう. この情報もあらかじめ全ての節にたいして求めておけばO(N)である.

CPSには branch命令があるので各CPS式の leafに現れるAPPには複数あり得る. 全てを求めておいても良いが, ここでは単純に,「常に, その部分木の一番右端のAPP」を求めることにする. Register targettingをうまく働かせる意味では, これは全く最適なやり方ではなく,

最もありそうな pathをある賢いやり方で選択する

79

Page 80: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

という方法の方が良い. しかしこれをするためには, 各 branchでどちらがよりとられやすいかの予測 (branch prediction)が必要になり, それにはCPS式において loopを検出するなどの作業が必要になる. それは難しくはないが, 面倒である. 上でやってることは全ての branchは左よりも右に良く分岐する (つまり条件が成立しない)と予測していることになる. これは再帰を使って loop を書く時の styleとして,「ある条件が成立したら抜ける」という書き方をすることが多いからである. 例えば, listから 0

を見つけるという関数を書くのに,

(define (find-zero l)

(cond ((null? l) #f)

((= 0 (car l)) #t)

(else (find-zero (cdr l)))))

と書くように.

31 コード生成の実際コード生成器は, CLO式にある情報を付加した拡張CLO式,と register

mapを引数として受けとる. その付加される情報とは以下の通りである.

• その式の自由変数

• Targettingのための変数並び (具体的にはその式の leafのどこかに現れる, (APP f (a⃗))において, f と a⃗に現れる変数と, 最終的にそれらがおかれるべき場所.

また, 普通の register割り当てには決して使われない temporary register t

が一つあるものとする.

31.1 APP

(APP f (a⃗))では以下のことを行なう命令列を生成する. f が変数かそうでないかで微妙に違う.

f が変数の場合: 1. f および a⃗の中の各変数引数を所定の registerの上にmoveする.

80

Page 81: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

2. a⃗の中の変数以外の引数 (定数および label)を所定の registerの上に生成する.

3. f に jumpする.

f が定数または labelの場合: 1. a⃗の中の各変数引数を所定の register

の上にmoveする.

2. a⃗の中の変数以外の引数 (定数および label)を所定の registerの上に生成する.

3. f に jumpする.

となる. 前者の場合, f を適当な引数に使われない register(例えば引数が5 個なら第 5 register)においておけば良い.

この中で唯一 trivialだといえないのが, 最初の, a⃗(または fおよび a⃗)の中の各変数引数を所定の registerの上にmoveする部分で, 第 i 引数を第 i

registerにmoveする時に, その registerに他の引数がのっていたら, それを最初に所定の位置に逃がしてやらないとまずい. それを逃がしてやるためにさらに他の registerを逃がさなくてはいけないかも知れない. そして一番面倒なのはその関係が cycleになっている場合である. 今,

(APP f (x y))

において, xが r1, yが r0に載っていたとする (r?は第? register). x→r0

としたいが, そのためには, yをどかしたい. yの最終目的地は r1なので,

y→r1としたいが, それには xをどかしたい · · · となる.

このような場合は一つ予約済みの temorary register tを用いて,

(move r0 t) ; (y -> t)

(move r1 r0) ; (x -> r0)

(move t r1) ; (y -> r1)

とする.

31.2 Primitive

最初から branchとそうでない場合を区別した方がわかりやすいのでそうする.

81

Page 82: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

非 branch: 1. 受けとった register map Mを使って各引数の registerを求める.

2. Mの内,引き続くCPS命令の中で使われている変数のmapping

のみを残した register map M ′を作る

3. M ′と targettingのための情報を使って,この命令で束縛される変数に registerを割り当て, 自分自身に対する命令を生成するとともに, そのmappingを追加した register map M ′′を作る.

4. 最後に, この命令で束縛される変数が以降使われない時のために, M ′′から, 引き続くCPS命令の中で使われている変数のmappingのみを残した register map M ′′′を作る.

5. 引き続く命令列に対し, M ′′′ を渡して命令列を生成し, そのprimitive 自身のための命令列と直列に並べる.

Branch: 1. Uniqueな label Lを生成する.

2. 受けとった register map Mを使って各引数の registerを求める

3. 引き続く各命令列に対し, その中で使われているmappingのみを残した register map M ′

1,M′2を作る.

4. M ′1,M

′2を使って各枝の命令列 (それぞれ T,E とする)を生成

する.

5. ( ( compare? op0 op1 $L$ )

$E$

$L : $

$T$)

のように全体をつなげる.

ここに書かれている新しい register mapを作る「順番」は割と微妙で重要である.

1. Mから,それ以降の命令で使われる変数のみを残したmapping (branch

の場合複数個)M ′を作り,

2. M ′を使って, その命令で束縛される変数に対する registerを見つけ,

M ′にそれを追加したmapping M ′′を作り,

82

Page 83: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

3. M ′′から,再びそれ以降の命令で使われる変数のみを残したmapping

M ′′′を作る. 以降の命令にはM ′′′を渡す.

1.をやらないと, 2.で operandとなる registerは絶対に destinationとして再利用されない. 3.をやらないと, この命令で束縛された値が実は使われないという時に, 直後の 1命令では, その registerを再利用できない.

31.3 各関数に対する命令列生成とheap limit check

各CPS関数に対する命令列生成もあまり難しいことはない. 単に初期の register mapを作り (第 i parameterが第 i registerにmappingし, body

部で使われているもののみを残す), body部に対してコード生成をし, 先頭に labelをはれば良い.

Heap Limit Check しかしここでそれに一つだけ少しだけ大した仕事を行なうことにする. 関数の先頭で, その bodyで使われる全ての heap割当のための heap limit checkを一度に行なうことにする. たとえその中で何回 heap命令が実行されていようとも行なわれる heap limit checkは一回だけであるところがすばらしい. 21 例えば,

(heap [a b] [c]

[(heap [d c] [e]

[(heap [f e] [g] ..)])])

のような列にたいしては容易に, 割り当てられる合計は 3 recordで, 内容は 6 wordであると計算できる.22 Branchがある場合は各枝のmaxをとればいい.

このようにして命令列全体にたいして割り当てられる heapの量の上限を見積もることができるから, それを関数の先頭で checkする. その check

のしかたは heapの構成にもよるが, ここでは以下のようにする.

21このように複数の heap allocationの limit checkをそれらの合計でmergeできるためには, memory allocationが連続領域から行なわれる必要がある.

22来週話すように, GCのために各 record の先頭に 1 wordの tagをつけるので, 合計9 word必要になる.

83

Page 84: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

Heapの構成 Heapは図のようになっている. これは standardな copying

GCのための heapの構成である.

h

B

E

L

A B

K

allocated

二つの同 sizeの領域A, Bにわかれており,常にどちらかの領域をactive

な領域と呼び, activeでない方は空である. Activeな領域の先頭 (B)からあるところ (h)まではすでに objectが割り当てられており, そこから領域の最後 (E)まではまだ objectが割り当てられていない. Allocationは hが指す場所から行なわれる.

さて, 単純に考えると, c byte (cは compile時定数)の heap limit check

は, h+ cとEを比較すれば行なえるが, 少し trickを使って, E−Kを常に指す register (Kは適当な定数, 例えば 256)Lをつくっておけば, hとLを比べて hの方が小さければそれは「少なくともK byte」空きが残っていることを保証していることになり, cがK以下の時は 1 compareで check

が行なえる. cがK以上の時は一度足し算をしなくてはならない.

さて, GCを呼ぶこと自体は,

84

Page 85: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(jump gc:)

命令で行なえるが (gc:以降に, GCのための命令が書かれている), GCには以下の情報を渡す必要がある.

• 現在のコード番地 (c)

• その関数先頭での register map (M)

• 試みた allocation size (s)

現在のコード番地は gcが returnをするために必要である. その関数先頭での register mapは gcが live objectを見つける rootを知るために必要である. 試みた allocation sizeは, GCが復帰後試みた allocationが確かに満たされるだけ heapが残っているかどうかを検査し, 満たせなかったら programを終了させるために必要である. さもないと, 1億 byteのallocationを試みた programは, 永遠に望みのないGCをし続けることになるだろう.

そのための情報は, cを temporary registerにおき (parameterを渡すための registerはまだつぶしてはいけないことに注意)他の registerは, cのアドレスの 1 word前 (c[-1], c[-2]あたり)にしまっておく. そのために先の, live-reg擬似命令を使う. live-reg擬似命令の formatは, parameter

registerの数によって決まり, 実際には,

• heap limit check size

• 各 registerにつき,それが liveであれば 1がたっているようなbitmap

をつなげたものになる.

32 コード生成例以下の例では,いちいち, []と()を区別しない (programの出力を indent

しただけ). また, parameter渡しに使える registerは 16個とする.

32.1 単純な関数呼びだしScheme:

(define (f x)(+ x (g x)))

85

Page 86: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

CPS:(f (k1 x)

(fixs ((k3 (r4)(+-two (x r4) (v2)

((appb k1 (v2))))))(appf g (k3 x))))

CLO:(f: (f k1 x)

(fixs ((k3: (k3 r4)(rref (k3 2) (x)((+-two (x r4) (v2)

((rref (k3 1) (k1)((rref (k1 0) (lk1)

((appb lk1 (k1 v2))))))))))))(stack (k1 (label k3:) k1 x) (k3)

((rref ((label g) 0) (g)((rref (g 0) (lg)

((appf lg (g k3 x))))))))))

抽象機械コード:

((live-regs 0 (0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0))f:(stack r1 (label k3:) r1 r2 r1)(rref (label g) 0 r0)(rref r0 0 r3)(jump r3)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k3:(rref r0 2 r2)(+-two r2 r1 r1)(rref r0 1 r0)(rref r0 0 r2)(jump r2))

32.2 単純な loop

Scheme:(define (fact n p)(if (= n 0)

p(fact (- n 1) (* n p))))

ただし, 単純のため*は primitiveとする.

CPS:(fact (k1 n p)

(=-two (n 0) ()((appb k1 (p))(--two (n 1) (v6)((*-two (n p) (v7)

((appf fact (k1 v6 v7)))))))))

86

Page 87: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

CLO:

(fact: (fact k1 n p)(=-two (n 0) ()

((rref (k1 0) (lk1)((appb lk1 (k1 p))))

(--two (n 1) (v6)((*-two (n p) (v7)

((appf (label fact:) (fact k1 v6 v7)))))))))

抽象機械コード:

((live-regs 0 (1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0))fact:(=-two r2 0 (label l6:))(--two r2 1 r4)(*-two r2 r3 r3)(move r4 r2)(jump (label fact:))l6:(rref r1 0 r2)(move r1 r0)(move r3 r1)(jump r2))

32.3 Heap割り当てを含む例Scheme:

(define (make-3d-point x y z)(list x y z))

CPS:

(make-3d-point (k1 x y z)(heap (z ’()) (v4)((heap (y v4) (v3)

((heap (x v3) (v2)((appb k1 (v2)))))))))

CLO:

(make-3d-point: (make-3d-point k1 x y z)(heap (z ’()) (v4)((heap (y v4) (v3)

((heap (x v3) (v2)((rref (k1 0) (lk1) ((appb lk1 (k1 v2)))))))))))

87

Page 88: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

抽象機械コード: r9と r10を比べて heap limit checkをする. 本体はlabel l5:から始まる.

((live-regs 9 (0 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0))make-3d-point:(<-two hp hl (label l8:))(move (label make-3d-point:) tmp0)(rref (label gc) 0 tmp1)(rref tmp1 0 tmp1)(jump tmp1)l8:(heap r4 ’() r4)(heap r3 r4 r3)(heap r2 r3 r2)(rref r1 0 r3)(move r1 r0)(move r2 r1)(move r3 r2)(jump r2))

32.4 中で関数呼び出しをしまくる例Scheme:

(define (dist a b c x y)(let ((m (f+ (f* a x) (f* b y) c))

(n (sqrt (f+ (f* a a) (f* b b)))))(f/ m n)))

f+, f*, f/, sqrtはすべて関数呼びだし.

CPS:

(dist (k1 a b c x y)(fixs ((k6 (r7)(fixs ((k8 (r9)(fixs ((k4 (r5)

(fixs ((k2 (r3)(fixs ((k14 (r15)

(fixs ((k16 (r17)(fixs ((k12 (r13)

(fixs ((k10 (r11)(appf f/-two (k1 r3 r11))))

(appf sqrt (k10 r13)))))(appf f+-two (k12 r15 r17)))))

(appf f*-two (k16 b b)))))(appf f*-two (k14 a a)))))

(appf f+-two (k2 r5 c)))))(appf f+-two (k4 r7 r9)))))

(appf f*-two (k8 b y)))))(appf f*-two (k6 a x))))

88

Page 89: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

CLO:

(dist: (dist k1 a b c x y)(fixs ((k6: (k6 r7)

(fixs ((k8: (k8 r9)(fixs ((k4: (k4 r5)

(fixs ((k2: (k2 r3)(fixs ((k14: (k14 r15)

(fixs ((k16: (k16 r17)(fixs ((k12: (k12 r13)

(fixs ((k10: (k10 r11)(rref ((label f/-two) 0) (f/-two)((rref (k10 15) (k1)

((rref (k10 5) (r3)((rref (f/-two 0) (lf/-two)

((appf lf/-two (f/-two k1 r3 r11))))))))))))(stack (k12 (label k10:)) (k10)((rref ((label sqrt) 0) (sqrt)

((rref (sqrt 0) (lsqrt)((appf lsqrt (sqrt k10 r13)))))))))))

(stack (k16 (label k12:)) (k12)((rref ((label f+-two) 0) (f+-two)

((rref (k16 1) (r15)((rref (f+-two 0) (lf+-two)

((appf lf+-two (f+-two k12 r15 r17)))))))))))))(stack (k14 (label k16:) r15) (k16)

((rref ((label f*-two) 0) (f*-two)((rref (k14 7) (b)

((rref (f*-two 0) (lf*-two)((appf lf*-two (f*-two k16 b b)))))))))))))

(stack (k2 (label k14:) r3) (k14)((rref ((label f*-two) 0) (f*-two)

((rref (k2 8) (a)((rref (f*-two 0) (lf*-two)

((appf lf*-two (f*-two k14 a a)))))))))))))(stack (k4 (label k2:)) (k2)

((rref ((label f+-two) 0) (f+-two)((rref (k4 6) (c)

((rref (f+-two 0) (lf+-two)((appf lf+-two (f+-two k2 r5 c)))))))))))))

(stack (k8 (label k4:)) (k4)((rref ((label f+-two) 0) (f+-two)

((rref (k8 1) (r7)((rref (f+-two 0) (lf+-two)

((appf lf+-two (f+-two k4 r7 r9)))))))))))))(stack (k6 (label k8:) r7) (k8)

((rref ((label f*-two) 0) (f*-two)((rref (k6 1) (b)

((rref (k6 2) (y)((rref (f*-two 0) (lf*-two)

((appf lf*-two (f*-two k8 b y)))))))))))))))(stack (k1 (label k6:) b y c a k1) (k6)((rref ((label f*-two) 0) (f*-two)

((rref (f*-two 0) (lf*-two)((appf lf*-two (f*-two k6 a x))))))))))

抽象機械コード:

((live-regs 0 (1 0 1 1 1 1 1 0 0 0 0 0 0 0 0 0))

89

Page 90: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

dist:(stack r0 (label k7:) r3 r6 r4 r2 r0 r0)(rref (label f*-two) 0 r1)(rref r1 0 r4)(move r5 r3)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k7:(stack r0 (label k9:) r1 r5)(rref (label f*-two) 0 r1)(rref r0 1 r2)(rref r0 2 r3)(rref r1 0 r4)(move r5 r0)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k9:(stack r0 (label k5:) r5)(rref (label f+-two) 0 r6)(rref r0 1 r2)(rref r6 0 r4)(move r5 r0)(move r1 r3)(move r6 r1)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k5:(stack r0 (label k3:) r5)(rref (label f+-two) 0 r6)(rref r0 6 r3)(rref r6 0 r4)(move r5 r0)(move r1 r2)(move r6 r1)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k3:(stack r0 (label k15:) r1 r5)(rref (label f*-two) 0 r1)(rref r0 8 r2)(rref r1 0 r4)(move r5 r0)(move r2 r3)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k15:(stack r0 (label k17:) r1 r5)(rref (label f*-two) 0 r1)(rref r0 7 r2)(rref r1 0 r4)(move r5 r0)(move r2 r3)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k17:(stack r0 (label k13:) r5)(rref (label f+-two) 0 r6)(rref r0 1 r2)(rref r6 0 r4)(move r5 r0)

90

Page 91: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(move r1 r3)(move r6 r1)(jump r4)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k13:(stack r0 (label k11:) r0)(rref (label sqrt) 0 r4)(rref r4 0 r3)(move r1 r2)(move r4 r1)(jump r3)(live-regs 0 (1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0))k11:(rref (label f/-two) 0 r5)(rref r0 15 r6)(rref r0 5 r2)(rref r5 0 r4)(move r6 r0)(move r1 r3)(move r5 r1)(jump r4))

33 性能の改善について最後の例をネタに, 性能の改善について思いを馳せる.

一見して無駄だと思えるのは,

1. 各浮動小数点ルーチンの開始番地を得るためのメモリアクセス (2

回). 例えば, f/を呼ぶためのCPSはCLOでは,

(rref ((label f/-two) 0) (f/-two)

(( ...

(( ...

((rref (f/-two 0) (lf/-two)

((appf lf/-two (f/-two k1 r3 r11))))))))))

となり, それは最終的な抽象機械コードでは,

(rref (label f/-two) 0 r5)

...

(rref r5 0 r4)

...

(jump r4)

91

Page 92: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

のように 2回間接参照をしている. これらの間接参照の結果, 結局でてくるのは, 少なくともこの場合は (label f/-two:)のことであることを我々は, (f/-twoというルーチンがどこで何をするものか知っているので)知っている. これを直接, CLOにおいて,

( ...

(( ...

(( ...

(( ...

((appf (label f/-two:) (f/-two k1 r3 r11))))))))))

としてしまえるはずである.

2. 呼びだし時における返り番地の stackへの書き込み. 最初の関数呼び出しを除くほとんどの各関数呼びだし時に stackに書き込まれているのは, 返り番地か, 返り番地+直前の呼び出しの返り値, といったところである. これを, 返り番地はなんとか registerの上にのっけたまま呼びだしてしまいたくなる.

2回 indirectionについて 良く見ると原因が二つあって, それらは一応独立している.

• 1個目の accessは global変数の「値」をとってくる access

• 2個目の accessは closureから, コードの番地をとってくる access

まず, Min-Scheme Fixにおいては, 1個目は, calling conventionから,「呼ばれる関数が closureを必要としない」ということがわかっていない限り避けられない. 2個目は, 得られるコード番地がわかっていれば避けられる. 一方Min-Schemeにおいては, 2個目は必要ない. 1個目をやればその時にでてくるのがコード番地である. では 1個目についてはどうかというとここでもやはり, 得られるコード番地がわかっていれば避けられる. では, 一般に得られるコード番地がわかってるかというと, これにはいろいろ条件がついてややこしい.

まず一般には, CPSの

(APP f (x y z))

を,

92

Page 93: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(APP (label f:) (f x y z))

として良いわけではない. 第一に, fは toplevelで定義されているものではなく, 関数のパラメータか何かかも知れないし, Min-Scheme Fixにおいては内部で定義されたものかも知れない. それらの場合, (label f:)などというラベルが globalに存在するわけではない. 第二に, fがたとえtoplevelであったとしても, fそのものが関数として定義されているわけではなく, fは実はたまたま関数を値に持っている変数かも知れない. その場合も, (label f:)などというものが存在するわけではない. 例えば以下のような場合である.

(define (g x)

...)

(define f g)

この場合, (label g:), (label g), (label f)は存在するが, (label

f:)は存在しない. ここで, (label g:)は gを実現する命令列が書かれている番地, (label g)は gの closureへの pointerが格納されている番地, (label f)にはこの状況においては, (label g)同様, gの closureへの pointerが格納されている.

まとめると, どうしても一般的に上の最適化をしたければ, 各 toplevel

変数に対して, それが,

(define (f x)

)

として定義されたものなのか,

(define f ..)

として定義されたものなのかを区別できる必要がある. 分割コンパイルを許すことを考えると, このための情報は宣言として与えるしかない.

注: Cをよく知っている人への analogyをいうと,

f (x);

という呼び出しを見た時に, fが,

93

Page 94: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

f (x)

{

...

}

のように定義されているのであれば, そのまま fに飛べば良いが, もし f

は関数ではなく global変数の場合, つまり,

void g (x)

{

}

void (*f)() = g;

のような場合, f (x)を実行するには, 1メモリアクセスが必要である. C

はこの 2 caseを宣言により区別できるので前者に対しては効率的な jump

を生成できるが, Min-Schemeにおいてはそれが成り立たない. 宣言を付け加えない限り, 常に後者としてあつかわざるを得ない.

まとめると, ad-hocな方法として, コンパイラに組み込みの知識としてtoplevelで定義される関数の定義一覧を与えるというのは, 簡単にできるので, 差し当たってはこのようにしてしまうのが良いだろう. そうすればtoplevel関数の呼び出しに関しては, Min-SchemeだろうがMin-Scheme Fix

だろうが直接 jump先がわかる.

戻り番地の書き込みについて 次に戻り番地は stackに書かないまま (reg-

isterにおいたまま)呼び出すことができないか考える. 実はこれも一般的にやろうとすると結構面倒くさい. 例えば, CLOにおける, sqrtを呼び出すところで,

(stack (k12 (label k10:)) (k10)

((rref ((label sqrt) 0) (sqrt)

((rref (sqrt 0) (lsqrt)

((appf lsqrt (sqrt k10 r13))))))))

今回, stackに書かれているのは, (label k10:)でそれはつまり戻り番地だけである. これを,

94

Page 95: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(stack (k12) (k10) ; 何も書かない!

((rref ((label sqrt) 0) (sqrt)

((rref (sqrt 0) (lsqrt)

((appf lsqrt (sqrt (label k10:) k10 r13)))))))) ; 戻り番地も register

のようにできたら幸せなわけである. つまり calling convensionを変えて,

今まではレコードの中にはいっていた戻り番地を外に「追い出してやる」.

• 第 0引数 = 呼び出す関数の closure

• 第 1引数 = 戻り番地

• 第 2引数 = continuation record

• 第 3引数 = 引数 1

• 第 4引数 = 引数 2

• ...

のようにする. これは可能な最適化であるが, コメントをいくつか.

• この最適化の効果について. まずこれをやった場合戻り番地は絶対にメモリに書かなくて良くなって, なんてすばらしい, と思うかも知れないがそこまで話しはうまくない. このような calling convention

をとると, 各Min-Scheme関数は, 今まで一つであった継続引数を二つにわけて受けとることになる. それらは 100% その関数が return

する時まで生き続けるから, その関数が nestした関数呼び出しをする時は, 必ず saveされる. その時に saveされるword数は今までと比べてかならず 1 word増えている. つまりこの場合は, 戻り番地がsaveされるタイミングがちょっとずれたに過ぎなくなる. 本当に得をするのはその関数が nestした関数呼び出しをせずに戻ってきた場合である (もちろんこの例においてはそれでも十分嬉しいわけだが).

• これを実現するために必要な closure変換アルゴリズムの変更について.

FIXH 各関数の第 1引数 (continuation)を, 二つの語にわける. 例えば,

95

Page 96: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(FIXH [(f (k x y) ..)] ...)

を,

(FIXH [(f (k0 k x y) ..)] ...)

のように.

APPF (APPF f (k x y))

において,

– kがこのMin-Scheme関数内で作られた場合:

(APPF f ((label k:) k x y))

– kがこのMin-Scheme関数の continuation引数の場合:

(APPF f (k0 k x y))

FIXS FIXSにおける closure formatは, 戻り番地を格納しないように変更する.

APPB (APPB k (r))

を,

kがこのMin-Scheme関数内で作られた場合:

(APPB (label k:) (k r)),

kがこのMin-Scheme関数の continuation引数の場合:

(APPF k0 (k r)),

とする.

34 16 bit machineのためのレジスタ割り当て最後に. ここまでの話しはすべて,

Min-Schemeの 1語 = CPSの 1語 = 抽象機械の 1語

という前提であった. ところでレイトレを走らせるためには, 1語は 32 bit

なくてはならず, CPUが 16 bit machineの場合,

抽象機械の 1語 = 実機械の 1語

というわけにはいかない. その差をどこで吸収するかはなかなか難しい.

96

Page 97: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

抽象機械コード → 実機械コード

のフェーズでやろうとすると,

抽象機械の 1語 = 実機械の 2語

となって, 16 bitに収まるデータ (intger etc.)まですべてに 2語が割り当てられることになって悲しい. 各式の型を推論するというアプローチは,まっとうなアプローチではあるが, 今まで作ってきたコンパイラの passすべてに大規模な変更が必要である. そんなことをするくらいなら, なぜ最初からCなりMLなりを作らないのか? という疑問が生ずる (Min-Scheme

だったおかげでどれだけ楽ができているか暇があったら考えてみて下さい).

もっと手が抜けてかつ悲惨なことにならない (= integerなどに関しては1個しか registerを割り当てなくて済む)アプローチがあれば嬉しい. すると自然に思いつくのは register allocation phaseを少し変更して,「この先integer, pointer, etc.)としてしか使われていない変数には registerを 1個しか割り当てない, というアプローチをとれそうである. メモリに書き込まれたり, APPの引数になったりしたものはあきらめて 2 word割り当てる. 以上の処理を register allocation phaseを変更して実現することは可能だと思うが, これに関しては去年の鴨志田というエライ先輩が, elegant

な方法を示してくれたのでこれを紹介する.

そのアルゴリズムは具体的には, CLO→CLOの変換として働く. 原則として各引数を二つの語 (low word, high word)に splitする. 浮動小数点数以外は, low wordの方にのみ値が書かれており, high wordは必要ない. Primitive operationは low/highが両方必要な場合 (例えば (rset!

[r idx v] [] ..)の vは, low/highの両方をアクセスするように引数の数を増やすが, lowの方しか必要ない場合は明示的にそちら「だけ」accessするように変更する. 定義の方に関しても同様に, low wordだけを定義する命令 (整数の足し算など) は, 1 wordだけを定義するように, 両方を定義する命令 (rrefなど)は, 2 word分定義するように変更する.

例えば,

(+ [x y] [r] [( ...)])

において, xや yは low wordしか必要とせず, また, 定義されるのも low

wordだけなので,

(+ [xl yl] [rl] [(...)])

97

Page 98: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

のようになり,

(rref [r idx] [v] [(...)])

においては, r, idxは low wordしか必要とせず,定義されるのは low/high

の両方だから,

(rref [rl idxl] [vl vh] [(...)])

とする. また,

(stack [r x y z] [p] [( ..)])

の場合,

(stack [rl xl xh yl yh zl zh] [pl] [( ..)])

となる. APPに関しては,

• (APPF f’ (f k x y)) は, (APPF fl’ (fl kl xl xh yl yh)) のように Min-Schemeレベルの引数は両方使い, closureおよび con-

tinuationに関しては lowだけを使う.

• (APPB k’ (k r))は, (APPB kl’ (kl rl rh))のように, 返り値の引数は両方使い, closureは lowだけを使う

FIXS, FIXHにも対応する変更を加えるのはいうまでもない.

このような変換の結果, high wordが定義されていない変数のhigh word

を参照することがでてくる. 例えば,

(+ [x y] [r]

[(stack [k r] [c] ..)])

は,

(+ [xl yl] [rl]

[(stack [kl rl rh] [cl] ..)])

となり, rhは未定義となる. 未定義の変数はこの場合だけにしか現れないので, 便宜上の抽象機械 registerとして dummy registerを設けて, rh の使用を, dummy registerへの accessで置き換えてやれば良い. そして抽象機械コードから実機械コードへの変換時に dummy registerの使用に関しては適宜, 消すなり何なりしてやれば良い.

このやり方のうまい点は変換後のコードを単に同様の抽象機械コード生成器にかけるだけで,

98

Page 99: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• high wordが以降使用されない語に対して registerを割り当てたり,

• high wordが定義されていない語に対して registerを割り当てたり,

しなくて良い点である. 限界は, 上で使用/定義といったときに, 見ている範囲が非常に狭い範囲 (CLOにおける 1CPS関数)しか見ていない点で,

その結果関数呼び出しを跨って使用, 定義連鎖を覚えておくことはできない. 例えば,

(define (f x)

(h (+ x 1) (g x)))

において, (g x)呼出し前に (+ x 1)を行うとすると, CPSは,

(f (k1 x)

(+-two (x 1) (v4)

((fixs ((k5 (r6) (appf h (k1 v4 r6))))

(appf g (k5 x))))))

となり, CLOは,

(f: (k1 f x)

(+-two (x 1) (v4)

((fixs ((k5: (k5 r6)

(rref ((label h) 0) (h)

((rref (k5 1) (k1)

((rref (k5 2) (v4)

((rref (h 0) (lh)

((appf lh (k1 h v4 r6))))))))))))

(stack (k1 (label k5:) k1 v4) (k5)

((rref ((label g) 0) (g)

((rref (g 0) (lg)

((appf lg (k5 g x))))))))))))

となる. (+ x 1)の結果は v4に bindされており, したがって v4が low

wordしか必要していないのは明らかだが, 関数呼出し後 (つまり k5の中),

それは

(rref (k5 2) (v4)

99

Page 100: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

によって取り出されて, そして

(APPF lh (k1 h v4 r6))

によって使われているために, 二つの registerを割り当てざるを得ない.

これによってどのくらい無駄が生ずるのかは実際のところ良くわからない.

35 今後の予定• 11/27—抽象機械→本物の機械の変換

• 12/4—GC

• 12/11—その他の可能な最適化

• Advanced Topics in Compilers and Runtime Systems

36 レポート12月 11日

までに,

• Closure変換

• 抽象機械コード生成

のアルゴリズムを完成させ, いくつか動作確認をした例とともに各班ごとに一つ提出せよ. 抽象機械を飛ばしていきなり実機械コードを出す場合(お勧めはしませんが), 後者をそれで置き換え可能. CPSに基づかないコンパイラの場合対応するところ (抽象, または実機械コード生成)まで.

レポートは, 電子mailで, プログラムと, 動作確認の例でよい. 詳しい説明は不要.

宛先は,

tau@...

まで.

100

Page 101: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

A 参考データ一昨年の川道君が作成し, 去年の長野君が手直しした Scheme用のレイトレーシングのプログラム (̃tau/comp/RT nagano/)の各fileを今まで述べた方方でコンパイルした時の統計. すべてコンパイル時に得られた static

な統計であり,実際に走らせた時のものではないので使用には注意が必要.

A.1 レジスタ利用

プリミティブの targetに対してレジスタを割り当てる時どの番号がどのくらい使われているか. 1 Scheme word = 1 machine wordの場合と, 1

Scheme word = 2 machine wordの場合 (括弧内). Anti-Targetting時および Targettingが失敗した時には可能なレジスタのうちもっとも小さな番号を割り当てる.ファイル名 0—3 4—7 8—12 13—16 16—19

ReadData.scm 484 (427) 484 (902) 3 (343) 0 (0) 0 (0)

Scan.scm 329 (284) 430 (584) 40 (436) 1 (54) 0 (15)

Tracer.scm 107 (107) 78 (148) 1 (68) 0 (4) 0 (0)

Solver.scm 299 (246) 357 (538) 33 (374) 4 (37) 0 (6)

Shadow.scm 80 (82) 71 (110) 1 (76) 0 (5) 0 (0)

Subroutine.scm 538 (486) 622 (955) 2 (529) 0 (6) 0 (0)

rt.scm 53 (40) 22 (84) 0 (2) 0 (0) 0 (0)

いいたいことは,

• 16 bit machineにおいても汎用レジスタは 20個あれば溢れない.

• 汎用レジスタが 12個くらいあれば, 番号の大きなレジスタをメモリでエミュレートしても penaltyは (多分)それほど大きくない.

一般にレジスタをたくさん使用する原因となるのは,

• たくさん引数を持つ関数,

• bindされた変数をなかなか使わない計算

である. 溢れがいやならこれらを手がかりに直すことができるだろう.

101

Page 102: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

A.2 Stack

実行された stack命令と, 書き込まれた語数. 1 Min-Schemeword = 1

machine word. したがって 16 bitを 1語と換算すると, もう少し増える.ファイル名 stack命令 書かれたword数ReadData.scm 181 289

Scan.scm 135 215

Tracer.scm 25 50

Solver.scm 138 221

Shadow.scm 19 43

Subroutine.scm 223 343

rt.scm 11 26

staticに平均すると平均して, 一回につき 1.62 word書かれている. つまり一回関数呼び出しなどで stackに何かを積む場合に, 1.62 wordメモリに退避するということである. そのうちの 1 wordは戻り番地である. 1

wordが 16 bitの場合, 戻り番地の high wordは書き込まなくて良い. すると最悪の場合を考えると, 1 + 0.62× 2 = 2.24 wordになる.

A.3 Move

APP時に必要なmoveの数. 1 Min-Schemeword = 2 machine wordで,

今は undefinedであるがために必要のないmoveまで数えている. APPF

とAPPBで別に数えた. x/y/zで, 計 z回のAPPに対して, 計 y個の引数が渡され, それに対して x個のmoveが必要であったことを示している.

hline ファイル名 APPF APPB

ReadData.scm 434/930/184 56/81/27

Scan.scm 389/824/134 70/93/31

Tracer.scm 51/140/24 51/72/24

Solver.scm 439/796/137 53/90/30

Shadow.scm 54/146/22 38/51/17

Subroutine.scm 645/1194/220 99/135/45

rt.scm 29/62/14 10/15/5

staticな平均をとると, 1 APPFにつき 2.77 move, 1 APPBにつき, 2.1

move. また, 1引数につき 0.5 moveくらい必要で, targettingがそこそこうまくいっているということがいえる.

102

Page 103: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

前章と合わせるとMin-Schemeの関数呼びだし 1回の, 非常に大雑把なコストが見積もれる.

Min-Schemeの関数呼びだし = 2.24 stack write + 2.77 move

+ 2 indirection + jump + 1 indirection + 2.1 move + jump

+ 1.24 stack read = 6.48 memory read/write + 4.87 move +

2 jump.

これは stackへの値の退避,引数の並びかえ,コードアドレスを得るためのindirection, jump, 戻り番地を得るための 1 indirection, 返り値を返すためのmove, jump, 退避された変数の restoreを含んでいる. また退避された変数は restoreされるとはかぎらないが, 今は restoreされるとしている.

また, 先に述べた, indirectionをなくすための方法を導入すれば, 6.48

の部分が 4.48になるでしょう.

B 訂正GCのことを考慮して, 先週の CLOの 1 wordを 2 wordに分離するところを少し訂正します. 先週の資料, 19ページ第 3パラグラフ後半

. . .となる. APPに関しては,

• (APPF f’ (f k x y))は, (APPF fl’ (fl kl xl xh yl

yh))のようにMin-Schemeレベルの引数は両方使い, clo-

sureおよび continuationに関しては lowだけを使う.

• (APPB k’ (k r))は, (APPB kl’ (kl rl rh))のように,

返り値の引数は両方使い, closureは lowだけを使う

. . .

とありましたが, FIXSで定義される関数とFIXHで定義される関数とで,

引数のレジスタの low/high wordが揃っていた方が簡単なので, 以下のようにする.

. . .となる. APPに関しては,

• (APPF f’ (f k x y))は, (APPF fl’ (fl kl xl xh yl

yh))のようにMin-Schemeレベルの引数は両方使い, clo-

sureおよび continuationに関しては lowだけを使う.

103

Page 104: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• (APPB k’ (k r)) は, (APPB kl’ (0 kl rl rh)) のように, 先頭に 0を入れ, 返り値の引数は両方使い, closure

は lowだけを使う

. . .

これは各関数の先頭でGCが起きた時に, 第 0, 1レジスタには low word,

以降は lowと highが交互に並ぶという conventionを守りたいためにこのようにする. (GCがなければ先週同様で良い).

C 実機械コード生成の概要先週定義した抽象機械と実際の機械の間には, 機械ごとに大小様々な差がある. 各機械ごとに固有の事項は個々の機械ごとに必要なところを埋めてやるしかないのでここでは詳しく述べないが, およそどんな機械であってもおこなわなくてはいけない話しは, データ表現の決定である. 実機械ではどんなデータも整数として表さなくてはいけないので, 抽象機械レベルにおいてもなお, 「抽象化」されていたデータ表現をここで, 具体的な整数値に置き換えてやる必要がある.

Min-Schemeの 1 wordは,

• 整数

• (レコードへの)ポインタ

• 浮動小数点数数

• 論理値 (#tおよび#f)

• 文字

• 空リスト

のいずれかを表し, 抽象機械では (1 Min-Scheme word = 2抽象機械word

を仮定すると),

• 整数

• (レコードへの)ポインタ

104

Page 105: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• 浮動小数点数数の下位word

• 浮動小数点数数の上位word

• 論理値 (#tおよび#f)

• 文字

• 空リスト

のいずれかとなる.23 今回の資料では, 以下単にwordといった時には, 抽象機械 wordまたは実機械 wordのことを指し, Min-Scheme 1 wordは機械の 2 word に対応するものとする. さて, 次の二つの理由から, これらを実行時に区別できなくてはいけない.

• null?, boolean?などの型判定述語のサポート

• GCによる, あるデータがポインタか否かの判別

実際にはどちらもレイトレだけを動かすためにはなしですませられるものだが, 24 ここでは一般的な, 各下位word中の各 2 bitを tagとして用いる方式を紹介する. つまりここでは浮動小数点を表すのに 30 bitしか使えない (2 bit削っても精度的にはOKと仮定する. ルール違反ではないでしょう)とする. 上位wordについては tagはつけなくて良い.

C.1 Tagging Scheme

Tagging Schemeは, あるデータの論理的な値 (つまり抽象機械における値)が与えられた時に, それをどのような整数で表すか, を計算する関数T を与えることによって表現される. 実際の taggingはかなり任意性のあるもので各種制約に応じて決めるのが良い. ここでは例として以下のようにする.

整数 T (i) = 4i+ 1 つまり整数の下位 2 bitは 01.

23浮動小数点以外は, 片方の wordにしか意味がない.24注: レイトレが null?などを使わないという意味ではない. 使われてはいるが, そこでは空リストと, 空でないリストだけが区別できれば良いのであって, その他のすべてのデータ (たとえば整数)と厳密に区別できなければいけないわけではない, という意味である. 詳しくは後述.

105

Page 106: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

ポインタ T (a) = a− 1 (ただし, aが実際にデータが割り当てられているアドレスで, それは 4の倍数と仮定する.) つまりポインタの下位 2

bitは 11.

浮動小数点数 30 bitの表現を, h: 上位 16 bit, l: 下位 14 bit として, それぞれの bit表現を整数だと思った時に

• 下位 word: T (l) = 4l つまり浮動小数点数の下位 wordの下位2 bitは 00

• 上位word: T (h) = h つまり浮動小数点数の上位wordには tag

はつけない.

その他 ここまでで, 下位 2 bitは, 01, 11, 00が使われているので, 残る10を用いて, さらに tagをつけて, 論理値 (2通り), 文字 (256 通り),

空リスト (1通り)を encodeすれば良い (省略).

上位wordには tagがついていないので,たとえばメモリ中のあるwordに,

0001000100110011というデータが格納されていた時, それが何か浮動小数点の上位 wordなのか, それともアドレス 0001000100110100を指すポインタなのかを別の方法で区別できなくてはならない. その仕掛けは,

• 型判定述語に関しては, それは最初からわかっている (なぜならば,

プリミティブの引数のどれが, 下位wordかはプリミティブごとに決まっている).

• GCに関しては, それがGC起動時にレジスタに載っているものであれば, 関数先頭におけるレジスタ使用の convention (具体的にはr0, r1, および以降は各偶数番号レジスタが下位 wordであり, 残りが上位wordである)から定まる. もしそれがメモリ中のwordであれば, レコードの先頭からのword単位での offsetが偶数ならば下位word, 奇数ならば上位wordである. またゴミが入っているレジスタは live-regs mapによって検出できる.

.

C.2 各種演算の実現

各種演算を実現するには, データの表現を考慮して, tagをはずした元のデータを求めて, 実際に演算をして, 再び tagのついたデータを構築す

106

Page 107: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

る必要があるが, 実際にはこれらの stepは, 実際に実行時に tagをつけたり外したりしなくても 1度にできることが多い. すべてではないが重要なものを見ていく. 一般には, 抽象機械での演算:

(op x y r)

を実現するためには,

r := T (T−1(x) op T−1(y))

を計算してやれば良い.

C.2.1 足し算

(+ x y r)

において, x, yは整数だから, T−1(x) = (x− 1)/4. したがって,

r := 4((x− 1)/4 + (y − 1)/4) + 1 = x+ y − 1

したがって単に与えられたデータ表現に対して実機械における足し算を実行し, 1を引けば良い. 特にどちらかが定数の時はオーバーヘッドなしで実行できる.

C.2.2 引き算

(- x y r)

は,

r := 4((x− 1)/4− (y − 1)4) + 1 = x− y + 1

C.2.3 メモリ参照

ここが少しだけ注意が必要である.

(rref p i rl rh)

107

Page 108: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

において, pはポインタ, iは整数, rl, rhが結果を格納するレジスタ (下位が rlで上位が rh)であるが, iはMin-Scheme の 1 word, つまり抽象機械の 2 wordを単位として addressingされている. そのことを考慮してアクセスすべきアドレスを求める. ここで典型的な場合として, 実機械のaddressingの単位を 8 bitとする (つまり, アドレス aと a+1の間に 8 bit

入っている)と, アクセスしたいアドレスは,

al = T−1(p) + 4T−1(i)

および,

ah = T−1(p) + 4T−1(i) + 2

であり, それぞれ,

al = p+ 1 + 4((i− 1)/4) = p+ i,

ah = p+ 1 + 4((i− 1)/4) + 2 = p+ i+ 2

となる. ここが少しこの tag付けの trickyなところで, 整数の下 2 bitがあらかじめ tagとなっているために, それらが wordサイズに合わせてオフセットをスケーリングしたことになっている. また, メモリアクセス命令として, レジスタオフセットをサポートする場合に, 2種類 (素直に足し算したアドレスをアクセスするものと, そのアドレスのさらに 2 byte先をアクセスするもの)を用意しておくと便利ではある. Cの最適化コンパイラなどでは, 単純なコードに対しては, 配列アクセス時のスケーリングを毎回行なう代わりに, 中間コード生成時にスケーリングを明示的に行なって, 可能であればそれを最適化によって取り除いているが, 実装は割と面倒であるし, offset自身が関数の返り値であったり, メモリから取り出されている場合には最適化も事実上不可能である.

その他の命令に関しても同様に, 各命令の引数の型に応じて, 結果を計算する実機械命令を生成できる.

D Compilation Unit

今までの話しで, Min-Schemeの関数定義を受けとり, それをCPSに変換し, closure変換を行ない, 抽象機械コードを生成し, 実マシンコードを生成できるようになった.

実際の compileの単位は, toplevel定義の羅列である. そこには,

108

Page 109: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(define (f x)

(+ x 1))

のように, 関数を定義するものと,

(define y (g 10))

のように, それ以外の値も定義できるものとがある. 特にMin-Schemeにおいては fix式が許されないからこの二つの区別は厳格である. 25

Compiler作りの最後の仕上げとしてこの羅列を受けとり, そこから ob-

ject file (unixの.oに相当するもの)を出すことを, 今一度整理してみる.

つまり compileというものが受けとるもの, 出力するものを整理してみる.

例えば, 特に object fileはただの実マシンコードの羅列だけでは済まないのは明らかである. 例えば,

(define y (g 10))

に対してはどこかのアドレスに (g 10)を実行した値が書かれていて, 他の (現在の compilation unit内ではないかも知れない) 場所から yを参照したらそのアドレスを読むようになっていなくてはならないはずである.

さらに yを初期化するに当たっては一般には何らかのコード (この場合は,

(g 10))を実行しなくてはならないはずである. それらは Schemeのmain

関数の前に実行されねばならない.26

それをどうとらえれば話しが簡単になるかというと,

compilerの出力= Toplevelの symbolの値を格納するためのデータ領域 (初期化データ領域)

+ 決められたエントリポイントが呼び出されるとそれらを初期化するようなコード (初期化コード)

という見方をすれば良い.

例えば,

25Min-Scheme Fixにおいては, (define (f x) ...)は, (define f (fix ((t (x)

...)) t))のことだと思えるのでこの二つを区別する必然性に乏しいが, 以下では fix

式が存在しなくても通用するよう, 二つを区別して考える.26C などではそれをしなくてもいいように, 初期化式に書ける式が制限されている.

C++ではコンストラクタがmain関数に入る前に呼ばれるようにするために, リンカが苦労している.

109

Page 110: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(define (f x) (+ x 1))

(define (g x) (+ (f x) 1))

(define y (g 10))

という compilation unitにおいては,

• 初期化データ領域は, 3 word分 27(f, g, y).

• 初期化コードは f, g, yを順に初期化するコード

になる.

それぞれを生成し, それをひっつけたものが出力結果, それを fileに書いて, どこからどこまでが初期化データ領域で, 初期化コードはどこから始まるみたいなことを fileに書けば, いわゆる.oに相当するものになる.28

初期化データ領域は単に, 定義された symbolの数だけ確保すれば良いから, 後は, 初期化コードをいかに楽して出すかを考える.

D.1 初期化コード

初期化コードを生成するのに, 直接マシンコード生成をしようなどと考える必要はない. CPSレベルで容易に実現できる.

最初の例に戻ると,

(define (f x) (+ x 1))

(define (g x) (+ (f x) 1))

(define y (g 10))

この compilation unitを初期化するコードは以下のようなCPS関数として実現できる.

27抽象マシンで 3 word28しかし我々のつくっているものは, 任意の複雑な初期化式を許すから, unitxの.oよりも立派である.

110

Page 111: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( i n i t−module ( c0 )

( f i x h ( ( f ( k1 x ) (+−two (x 1) ( v2 ) ( ( appb k1 ( v2 ) ) ) ) ) )

( r s e t ! ( ( l a b e l f ) 0 f ) ( )

( ( f i x h ( ( g ( k3 x ) ( f i x s ( ( k5 ( r6 ) (+−two ( r6 1) ( v4 )

( ( appb k3 ( v4 ) ) ) ) ) )

( appf f ( k5 x ) ) ) ) )

( r s e t ! ( ( l a b e l g ) 0 g ) ( )

( ( f i x s ( ( k7 ( r8 ) ( r s e t ! ( ( l a b e l y ) 0 r8 ) ( ) ( ( appb c0 (#t ) ) ) ) ) )

( appf g ( k7 1 0 ) ) ) ) ) ) ) ) ) )

init-moduleを普通の関数同様, 適当な c0(FIXSで割り当てられた継続)

を与えて呼び出してやると, (label f), (label g), (label y)をそれぞれ適切に初期化した上で返ってきてくれる. プログラムの初期化時にすべてのmoduleの initializeルーチンを呼んでやれば, main関数の実行準備が整ったことになる.

やっていることは,

• (define (f x) ...)型の定義にたいしてはそれをFIXHを使って定義し, その FIXHの body部でそれを rset!を使って適切な場所(label)に書き込んでおり,

• (define f ...)型の定義は初期化式を直接評価して,それを rset!

を使って適切な場所 (label)に書き込んでいる.

一般的な枠組を示すと,

• ( d e f i n e ( f x ) {\ i t body\/})

型の定義にたいしては,

( f i x h ( [ f ( c x ) . . . ] )

( r s e t ! [ ( l a b e l f ) 0 f ] [ ]

[ ( . . . ) ] ) )

というCPSコードを生成する. ここでtはuniqueな名前で, [(...)]

部には, f以降の初期化のための式が入る.

• ( d e f i n e f body )

型の定義にたいしては,

111

Page 112: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( . . .

{\ i t eva luate body\/}. . .

; ; assume the value i s on v

[ ( r s e t ! [ ( l a b e l f ) 0 {\ i t v \/} ] [ ]

[ ( . . . ) ] ) ] )

というCPSコードを生成する. ここでも, [(...)]部には, f以降の初期化のための式が入る.

後はこの関数に対して, 今まで述べた要領でコードを生成すれば, 自動的にそれがその compilation unitに対するコード領域のイメージになる.

Min-Schemeの場合の注意 なお,上で生成されるCPSコードはnestした FIXHを使っているため, 一見Min-Scheme Fixだけに通用する考え方であるように見えるが, 実際には定義される各関数は自由変数を持たないから, Min-Scheme Fixにあったような FIXHに対する一般的な closure

変換関数などを実装する必要はない.29 実際, closure変換を行なうと, 各toplevel関数に対する closureは, コード番地のみを含む 1 wordの closure

になるはずであるから, そのようなコードを最初から生成する closure変換もどきを付け焼き場的に作ってしまえば良い. つまり, init-moduleのコードを

( i n i t−module ( c0 )

( heap ( ( l a b e l f : ) ) ( f )

( r s e t ! ( ( l a b e l f ) 0 f ) ( )

( heap ( ( l a b e l g : ) ) ( g )

( r s e t ! ( ( l a b e l g ) 0 g ) ( )

( ( f i x s ( ( k7 ( r8 ) ( r s e t ! ( ( l a b e l y ) 0 r8 ) ( ) ( ( appb c0 (#t ) ) ) ) ) )

( appf g ( k7 1 0 ) ) ) ) ) ) ) ) )

のように変換してやれば, それは closure変換をしたのと同じことになる.

ここで,

(heap ((label f:)) (f)

29そうしても実際には対した手間ではないが.

112

Page 113: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

などにより f, gを作っているが, これは実際に closure formatを計算して求めているのではなく, fや gが実際には自由変数を含まないという知識を利用して, 最初からそう決めつけてしまっているだけである.

なお, 授業の資料 (6)の,「性能の改善について」で述べたように, Min-

Schemeに対しては各 toplevel関数に対する closureを作る必要はない. 上ではあえてそのようにしてあるが, その章に述べてあることを採用するのであれば,

(heap ((label f:)) (f)

((rset! ((label f) 0 f) ()

によって, fの closure recordを作る変わりに, 単に,

(rset! ((label f) 0 (label f:)) ()

としてしまえば良い.

E Linkerの仕事さて, 抽象機械コードおよび (おそらく)実マシンコードには, 命令のオペランドとして, labelなるものをとることができたが, それは最終的に全てのモジュールを linkする時に, 適切なアドレス定数に substiteされる.

以下, 各 compilation unitの出力結果を object code (または file), link

の結果を executable code (または file)と呼ぶことにする. 各 object code

は, link用に以下のような情報を用意する.

• その object内で定義されている外部から参照可能な labelの名前

• その object内で, labelを使用している命令の場所および命令

• その objectを initializeする関数の, file内における相対アドレス

外部から参照可能な labelの名前とは, 一般的には toplevelで定義された symbolということになる. これを globalな labelと呼ぶことにする.

Linkerは全ての fileの初期化データ領域および初期化コードをつなぎ合わせた時に, 各外部ラベルの値 (アドレス)を求め, それを利用して label

の使用場所をそのアドレスで置き換える.

また, 初期化コードを順に呼んだのちに, main関数を呼ぶようなコードを生成そのコードの先頭をプログラムの entry pointとする.

113

Page 114: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

Linkerが必要な理由は compilation unit中に外部参照が含まれていても compileを行なえるようにするため (つまり分割 compileを supportするため)であり, object fileが binary fileである必要性は (link時間を短くする理由以外には)ない. Binary fileをいじるのが面倒であれば, object

file = assembly textとしてしまって, リンクはすべての assembly textをつなげて, assembleするだけでも良い (もちろんアセンブラがラベルの使用を許しているというのが前提).

F Garbage Collection (GC)の概要GCの行なうべきことは単純で, GCが終了した後も必要とされるオブジェクトを保存しつつ, そうでないオブジェクトが占める領域をこれからのメモリ割当のために再利用できるようにすることである.30

「GCが終了した後も必要とされるオブジェクト」を正確に求めることは明らかに決定不能なので,ほとんどのGCは以下の条件から「到達可能」と証明できるオブジェクトを保存し, そうでないものをゴミとみなす.31

• GC開始時に生きているレジスタ, および大域変数中にあるポインタで指されるオブジェクトは到達可能 (スタックはスタック全体を一つの巨大なオブジェクトとみなすことにする).

• あるオブジェクトが到達可能な時, そのオブジェクト内に格納されているポインタで指されるオブジェクトも到達可能

つまり, スタックやレジスタを根とした, グラフの traverseを行なうのがGCである.

したがってGCのための基本的な要請は, GC開始時にレジスタ上にある各wordやオブジェクト中の各wordがポインタか否か判断できること,

およびあるポインタが与えられた時にそのポインタが指す範囲 (つまりオブジェクトの先頭と末尾)がわかることである. 具体的にいえば,

1. GC起動時における各生きている register上のwordがポインタか否かが判断できる.

30ここでオブジェクトとは我々の文脈 (Min-Scheme) においては heap, stack, または roffs命令によって割り当てられたレコードのことであるが, この資料では一般的な用語であるオブジェクトを用いる.

31MLの型システムを用いた, より進んだ GCもこの世には存在する.

114

Page 115: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

2. あるポインタが与えられた時, そのポインタが指すオブジェクトの先頭が発見できる.

3. あるポインタが与えられた時, そのポインタが指すオブジェクトの末尾が発見できる.

4. あるオブジェクトが与えられた時, オブジェクト内の各wordがポインタであるか否かが判断できる

G 前提作り上の条件は, 我々のシステムでは以下のように満たされている.

• 0. 下位 wordが与えられた時に, それがポインタであるか否かはword中の tag bitによってわかる.

• 1. GC起動は CPS関数の先頭でのみ起こり, その時の生きているregisterは live-regs疑似命令によって得られる. このうち, 先週述べたように, r0, r1, および r2以降のレジスタには上位word, それ以外には下位wordが入るため, 1.と合わせて, 各レジスタがポインタか否かが判定可能.

• 2. スタックオブジェクトに関しては, スタックの先頭で良く, それは r0または r1のどちらかに入っている. どちらであるかは, r0がスタックを指すポインタか否かで判断できる (ポインタがヒープを指すかスタックを指すかは, アドレスの大きさによりわかると仮定している). ヒープオブジェクトに関しては, オブジェクトの第 0 word

の一つ手前 (オフセット-1)に, 他のいかなるデータとも識別可能なtagをおくことにする.

• 3. スタックオブジェクトに関しては, スタックの底で良く, それはプログラム開始時に知ることができる. ヒープオブジェクトに関しては, 先頭の tag に大きさを書いておく.

• 4. オブジェクト中には下位 wordと上位 wordが交互にならんでいるので, 各下位wordについて, tagを見て判断すれば良い.

115

Page 116: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

ヒープオブジェクトの formatについて 上の tag wordについて, 少し詳しく述べておく. あるオブジェクトの先頭アドレス aが与えられた時,

その手前の 2 machine word (= 1 Min-Scheme word)をそのレコードの先頭および大きさを知るための tagとして余分に割り当てる. つまり,

Min-Schemeの

(cons x y)

によって作られる実際のレコードの formatは,

のようになる. そして, 図の Tlは, 他のどんなMin-Scheme データの下位wordとも識別可能な tagをつけておく. 具体的には, 下位 2 bitとして10 を用いてさらに, 論理値や空リストと衝突しないための tagをつける.

これによってあるポインタ rが与えられた時に, (rref r -1), (rref r

-2)と順に tag wordを示すデータが登場するまで読んでいけば, 先頭が発見できる. そして, その tag wordのうちの上位wordにそのオブジェクトの大きさを書いておけば, そのオブジェクトの末尾が発見できる.

スタックオブジェクトに関しては, 末尾は常に一定であり, 先頭はGC

開始時のレジスタに入っているので, tag wordは必要ない.

H 抽象的なGarbage Collection Algorithm

GCにはmarkingまたは copyingというデータ構造による区別から, gen-

erational GC, incremental GCなど, 様々な種類があるが, およそどんなGCにも共通する事項がある.

H.1 Tri-color Abstraction

GCのアルゴリズムを, 以下のようにオブジェクトに「色を塗っていく」プロセスと見るとわかりやすい.

白 まだGCに発見されていないオブジェクト

116

Page 117: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

灰色 GCに発見されたが, まだ「直接」白オブジェクトを指しているかも知れないオブジェクト

黒 GCに発見され, かつ指しているオブジェクトはすべて黒か灰色

およそどんなGCも以下のように動作する.

すべてのオブジェクトを白にする;

レジスタ, スタックなどのルートオブジェクトを灰色にする;

while (灰色オブジェクトが存在する) {

g = 一つ灰色オブジェクトを取り出す;

g が直接指す白オブジェクトを灰色に塗る;

g を黒に塗る;

}

基本的なこととして, まずオブジェクトの色は時間とともに濃くなっていくことは見やすい. そしてGCが保っている invariantは,

黒 → 白のポインタは存在しない

ということで, GC終了時に灰色が存在しないため, GC終了時には黒が直接指すオブジェクトは黒だけになる.

これと,

ルートオブジェクトは初期状態で灰色

という条件から, GC終了時には,

ルートオブジェクトは黒 and 黒が指すのは黒

というわけで, 黒オブジェクトだけがルートオブジェクトから到達可能なオブジェクトということがわかる.

したがってGCを設計するのにも必要な操作は,

• あるオブジェクトを灰色オブジェクトの集合に挿入する操作

• 灰色オブジェクトの集合から一つオブジェクトを取り出す操作

• あるオブジェクトが白か否かの判定

117

Page 118: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

H.2 Copying GC

後で詳しく述べるが,ヒープを半分にわけ,半分が埋まったところでGC

を起動する. その時点でもともとのヒープが fromspace, もう一方の (初期状態で空の)ヒープを, tospaceと呼ぶ. tospaceは queueになっており, 白オブジェクトを灰色に塗るという動作は, fromspaceから tospaceの一方の端に copyすることで達成される. 灰色オブジェクトを取り出すという操作は, tospaceのもう一方の端からオブジェクトを取り出すことで達成される.

実装するのは単純で, heapの使用率は常に 50%であるので, fragmenta-

tionを避けるなどの考慮事項は一切必要ない. また, allocation (heap limit

check)が素早く行なえる. また, GCアルゴリズム自身は途中, constantで押えられない storageを全く必要としない.

H.3 Marking

Markingはしばしば mark and sweepとも呼ばれるが, 良い GCではsweepを lazyに行なうなどの改良がなされており, 現代的な用語としてはあまり正しくない.

Markingはある意味ではもっと素直なもので, 各オブジェクトに 1 bit

のmark bit (これはオブジェクトの header部に持つことも可能だが, 別個に bit mapを用意するのが普通)を設け, あるオブジェクトを灰色に塗る操作はそのオブジェクトをmarkすることで, 白でないことが即座に判定できるようにする. これとは別個に灰色オブジェクトへのポインタのみを管理するデータ構造を, 管理する (通常は stackとして実現され, mark

stackと呼ばれる). 灰色に塗る操作はmark bitをたてるとともに, mark

stackに挿入することで行なう. 一般にmark stackの大きさをGC開始時に定数で予測するのは難しい.

Markingではオブジェクトをコピーしない. したがって GC終了後再利用可能な領域はヒープ上の連続した領域とはならない. このため, allo-

cationは free list (利用可能な領域を linkしたもの)を用いて行ない, かつfragmentationを避けるための工夫が必要になる. Fragmentationを避ける一つの工夫は, サイズごとに割り当てる領域をかえて, 同じサイズのオブジェクトを連続した領域に割り当てることである. こうすれば, 小さなオブジェクトに大きなオブジェクトが邪魔されることがなくなる.

118

Page 119: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

Copying GCに比べると, memory参照の特徴は良いことが多いが, allo-

cation algorithmが複雑になる, fragmentationを避けるためにはmemory

が多く必要になる, など, 資源の厳しいCPUには不向きな点が多い.

H.4 Incremental GC

Incremental GCは marking中にも userプログラムが動作を可能にするもので, これによってGCの pause timeが短くすることが可能になり,

real time GCなども可能になる.

基本的な問題点は, 上のGCの invarinat:

黒 → 白のポインタは存在しない

が, userプログラムによって崩されてしまうことがある点である (つまりuser プログラムががヒープオブジェクトの書換えを行なうことで, 黒 →白のポインタが発生し得る).

この invariantを保つための方法はいくつかあるが, 簡単にはユーザプログラムがポインタの書換えを行なう時に,

• 書き換えられたオブジェクトを灰色に戻してやる, または,

• 書き換えられたオブジェクトが直接指しているオブジェクトを灰色にしてやる

のどちらかを行なう.

I Min-SchemeのCopying Garbage Collec-

tion

I.1 Heapの構成

Heapは図のようになっている.

119

Page 120: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

h

B

E

L

A B

K

allocated

二つの同 sizeの領域A, Bにわかれており,常にどちらかの領域をactive

な領域と呼び, activeでない方は空である. Activeな領域の先頭 (B)からあるところ (h)まではすでに objectが割り当てられており, そこから領域の最後 (E)まではまだ objectが割り当てられていない. Allocationは hが指す場所から行なわれる.

I.2 GCの概要

Global変数, register, および stackを rootとして, pointerを通してたどれるものだけを空の領域へ移していく. 全てを移し終った時点で終りである. Stackは, 全体を大きな一つの生きているレコードとみなし, かつcopyはしない (freeされる領域があるわけではないので copyは無駄). 同様に global変数やレジスタもそれ自体を一つの recordとみなすと考えや

120

Page 121: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

すい.

Copying GCではヒープオブジェクトが白から灰色に変わる時, そのオブジェクトのアドレスが変化する. したがって同じオブジェクトを指す他のポインタもGCの最終時には, 黒の方を指しておくようにしなくてはならない. 具体的には,

黒オブジェクトは, 古いアドレスを指さない,

という invariantを保つようにする. したがって, ある灰色オブジェクトを黒に塗りかえる時に, ポインタを update すれば良い. この操作を, ポインタの forwardingと呼ぶ.

GC中, 二つのポインタを keepする. それらは tospace中を指している.

scanned: これより下位のオブジェクトはすべて forwardされており, 白オブジェクトも, いかなるオブジェクトの copy前の方も指さない.

unscanned: scannedから unscannedのあいだのオブジェクトは copyされているが, まだ, 白オブジェクトや, 他のオブジェクトの copy前の方を指すかも知れない.

言い替えれば, tospace の底から, scanned までの間が黒オブジェクト,

scannedから unscannedの間が, 灰色オブジェクトである. ここから, どうやって灰色オブジェクトを一つ取り出したり付け加えたりすれば良いかはやさしいでしょう.

[2]はGCに対する良い紹介である. その中の copying collectionの章を参照.

J 付録: GC in Min-Schemeのリスト (全くいい加減)

もし FIXSを使うMin-Schemeをはじめに implementするならば, GC

のアルゴリズム自身をそれで書くことによる, 興味深い bootstrapが可能になる. いくつかのごくごく low levelの情報収集関数を外部関数として準備しておいて, アルゴリズムの大部分はMin-Schemeで書くのである.

もちろんGCの最中は heap割当をしないなどの考慮が必要になるが, それは continuationを stackに割り当てれば可能である.

Convention: 大文字の関数は externalな assembly routine (Schemeで定義不可能).

121

Page 122: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

; ; ;

; ; ; Header i s wr i t t en j u s t be f o r e the 0 th element .

; ; ;

( d e f i n e ( record−header r )

( record−r e f r −1))

( d e f i n e ( set−record−header ! r h)

( record−s e t ! r −1 h ) )

; ; ;

; ; ; Ex t r i n s i c func t i on P+

; ; ; (P+ record nwords ) r e tu rn s a record po in t e r which i s as i f a l l o c a t e d

; ; ; next to the RECORD whose has NWORDS Scheme words

; ; ;

; ; ;

; ; ; Header word conta in s the f o l l ow i ng in fo rmat ion :

; ; ; ( 1 ) Number o f words in the record

; ; ; ( 2 ) A f l a g which t e l l s i f the record has been copied in a c o l l e c t i o n

; ; ; ( 3 ) A forward ing po inter , i f the record has been copied

; ; ; We assume there are e x t r i n s i c func t i on c a l l e d

; ; ; RECORD−HEADER−NWORDS, RECORD−HEADER−ALREADY−COPIED? ,

; ; ; and RECORD−HEADER−FORWARDING−POINTER, which are t r i v i a l l y implemented

; ; ; as assembly func t i on ( or CPS16 func t i on i f a pa r s e r f o r CPS16 i s

; ; ; implemented )

; ; ;

; ; ;

; ; ; S i z e (number o f e lements ) o f a g iven record .

; ; ;

( d e f i n e ( record−nwords r )

(RECORD−HEADER−NWORDS ( record−header r ) ) )

122

Page 123: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

; ; ;

; ; ; FORWARD every element o f r ecord R

; ; ;

( d e f i n e ( forward−record−i t e r r i n )

( i f (= i n)

0

( begin

( record−s e t ! r i ( copy−ob j e c t ( record−r e f r i ) ) )

( forward−record−i t e r r (+ i 1) n ) ) ) )

( d e f i n e ( forward−record r )

( l e t ( ( n ( record−nwords r ) ) )

( forward−record−i t e r r 0 n)

(SET−SCANNED! (P+ r n ) ) ) )

; ; ;

; ; ; FORWARD every element o f r ecord R

; ; ;

( d e f i n e ( forward−reg−dump−i t e r r i n l i v e−reg−map)

( i f (= i n)

0

( begin

( i f (= 1 (LIVE−REG−MAP−REF l i v e−reg−map i ) )

(REG−DUMP−SET! r i ( copy−ob j e c t (REG−DUMP−REF r i ) ) ) )

( forward−reg−dump−i t e r r (+ i 1) n l i v e−reg−map) ) ) )

( d e f i n e ( forward−reg−dump r l i v e−reg−map)

( forward−reg−dump−i t e r r 0 NREGISTERS l i v e−reg−map) )

; ; ;

; ; ; Block copy func t i on

; ; ;

123

Page 124: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( d e f i n e ( copy−mem−i t e r s r c des t i n )

( i f (= i n)

0

( begin

( record−s e t ! des t i ( record−r e f s r c i ) )

( copy−mem−i t e r s r c des t (+ i 1) n ) ) ) )

( d e f i n e ( copy−mem sr c )

( l e t ∗ ( ( n ( record−nwords s r c ) )

( des t (MAKE−UNINITIALIZED−VECTOR n ) ) )

(SET−UNSCANNED! (P+ dest n ) )

( copy−mem−i t e r s r c des t 0 n ) ) )

; ; ;

; ; ; COPY, g iven R i s a heap−a l l o c a t e d record

; ; ; ALREADY−COPIED? re tu rn s #t i f header o f R t e l l s R has a l r eady been

; ; ; cop ied during t h i s c o l l e c t i o n .

; ; ; (COPY−MEM R S) i s an e x t r i n s i c func t i on which j u s t a l l o c a t e s S

; ; ; machine words and copy the contents o f R to the new record , and r e tu rn s

; ; ; the po in t e r to the new record .

; ; ;

( d e f i n e ( copy−record r )

( l e t ( ( h ( record−header r ) ) )

( i f (RECORD−HEADER−ALREADY−COPIED? h)

(RECORD−HEADER−FORWARDING−POINTER h)

( l e t ∗ ( ( r−prime ( copy−mem r ) ) )

; ; put forward ing po in t e r

( set−record−header !

r (MAKE−ALREADY−COPIED−HEADER s r−prime ) )

r−prime ) ) ) )

( d e f i n e ( copy−ob j e c t o )

( i f (HEAP−RECORD? o )

124

Page 125: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

; ; #t i f t h i s i s a heap−a l l o c a t e d record

( l e t ( ( ho ( record−head−o f f s e t o ) ) )

; ; get the head o f the record , copy i t , and o f f s e t i t back

(P+ ( copy−r ecord (P+ o (− 0 ho ) ) ) ho ) )

o ) )

; ; ;

; ; ; Forward un t i l done

; ; ;

( d e f i n e ( forward−unt i l−done )

( i f ( eq? SCANNED UNSCANNED)

0

( begin

( forward−record SCANNED)

( forward−unt i l−done ) ) ) )

; ; ;

; ; ; Forward a l l frames in s tack

; ; ;

( d e f i n e ( forward−stack−i t e r frame bottom )

( i f ( eq? frame bottom )

0

( begin

( forward frame )

( forward−stack−i t e r

(P+ frame ( record−nwords frame ) ) bottom ) ) ) )

( d e f i n e ( forward−s tack top )

( forward−stack−i t e r top STACK−BOTTOM))

; ; ;

; ; ;

; ; ;

125

Page 126: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

( d e f i n e ( f l i p−space )

( l e t ( ( to TO−SPACE−HEAD)( from FROM−SPACE−HEAD))

(SET−TO−SPACE−HEAD! from )

(SET−FROM−SPACE−HEAD! to ) ) )

( d e f i n e ( set−heap−po i n t e r s )

(SET−HEAP−POINTER! SCANNED)

(SET−HEAP−LIMIT−POINTER!

(P+ TO−SPACE−END (− QUICK−HEAP−CHECK−MAX) ) ) )

; ; ;

; ; ; GCMAIN takes three r e co rd s as parameters

; ; ;

( d e f i n e ( gcmain stack−top g l oba l s reg−dump l i v e−reg−map)

( forward g l oba l s )

( forward−reg−dump reg s l i v e−reg−map)

( forward−s tack stack−top )

(SET−SCANNED! TO−SPACE−HEAD)( forward−unt i l−done )

( set−heap−po i n t e r s )

( f l i p−space ) )

; ; ;

; ; ; GC: look l i k e what f o l l ow s .

; ; ; When t h i s i s invoked , g ene ra l purpose r e g i s t e r s R[0]−−R[ n−1] hold

; ; ; f unc t i on parameters and r e g i s t e r T c a l l e d temporary r e g i s t e r ho lds

; ; ; the code address from which GC i s invoked and from which the

; ; ; computation w i l l be r e s t a r t e d .

; ; ;

; ; ; We c a l l GCMAIN a f t e r sav ing r e g i s t e r s

; ; ;

126

Page 127: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

(GC:

; ; f i r s t save r e g i s t e r s in a record

( record−s e t ! ( l a b e l REG−DUMP) 0 n)

( record−s e t ! ( l a b e l REG−DUMP) 2 R[ 0 ] )

( record−s e t ! ( l a b e l REG−DUMP) 4 R[ 1 ] )

( record−s e t ! ( l a b e l REG−DUMP) 6 R[ 2 ] )

. . .

( record−s e t ! ( l a b e l REGS) 2n R[ n−1])

; ; save cont inuat i on o f GCMAIN

( record−s e t ! ( l a b e l GCCONT) 2 T)

; ; setup arguments

; ; ( gcmain gcmaincont stack−top g l o ba l s reg−dump l i v e−reg−map)

( record−r e f ( l a b e l GCMAIN) 0 R[ 0 ] )

( record−r e f ( l a b e l GCMAINCONT) 0 R[ 1 ] )

(move SP R[ 2 ] ) ; s tack po in t e r

( record−r e f ( l a b e l GLOBALS) R[ 4 ] ) ; g l o b a l s

( record−r e f ( l a b e l REG−DUMP) R[ 6 ] ) ; reg−dump

( record−r e f T −1 R[ 8 ] ) ; l i v e−reg−map

( jump ( l a b e l GCMAIN: ) ) ) ; jump in to GAMIN

(GCMAINCONT:

; ; r e s t o r e r e g i s t e r s

( record−r e f ( l a b e l REG−DUMP) 0 n)

( record−r e f ( l a b e l REG−DUMP) 2 R[ 0 ] )

( record−r e f ( l a b e l REG−DUMP) 4 R[ 1 ] )

( record−r e f ( l a b e l REG−DUMP) 6 R[ 2 ] )

. . .

( record−r e f ( l a b e l REG−DUMP) 2n R[ n−1])

; ; resume the invoker

( record−r e f ( l a b e l GCMAINCONT) 2 T)

( jump T) )

127

Page 128: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

K まとめこの演習で学んだ (はずの)ことは大雑把にいえば,「first class function

(特に自由変数を持った関数)や, Garbage Collectionなどの新しい feature

を盛り込んだ言語の compileの枠組」です.

具体的には, CPSという中間言語を用いて, SchemeやMLのような言語をCPSに変換し, その上で最適化 (例えば η reduction)を行ない, closure

変換によって, register上の変数とmemory上の変数を区別し, register al-

locationによって具体的にどの変数が registerにのるかを決め, machine

語を生成した, ということになります.

30人以上いる皆さんのうち全員が今後プログラム言語の設計や実装とつきあっていくわけではないだろうから, CPSという specificな話しと今後多少なりともつきあっていく人は, そんなに多くはないでしょう. それでも最低限全員の人にわかって (というか, 「感じて」といった方がいいのかもしれない)欲しかったのは, 皆さんにとって compilerというものが「不思議な black box」から,「ただの記号変換プログラム」に思えて来ることであることです.

Compilerがやってることが所詮はちょっと高級な記号の変換であることを知ると, compilerが吐いたコードを眺めたりすることに抵抗がなくなってくるし, それを以前よりももっと面白く観察することができるようになります. 例えば, Schemeの lambda式がどのように実装されているかは,

言語処理系毎に「微妙な」差があります. 原理はだいたい同じで, 自由変数をメモリにとっておくというだけの話しなのですが, この原理を知らないと compilerにコードをはかせて見ても, 何でこういうコードがでてきたのかわからない. 何のために彼がここでmemory を allocateしているのかがわからない, ということになります. 基本原理を知っていれば, でてきたコードがやってることが何となくわかるし, 処理系毎の「微妙な違い」を知ることもできて面白いでしょう. さらに発展して, その変換を行なう compilerのソースコードを読めるようになり, そこまで来れば, 例えばある Scheme処理系を拡張しよう, と思い立ったときにどこをいじればいいのか (compiler? runtime system?)がわかるようになるわけです. これは直接プログラム言語を研究していない人でも必要になる場面が来るかも知れません.

また, これと独立した話しとして, 言語の実装に関する最新の論文などを読みこなせるようになるでしょう (特に関数型言語の実装に関する論文などは, CPS に関する知識を前提としているものが多い). これは専門家

128

Page 129: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

になりたい人には役に立つでしょう.

L 個人用もしくは小人数グループ用課題以前も約束したように, 個人用もしくは小人数グループ用課題を出します. これは班の中で全然仕事をせずにすんでしまう人をなくすための方策です. 必ず一通は, 個人名の入ったレポートを出してもらうようにすることです. 小人数グループは具体的には 3人くらいまでと思って下さい(それ以上は応相談). 大グループはこのレポートの意図をくんで, あまり作らないように.

皆さんは今, ray tracingを自作CPUで動かすという目の前の目標に向けて頑張っていることでしょう. こちらとしてはそれを邪魔する気は毛頭ないです. もし「ray tracingを動かす」という過程でなにか面白い topic

(compilerの最適化, ray tracingプログラムの最適化 etc.)があれば, それをレポートにしてくれて構いません (資料 (1)参照).

そうでない場合, 以下の演習のうちから一つ選ぶことを考えて見て下さい. それ以外の課題も応相談.

さまざまな言語処理系の「仕組み」を調べる 具体的には,

• gcc (C compiler)

• Scheme→C compiler

• ML→C compiler

• g++ (C++ compiler)

を候補としてあげておきます.

どの場合も, 出されたコードに対して「なぜこれで動くのか」を説明するよう心がけて下さい. また, ある処理方式によって極端に速くなったり遅くなったりする場合を発見して実際に実験したり, ある場合には処理系の bugを見つけたりする, ということを一つの遊ぶ目標にして下さい.

まず, gccの場合, (再帰呼び出しを含む)関数呼び出しの際に continua-

tionがどこに saveされているか? ということを最低限説明するようにして下さい. 後は最適化オプションをつけると具体的に何が変わるか, など.

Scheme→CやML→Cは Scheme/MLをCに変換する typeの compiler

です. それぞれについて,

129

Page 130: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

• 自由変数を持った lambda式などをどう表現しているか?

• Continuationをどこに saveしているか?

• 浮動小数点をどのように表現しているか?

• Garbage collectorがどうやって pointerを正しく traverseしているか?

などを調べてみて下さい.

g++は gccと同様のテーマの他に, オブジェクト指向言語特有の virtual

function dispatchがどのように実現されているかを調べてみて下さい (授業では全く取り上げていないので知らない人は気にしなくて構いません.

が, 知ってる人は多いのではないでしょうか. C++は (いろんな意味で)

知っておいて損はない言語でしょう.

Ray tracingの徹底解明 過去数年に渡って ray tracing競技会を行なっており, speedも改善されているようです (この世界においても年々ものすごい勢いで早くなっているところが凄い). Applicationを早くするためのまず第 1歩は, まずその applicationを良く理解することである, というわけで, ray tracingを早くするための, ただの経験報告にとどまらないreportを高く評価します.

やり方は, 例えば, 以下のような performance modelをうち立てる.

ray tracingの実行時間 =○×かけ算一回当たりの時間 +○× 1 word 出力する時間 + ...

というような式がでて, それがきちんと実測と合えば良い. 以前から, ray

tracingはかけ算が何回, 足し算が何回... というような報告はなされていた. が, これに加えて, 出力オーバーヘッドや, コンパイラの事項, たとえば関数呼び出しのオーバーヘッドなどがどのくらい入っているかを調べて, 後の人が (コンパイラを含めて)どこに注力すべきかを知見として残す, という立派な仕事である.

複数の班の有志が共同してやると非常に面白い.

コンパイラのRetargetting これは特にCPUがてこずった班のコンパイラ担当者用課題として適切で (それ以外の人がやっていけないというわけではないです, もちろん), 自作コンパイラに, Sparcのアセンブラやま

130

Page 131: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

たはCコードなどを吐かせる, という話し. コンパイラ担当者ならずとも,

retargettingのみでもOK.

最適化 授業でやった枠組の延長にある, 最適化を施して実験する. In-

liningなどが代表例. その他なんでも自分がぶち当たって, 工夫した問題を, 結果とともに報告すればOK.

Schemeアプリケーション Ray Tracingに比肩しうるアプリケーションの提案および Schemeプログラムの作成. 望ましい条件は,

• 絵が出る

• 少ない I/Oから大量の計算をする

• (できれば)浮動小数点を使わない

最後は, ほとんど浮動小数点演算の性能のみで性能が決まってしまうよりも「オーバーヘッド」と呼べる部分が大きいアプリケーションの方が, コンパイラの作りがいがあって面白いからである. プログラムは chezscheme

などで動作が確認できればOK.

M 参考文献集Compilerに関する以下の論文か, または先週の稲垣君が参考文献としてあげた論文のうち一つ以上を読む.

Closureの割り当て方法に関する話し 何回か述べたように, [3]は授業で扱った話しに関する包括的な本である. 本自体は厚いが, 前半は授業の内容を followしていれば読むのは容易でしょう. レポートとしては, 5, {6, 7, 8, 9, } 10, 12, 16, のうちのどれかを読んでまとめる.

[4, 5]は同じ著者らによるこの話しの続きである. かなり高度な論文.

[4]は, continuation recordを全て heapに割り当てるか stackに割り当てるか, どちらが良いかを論じた論文. 授業の方法は関数呼出時にできる“continuation record”は, stackに割り当て, lambda式のための closure

は, heapに割り当てていた. “Continuation record”を heapに割り当てるのは, 「関数呼び出しごとに heap allocationが行なわれるので遅いような気がする」そして, stackに割り当てるのは従来からの手続き型言語で

131

Page 132: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

すでに行なわれていた方法で, これをやった方が速くなるのでは? とは誰もが考えることである. 結論は面白くて, 「普通のプログラムでの性能はどちらでも変わらない. したがって heapに全て割り当てる単純な方式の方が良い」というものである. [5]は, closure変換の algorithmを変えて,

より良い closure recordの作り方をするという論文である. 例えば,

(define (f x y z)

(begin

(g x y z)

(h x y z)

(+ x y z)))

のように複数の関数を呼び出す関数において, [3]や授業の方法では, 最後の x, y, zが各呼び出し siteにおいて saveされてしまう. 提案している方法は, ここで x, y, zを一度 saveしたら, 後はその recordを次からの呼び出しのために有効利用してしまおうというもの.

Garbage Collectionに関する話し [2]は, GC に関するやや古いがわかりやすい surveyである. [8]は garbage collectionに関するめちゃめちゃ分量の多い surveyである. 1, 2章読めば十分. Garbage collectionに関する話しで最近は重要な topicになっているものが generational GC と呼ばれる techniqueである. 論文中のその部分を読むのも良いだろう.

Cに compileする話し 多くの言語処理系ではCを中間言語として用いており, 例えばこれによって処理系の移植性をあげたり, いくつかの処理をさぼったりしている. しかし CPS との組合せは微妙である. CPSのAPP命令をCに翻訳するのに, Cの関数呼び出しが必要になってしまうが, それをしてしまうとCの stackが overflowしてしまうからである.

[6]はML→C compilerで使われている方法を解説している.

その他の言語, 特にオブジェクト指向言語の実装に関する話し [7]の 5

章は, C++などのオブジェクト指向言語の実装の際の問題点, 解決方法をわかりやすく述べている.

N 終りに是非自作コンパイラで ray tracingを!

132

Page 133: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

こんな人は連絡を · · ·

• CPS変換のし方がわからない!

• Closure変換を詳しく書いたレジュメが欲しい! (leftarrowまだ作ってない)

• うちはCPUができないので, コンパイラが先へすすめない!

• わからない!

O お知らせ授業はこれで終りです. 後は, ray-tracingおよび個人課題の提出に向けて頑張って下さい.

本・論文は多くの場合, 図書館においてあります. 手に入れ方がわからない場合, 聞きにきて下さい.

また, 各班は簡単に進行状況, 見通しを今週末までにmailで知らせて下さい. 宛先はいつも通り,

tau

までです.

参考文献[1] Alfred V. Aho, Ravi Sethi, and Jeffrey D. Ullman. Compilers Princi-

ples, Techniques, and Tools. Addison-Wesley, 1990.

[2] Andrew W. Appel. Garbage Collection, chapter 4, pages 89–100. The

MIT Press, 1991.

[3] Andrew W. Appel. Compiling with Continuation. Cambridge Univer-

sity Press, 1992.

[4] Andrew W. Appel and Zhong Shao. An empirical and analytic study

of stack vs. heap cost for languages with closures. Technical report,

Princeton University, 1994.

133

Page 134: 情報科学実験 資料 - 東京大学tau/lecture/...もちろんCPU を全くこれに沿って作らなくても, 逃げ道はある. しかしそれは時として, softwareの負担を大きく増やすの

[5] Zhong Shao and Andrew W. Appel. Space-efficient closure represen-

tations. In Proceedings of ACM Conference on Lisp and Functional

Programming, pages 150–161, 1994.

[6] David Tarditi, Anurag Acharya, and Peter Lee. No assembly required:

Compiling standard ml to c. Technical Report 90-187, Carnegie Mel-

lon University, Pittsburgh, 1990.

[7] Reinhard Wilhelm and Dieter Maurer. Compiler Design. Addison-

Wesley, 1995.

[8] Paul R. Wilson. Uniprocessor garbage collection techniques. to appear

in ACM Computing Surveys.

134