一人一党党

一人一党党

一人一人の、一人一人による、一人一人のための政治制度を!

文明崩壊後に作るセルフホスティングアセンブラ

これは 言語実装のカレンダー | Advent Calendar 2024 - Qiita の1日目の記事です。

このカレンダーを読んでいる皆さんの中には、 「AI兵器」の衝撃 “機械は犠牲を理解できず” ウクライナの無人機 イスラエル軍は?暗い未来の不安 | NHK | WEB特集 | イスラエル など、 スカイネット の実現間近な近年の国際情勢から、 Collapse OSの作者と同様に 人類文明の崩壊を心配する方々がいるかと思います。 或いは激化する米中対立を背景に、外国産パソコンに仕込まれた ハードウェアレベルのバックドアの噂とか コンパイラに仕込まれた細工とシステムのセキュリティの話 とかを真に受けた上司の暴走で、 半導体の一切無い部屋に監禁されて 「バックドア汚染の心配の無い、コンパイラとハードウェアを作れ!」 という業務命令を受け、 Jeremiah Orians氏Stage0 Bootstrap for the Free Software をダウンロードしたは良いが、 ハードウェアを構築できずに挫折した経験をお持ちとか。 そうではなく単に技術的興味で、 命令セットの設計とそれに対応したアセンブラの作成に挑戦したい方々もいるかと。

当記事では、文明崩壊後にアセンブラのセルフホスティングを行なう手法として、 小さなバイナリサイズのフィルタプログラムを組み合わせるものを紹介します。 成果物は stage0risc からダウンロードできます。

文明崩壊後のハードウェア

まず必要なのは、文明崩壊後でも用意できるハードウェアですが。 実際にハードウェアを製作するのは大変なので、 当記事では、入出力が連続したバイト列のみの、 極めて単純な仕様のエミュレータで済ませます。 UNIX系OSでいうところの「標準入出力」のみです。 実際、コンピュータ産業など未だ存在しない黎明期のコンピュータの入出力は 穿孔テープ テレタイプ端末 でしたから、同様にコンピュータ産業がもう存在しない文明崩壊後でも、 これらの入出力装置は製作できるでしょう。 冒頭で触れたJeremiah Orians氏のstage0を使ったことのある方々なら、 「プログラムはどうやって読み込ませるんだ?」 と疑問に思うかもしれません。 Orians氏のstage0では、標準入出力とは別に プログラムを読み込ませる装置を仮定しています。 対して当記事では、入力装置を節約するため、 ブートローダを焼いた書き換え不能なROMをコンピュータ起動時に使います。 このブートローダが、起動時に標準入力からプログラムを読み込みます。 読み込まれたプログラムが標準入力を用いる場合は、 読み込むデータをプログラムの直後にくっつけます。 これは先述の穿孔テープであれば、手作業で可能です。 ちなみに、冒頭でお知らせした成果物では、 テープの繋ぎ合わせ作業を現在の環境でエミュレーションするのに、 複数のファイルをつなぎ合わせるUnixコマンドの「cat」を使っています。

CPUについては、ハードウェアを新規に作るなら、3オペランドマシンより スタックマシン 1オペランドマシン の方が部品点数などが少なく作り易いです。 とはいえ、1オペランドマシンといえども新規にCPUを作るのは大変なので、 まだ使える既存のCPUが残っているならそれらを使いたいところです。 が、それらの殆どはレジスタマシンです。 また、エミュレータを噛ませることで、当記事のバイナリがそのまま動き、 文明崩壊後の手間を軽減するだけでなく、 崩壊前の現在でのお試しもラクチンになります。 エミュレータ上での動作を前提にするなら、 Virtual Machine Showdown: Stack Versus Registers で明らかにされた様に、3オペランドが有利です。 なので当記事では、簡略化した3オペランドRISCエミュレータを用います。

文明崩壊後のソフトウェア

初期のコンピュータでは、主記憶装置(メモリ)の容量が非常に限られていました。 日本初のコンピュータ FUJIC のメモリ容量は8415ビット(255ワード。ワード当たり33ビット。)。 1バイト当たり8ビットに換算すると、1052バイトに1ビット足りません。 文明崩壊後でも同様でしょう。 あるいは、現在で最も大量に生産されている、 すなわち文明崩壊後に最もサルベージし易いであろうコンピュータは、 1個当たりのお値段が数百円にも満たないワンチップマイコンですが。 それらに積まれているメモリ容量もキロバイト単位です。 なので、ソフトウェアの設計も、メモリをあまり必要としない方向で行きます。

まずは、 プログラミング言語Pascal に倣ってのワンパスコンパイル。 ソースコードを読み終える前にコンパイル結果を出力し始めれば、 出力した部分のソースコードや結果をメモリに残す必要が無くなります。 ただし、これを実現するためには、ソースコードの文法に様々な制限が掛かります。 アセンブラの場合は、 まだ読み込んでいない後方ラベルアドレスを使えなくなります。

そして、バイナリ生成とマクロ機能を別々のプログラムに分割します。

プログラムが小さければ、それを格納するのに必要なメモリも少なくて済みます。 また、プログラムの機能が少ないならば、 その機能を実現するのに必要なメモリも少なくて済みます。

製作し易さを最優先すべき文明崩壊後のアセンブラに、 マクロ機能は余計と思われるかもしれません。 しかし、Orians氏のstage0アセンブラでは、 英単語などの文字列「ニーモニック」で表された命令を 機械語ビット列に変換する処理を、 各ソースコード冒頭に追加するマクロ定義ファイルで行なっています。 このマクロ定義ファイルが肩代わりしているおかげで、 各ニーモニックに対応する機械語ビット列についての情報が stage0のアセンブラコードには不要となり、バイナリサイズの削減になります。 例によって、マクロ定義ファイルをソースコード冒頭に追加するのは、 穿孔テープであれば手作業で可能です。

結果、当記事の成果物であるstage0riscでは、 仮想マシンのメモリ量を1512バイトに設定しても、 アセンブラのセルフホスティングが出来ました。

分割した部分を置き換える

バイナリ生成とマクロ機能の分離には、 文明崩壊後のコンピューティング以外にも効能がありました。 それぞれの部分を別のプログラムに置き換えるのが簡単なことです。

stage0riscの開発当初は、 マクロ機能プログラムにはC言語プリプロセッサを使っていました。 成果物内のcheatsディレクトリ にその名残りのコードがあります。 あるいは逆に、 バイナリ生成プログラムだけ取り替えて マクロ機能プログラムを使い回すことが出来ます。 これを利用して、仮想マシンではなくx86実マシン上で セルフホスティングされたアセンブラを構築しました。 st0l86ディレクトリ にありますが、 セルフホスティング前にx86バイナリを構築するのに、 stage0risc仮想マシン用のマクロ機能プログラムを流用しています。

やり残したこと

当記事では低水準言語であるアセンブラを構築しましたが、 高水準言語なのに容易く実装できるものがあります。 バイナリサイズが小さいのであれば、 機械語手書きから言語処理系をブートストラップする のように、何らかのバイナリエディタに16進数の羅列を入力する方法が使えます。 小さいバイナリサイズといえば、 PC/AT互換機マスターブートレコード(512バイト)に収まる処理系が幾つかあります。

ガベージコレクタ付きの処理系が512バイトでお釣りが来るなんて、 早過ぎか遅過ぎか分かりませんが、時代を間違えているのは確かでしょう。

にも拘らず当記事でアセンブラを取り上げたのは、当記事で取り上げたCPUが、 スタックマシンとかではなくレジスタマシンだからです。 高水準言語でその豊富なレジスタの恩恵を受けるには、 レジスタ割付が必要となり実装難度が大きく跳ね上がります。 アセンブラであれば、「人間コンパイラ」に最適化を任せることが出来ます。 エミュレータの性能だけでなく現在普及していることも合わせて、 当記事ではレジスタマシンを取り上げました。

しかしレジスタマシンと違ってスタックマシンには、 まだ十分に研究の進んでいない分野、"stack scheduling"があると私は感じます。 レジスタマシンとスタックマシンとの違いは、 各命令でのオペランドの指定方法が、固定したレジスタ番号なのか、 スタック先頭との相対位置なのか、でしかありません。 そして先に述べたように、 レジスタマシンの恩恵を得るためにはレジスタ割付が必要であり、 多くの研究者の努力によりその方法が確立されています。 しかしスタックマシンでは、レジスタマシンのレジスタ割付に当たる、 「スタック割付」があまり研究されていません。

など、ないわけではないのですが。 レジスタマシンでのレジスタ間コピー命令(x86アセンブラレジスタ間mov)に当たる、 スタックマシンでのスタック操作命令(dup,swap,rotなど)の削減には 至っていないようです。 レジスタ割付の主目的の一つがコピー命令の削減であることを考えると、 まだ研究の余地があるように私は感じます。 そして関数呼び出しは、コピー命令などで適切なレジスタ・スタックに 引数・返り値を配置しなければならない「0オペランド命令」であり、 多くのスタックマシンでの基本命令と同じです。 なので、stack schedulingは、 高度にリファクタリングされて関数呼び出しだらけのコードであっても、 スタックマシンの性能向上に寄与すると私は考えます。 絶対位置であるレジスタ番号でオペランドが指定されるレジスタマシンでは、 関数間での引数・返り値の場所が衝突するのを解決するのは簡単ではありません。 特に、引数などの配置方法が「呼び出し規約」として 同一位置に固定されている外部関数だと、 レジスタ割付で削減したはずのコピー命令を追加するしかありません。 スタックマシンであれば、 スタックポインタが変化すれば各命令オペランドの参照する位置も変わるため、 関数を呼び出す順番を並び替えてスタックポインタを操作することで、 スタック操作命令など追加の命令を用いずに 各関数の引数・返り値の衝突を減らせるかも知れません。 ならば、実行速度よりコード密度を重視し、 インライン化せずに関数呼び出しを多用するコードを使う用途であれば、 スタックマシンの方がレジスタマシンより優位になります。

なので、スタックマシンがあまり生産されない現在の状況が、 文明崩壊後まで続くという確信を私は持てません。 そうでなくてもコード密度の優位は、メモリの少ない文明崩壊後では重要です。 stack schedulingの確立を前提とするなら、 当記事の取り上げるべきCPUはスタックマシンであり。 スタックマシンならば、冒頭に述べたCollapse OSが そうした ように、アセンブラではなく、 一部のスタックマシンの低水準言語として実装されたこともある高水準言語 forth を検討しないわけにはいかなかったでしょう。

zstd vs deflate on zswap

パラレルATAからしかbootできない自機で、HDDが死亡寸前。 今時、パラレルATA接続のストレージなんて手に入らない。 そこで、kexec機構を用いた最小限のlinuxカーネルとinitial ram filesystemをCDに焼き、USBメモリに格納したシステムへchainload

USBフラッシュメモリの寿命は書き込みによって減るので、swap領域への書き込みを減らすためにzswapを導入。 ここで、zswapの圧縮アルゴリズム選択で気になったのでネット検索したところ、2024年5月現在、多くのサイトで勧められている圧縮アルゴリズムzstdが、小さなファイルでは圧縮率がよくないらしい。 自分がこのことを知ったのは2016年のOSS圧縮ツール選択カタログ #Linux - Qiitaを読んでから。 zstdの開発元でも、Compression ratio worse than zlib for small blobs - Issue #1134 - facebook/zstd - GitHubとして認識されているようだ。 そして、swapはページ単位で行われ、自機の属するアーキテクチャであるx86でのページサイズは4キロバイトSquash Compression Benchmark - GitHub Pagesによると、4キロバイト前後のファイルサイズでは、zstdはスピードではdeflateを上回るが圧縮率では負ける。 なので、自分は圧縮率優先でdeflateをzswapの圧縮アルゴリズムに選択した。

新しめのツールはbrotli以外ファイルヘッダが大きそうですね 逆に言うと、gzip/bzip2はまさに汎用と言えるさすがの性能です

2016年のOSS圧縮ツール選択カタログ #Linux - Qiitaの結語は参考になる。

9キロバイトのコードで関数型言語を実装 - 自作言語revappの理由

これは言語実装のカレンダー | Advent Calendar 2023 - Qiitaの1日目の記事です。

このカレンダーを読んでいる皆さんなら、論文 「なぜ関数プログラミングは重要か」 に感銘を受けたりして、関数型言語を実装したいと思ったことがあるでしょう。 とはいえ、haskellやMLのような複雑な仕様を持つ言語では、 それに見合った複雑な実装が必要となって手に負えない、 と思った方も多いと思います。 しかし、LISPやforthのような単純な実装で済む処理系であれば、 一度は実装してみたことが、このカレンダーを読んでいる皆さんにはあるはずです。 私の自作言語revapp は、先の論文で挙げられた関数型言語の二つの主要機能である 高階関数と遅延評価を持ちながら、 静的リンクされた9キロバイトLinux-i386実行ファイルで実装できます。 Unix系のOSをお使いであれば、終了コード0を返すだけの trueコマンド が用意されているかと思います。 あるいは、このカレンダー読んでいる皆さんであれば helloworldプログラムよりも簡単に実装できるでしょう。 そのtureコマンド、 Linux-amd64の場合は20キロバイトを越えるようです 。 それを下回るコードサイズで実装できるほどに、revapp言語は単純なのです。

関数適用の語順が逆のラムダ式

実装を単純にするため、計算モデルとしてラムダ計算をrevappは採用しています。 このモデルでは、変数定義と関数適用のたった二つの構文で、 高階関数が書けてしまいます。 ただし構文解析の実装を単純にするため、 一般に使われている「ラムダ式」の文法ではなく、 De Bruijn notation - Wikipedia に類似した文法を使います。 ラムダ式の構文のうち関数適用については、 結合規則が左結合優先になっていて、左再帰の形になっています。

S -> x          (変数参照)
S -> λx.( S )  (変数定義)
S -> S ( S )    (関数適用)

実装の単純な構文解析手法として再帰下降構文解析が有名ですが、 この手法では左再帰の構文を扱えません。 この関数適用の語順がDe Bruijn notationではラムダ式の逆になっており、 左再帰ではなく右再帰になっています。

S -> x        (変数参照)
S -> [x] S    (変数定義)
S -> ( S ) S  (関数適用)

さらに実装を単純にするためにrevappでは、 関数末尾を表現する構文を追加して、変数参照構文も右再帰にします。

S -> 0文字の文字列  (関数末尾)
S -> x     S        (変数への関数適用)
S -> =x    S        (変数定義)
S -> ( S ) S        (関数への関数適用)

勿論、「0文字の文字列」なんて表記も検出もできないので、 この文法は構文解析できません。 しかし、この構文解析不能ソースコードの末尾に 閉じ括弧')'を一文字だけ加えたものは、 以下の文法で構文解析できます。

S -> )     (関数末尾)
S -> (S S  (関数への関数適用)
S -> =x S  (変数定義)
S -> x  S  (変数への関数適用)

ここで、関数末尾以外の全ての構文を右再帰の形にしたことが効いてきます。 インタプリタの中間表現としてよく使われる、 バイトコードの文法と比べてみましょう。

S -> "復帰を示す1byte"             (サブルーチンから復帰)
S -> "命令1を示す1byte" operand S  (仮想命令1)
S -> "命令2を示す1byte" operand S  (仮想命令2)
...

どちらも、1byte目で処理内容を決定できる、右再帰の文法です。 revappソースコード(の末尾に閉じ括弧を足したもの)の構文解析の実装は、 バイトコードのそれと同じくらい単純なのです。 勿論、バイトコードが中間表現として用いられているのは、 構文解析が単純だからだけではありません。 メモリ上に並んでいるのと同じ順番で仮想命令を逐次処理する、 単純な方法での解釈実行ができるからです。 このバイトコードの利点をrevappで利用するため、 私の書いたrevappインタプリタ実装では、 ローカル環境を加えた仮想スタックマシンを用いています。 revappでは引数が関数本体よりも先に記述されるので、forth言語などと同様に、 スタックマシンを基にした仮想マシンでの解釈実行が自然でしょう。 勿論、バイトコードと違って命令末尾以外にも再帰箇所がある、 関数への関数適用構文の処理だけは単純ではなく、 引数として再帰的に記述されたrevappソースコード構文解析する必要があります。 しかし、引数として記述される関数は遅延評価されるので、 解釈はしても実行までする必要はありません。 スタックに積むのがthunkである点を除いて、 変数への関数適用構文の場合と制御の流れは同じです。 結果として、既にお気付きかと思いますがこの仮想マシン、 先述のrevapp文法を直接解釈実行します。 仮想マシンなのに中間表現の仮想命令セットがないという怪しげな点が不安ですが、 ソースコードを中間表現へ変換するコードが省け、実装が単純になるのは確かです。

言語自身による言語機能の実装

さらに私の書いたインタプリタでは実装を単純にするために、 値呼び機構の実装を省いています。 ファイル操作などの入出力には必須だし、 いくらラムダ計算がチューリング完全とはいえ 整数演算はCPU組み込みの演算命令を使う方が圧倒的に効率的なので、 バイナリデータを扱うプリミティブ関数群なら実装しているのですが。 それらの引数として、バイナリデータではなくそれを生成するthunkを渡すと、 thunkを評価して値を求める代わりに、 このインタプリタは異常終了扱いで停止します。 なので、プリミティブ関数に渡すバイナリデータを生成するときは、 バイナリデータを関数に渡す

(=プリミティブ関数  バイナリデータ プリミティブ関数)

の形のrevappコードで表される、 バイナリデータをラップするthunkを代わりに生成します。 そして、

引数thunk プリミティブ関数

と書く代わりに語順を入れ替えて

プリミティブ関数 引数thunk

と書きます。 語順を入れ替えることにより、遅延評価なので、 関数の位置にある引数thunkがプリミティブ関数より先に評価され、 値呼びの場合と同じ評価順序になります。 とはいえ、語順を入れ替える表記は見た目に悪いし、 複数の引数を取るプリミティブ関数では

((プリミティブ関数 引数thunk1) 引数thunk2) 引数thunk3

と括弧の入れ子が生じるので、関数にもラッパーを用意します。

(=引数thunk1 =引数thunk2 =引数thunk3
 ((プリミティブ関数 引数thunk1) 引数thunk2) 引数thunk3
)=ラッパー関数
( 使い方 )=
引数thunk3 引数thunk2 引数thunk1 ラッパー関数

これらのラッパーは、機械語ではなくrevapp言語自身で実装できます。 なので、このインタプリタの実行ファイルには テキスト形式のままのrevappソースコードが組み込まれています。 例えば、条件が成立した場合と不成立の場合の両方の処理を引数とする関数として ブール値を定義することで、条件分岐構文に当たる機能を実装しています。

(=t =f t)=true
(=t =f f)=false

遅延評価なので、片方の引数を捨てるだけで、 捨てた方の処理の実行を避けることができます。 高階関数と遅延評価があれば、制御構文の実装は省けるのです。 ループ構文に当たるものは、 ラムダ計算に於ける不動点コンビネータをrevapp言語で定義した関数です。

数値や文字列のリテラル構文の実装も省いています。

(=done done)=[
(=done =head =isend (done head cons) isend)=,
(nil ,)=]

の三つの記号を名前とする関数を定義することにより、

[ 要素 , 要素 ]

のような形式のリスト表記が可能になります。 リストが表記できるなら、記述密度は6倍ほど悪くなりますが、 一文字を要素とするリスト構造として文字列リテラルを表記できます。

([ 'H' , 'e' , 'l' , 'l' , 'o' ])=Hello

数値リテラル機能は、数字を要素とするリストから数値を計算する関数で足ります。

(([ 1 , 0 , 0 ]) decimal)=100

文字自身の定義は厄介で、

...
('@' one plus)='A'
('A' one plus)='B'
('B' one plus)='C'
('C' one plus)='D'
...

のようなコードを私の代わりに生成するプログラムを用意するハメになりました。 遅延評価によるメモ化が効くので、 1の加算を繰り返す効率の悪い計算方法でも使い物になるようです。

バイナリハック

残念ながら当然それらだけでは、無駄だらけのOS添付trueコマンドならともかく、 手書きのtrueコマンドより実装サイズが小さくなることはありません。

int main(void)
{
 return 0;
}

なので、くだんの9キロバイトバイナリについては、 アセンブラ中の.rodataセクションを.textセクションに書き換えたり、 ファイルヘッダを自前生成したりして、セクション領域の数を減らしています。 ヘッダや各セクション領域は開始位置がページサイズ単位で揃えられているので、 いくらコードサイズを減らしても、 ページサイズに合わせる分のパディングによって帳消しとなります。 このセクション統合ハックの効果を上げるため、 Tiny Forthインタープリタ と同様にベアメタルプログラミングに倣って、ライブラリの動的リンクを避けます。 メモリについては動的確保を行わず、 巨大な固定サイズのグローバル配列から切り出す方式を採っています。 ファイルなどの入出力は、OSのシステムコールを直接叩きます。 移植性は失いましたが、 このハックで4キロバイト以上ファイルサイズを削れました。

終わりに

jonesforth.s.txtより

Can you imagine writing C's 'if' in C? And that brings me to my third reason: If you can write 'if' in FORTH, then why restrict yourself to the usual if/while/for/switch constructs?

C言語のif構文をC言語で記述するなんて想像できるかい? ここから、私がforthを勧める理由の3番目が出て来るんだ。 もしforthでif構文を書けるのなら、 どうして、いつものif/while/for/switch構文に拘る必要があるんだい?

以上が、revapp言語の処理系を9キロバイトで実装できる主な理由だと私は考えます。 forthやLISPがしぶとく生き残っていて、新しい処理系まで現れるのは、 動作速度や強力な言語機能は勿論ですが、 実装のし易さが一番の原因だと私は思います。 そして、強力な言語機能、実装のし易さ、 この二点はこれらの言語では別々のものではなく 車の両輪のように分かち難いものです。 言語機能のうち、言語自身で記述できる部分が多いほど、 機械語で実装しなければならない部分が少なくなるからです。 機能の弱い言語では機械語で実装しなければならない部分でも、 強い言語であれば、その言語自身で記述できます。 逆に言えば、機械語で実装しなければならない部分こそ、 その言語で最も強力な機能と言えます。 forthに於いては、 機械語で書かれたルーチンと仮想命令で書かれたルーチンとを同一に扱う仕組み (threaded codeのヘッダに位置するcode field)。 LISPに於いては、特殊形式quote。 これが関数型言語についても当て嵌まると、revapp言語は示せたでしょうか? 高階関数と遅延評価の力は その機能を持つ言語自身の実装のし易さに繋げることができると。

外部関数呼び出し規約を考慮した累積レジスタ割り付け

この記事は 言語実装 Advent Calendar 2022 - Qiita の5日目のために書いた。

累積レジスタマシンで命令単位JITコンパイラの夢を見るで書いた去年の方法では、AOTコンパイラJITコンパイラに伝達した情報は、

  • 仮想レジスタの優先順位と、
  • レジスタに格納される変数が外部関数呼出で破壊されてもいい「破壊変数」なのか、それとも、保存しなければならない「保存変数」か

だけだった。

外部関数で破壊される物理レジスタをcN(clobbered register)(Nは適当につけたレジスタ番号)、保存されるレジスタをsN(saved register)、仮想累積レジスタをVN(Virtual register)(累積レジスタなら、Nは適当ではなく優先順位)とすると、以下の図1のように仮想レジスタに物理レジスタが割り付けられることになる。

図1

[V0][V1][V2][V3][V4][V5]...
[s0][s1][s2][c0][c1]

累積レジスタ割付では、仮想レジスタの優先順位が高いほど高性能の記憶装置が割り付けられるというのが、AOTコンパイラに対するJITコンパイラのお約束だ。 なので、外部関数呼出を跨ぐ場合はメモリに保存場所がコンパイルされてしまう物理破壊レジスタは優先順位のやや低い仮想レジスタに割り付けられ、優先順位最高の仮想レジスタには物理保存レジスタが割り付けられる。 一方、変数にレジスタを割り付けるアルゴリズムの多くは、なるべく多くの変数に物理レジスタを割り付けるため、寿命の短い変数に優先度の高い記憶装置を割り付けがちだ。 このため、外部関数呼出により寿命の制限される破壊変数の方が、保存変数より優先順位の高い仮想レジスタを割り付けられがちになる。 つまり、優先順位の高い累積レジスタには破壊変数と物理保存レジスタが割り付けられ、優先度の低い累積レジスタには保存変数と物理破壊レジスタが割り付けられる。 変数と物理レジスタで、破壊・保存のミスマッチが生じるのだ。

これの改善策が11月に見つかった。

まず、累積レジスタ列をふたつ、仮想破壊レジスタの列と仮想保存レジスタの列を用意する。 そして、それぞれの仮想破壊レジスタに変数を格納する度に、その変数より優先度が高くて同時に生存する変数を格納する仮想保存レジスタの、最大レジスタ番号を付記するのだ。 仮想保存レジスタ側にも同様に、仮想破壊レジスタについての情報を付記する。 そしてJITコンパイル時には、図2のように、仮想破壊レジスタ列(図2での[Sn])と仮想保存レジスタ列([Cn])とで向きを反転させ、物理レジスタの数だけ重複させて、物理レジスタと対応させる。

図2

...[S5][S4][S3][S2][S1][S0]

               [s0][s2][s3]
       [c1][c0]
       [C0][C1][C2][C3][C4][C5]...

破壊専用・保存専用に累積レジスタ列が別々になっており、変数と物理レジスタとの間での破壊・保存のミスマッチはなくなる。 問題は、破壊・保存のどちらにも使い回せる、物理保存レジスタの割付だ。 ここで変数格納時に付記していた、他方のレジスタ列についての情報が役立つ。 それにより、優先度の高い値を格納している仮想破壊レジスタ・仮想保存レジスタの最大個数をJITコンパイラは決定できる。 例えば、ある仮想破壊レジスタに割り付ける記憶装置を考える時。 優先度の高い仮想破壊レジスタの数は、当該仮想破壊レジスタレジスタ番号そのものだし。 仮想保存レジスタの方は、付記された最大レジスタ番号になる。 これら、当該仮想レジスタより優先度の高い仮想レジスタの数と、扱える物理レジスタの数とを比べれば、当該仮想レジスタに物理レジスタを割り付けるかメモリで済ますか、JITコンパイラは判断できる。

レジスタ割り付け - Wikipediaでは、各変数が別の変数と同時生存するか否かを表す二項関係を「干渉グラフ」と呼ぶ。 この干渉グラフは構築だけでも変数の数の二乗の計算量が掛かるので、線形の計算量しか掛けられないJITコンパイラでは扱えないが、処理時間の制限が緩いAOTコンパイラでは扱える。 変数に仮想レジスタを割り付けたあと、変数を仮想レジスタに置き換えて、同じ仮想レジスタを割り付けられた頂点を統合した干渉グラフも、AOTコンパイラで扱える。 なので原理上は、任意の変数や仮想レジスタについて、それより優先度が高くて同時生存し、破壊・保存の別が異なる変数・仮想レジスタの「集合」をAOTコンパイラは扱える。 なのに、その集合の最大レジスタ番号しかJITコンパイラに伝達しないのは、これもJITコンパイラの時間制限のためだ。

取り敢えずの概念実証コードをGitHub - abo-junghichi/crjitに上げた。 JITコンパイラまでの道程のスタート地点に過ぎないが、興味のある向きによる追試に役立てば良し。

深層学習での勾配逆伝搬を三値化してみた

深層学習といえば、その膨大な計算量を贖うためのGPUと、それを駆動するためのGPUプログラミングが必須と見られがちだが。
ニューラルネットワーク量子化についての最近の研究の進展と、その重要性 - SmartNews Engineering Blog
https://developer.smartnews.com/blog/2017/03/neural-network-quantization/
で述べられているように、各ニューロンのパラメータなどを浮動小数点数ではなく、表現範囲が1bit程度の整数で表すことで、GPUではなく通常のCPUプログラミングで深層学習が可能になる。
1bit程度の整数ならば
Linux Parallel Processing HOWTO: SIMD Within A Register(例えば MMX を利用)
http://linuxjf.osdn.jp/JFdocs/Parallel-Processing-HOWTO-4.html
のテクニックを使って、C言語での整数プログラミングの範囲内で多くのパラメータを一括処理できるからだ。

先述のBlogで述べられているように、学習済みのニューラルネットワークについては、出力やパラメータを1bit化しても十分に動作することがわかっているが。
その学習済みのニューラルネットワークを得るのは、1bit程度の整数で可能だろうか?

ニューラルネットワークに学習させる手法としては、当該ニューラルネットワークの出力の微分を計算して勾配を求める
バックプロパゲーション - Wikipedia
https://ja.wikipedia.org/wiki/%E3%83%90%E3%83%83%E3%82%AF%E3%83%97%E3%83%AD%E3%83%91%E3%82%B2%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3
が典型であり、先述のBlogで紹介されている研究も、この手法を無理やり使っている。
どこが無理やりかというと、出力が1bit化されていて0と1しかなく連続な値をとれないので、本当は微分可能ではない点だ。
先述のBlogより

アクティベーションを二値化するのは、アクティベーションにステップ関数を適用してから次のレイヤーに入力することほぼ(=小さい側の値が0ではなく-1なのがステップ関数と少し違う)と同じですから、そういう非線形な関数を適用している、と数式上で真面目に考えてバックプロパゲーションの計算を行うと、ほとんどいたるところで勾配が0になってしまいます。
そこで、forward時はステップ関数(しつこいけどちょっと違う)なんだけど、backward時には(-1, -1)から(1, 1)の間を直線でつないだ関数を考えます。
これをStraight Through Estimator (STE)と言います。

なのでこれらの研究では、出力が0から1に切り替わる不連続点とその周囲を、0と1とを結ぶ直線に置き換えて近似している。
この近似により、勾配の値が実数の形で出てくるので、あとは通常の深層学習と同じ手法が使えるのだが。
勾配の値が実数≒浮動小数点数である、その通常の深層学習の手法ではGPUが必須なわけで、GPUプログラミングを避けたい自分としては美味しくない。

代わりに、学生の頃に物理を学んだ自分としては、不連続点での微分
ディラックデルタ関数 - Wikipedia
https://ja.wikipedia.org/wiki/%E3%83%87%E3%82%A3%E3%83%A9%E3%83%83%E3%82%AF%E3%81%AE%E3%83%87%E3%83%AB%E3%82%BF%E9%96%A2%E6%95%B0
を使い、このデルタ関数の近似によって勾配を得る手法を使ってみた。
すると、近似方法を上手く選べば、得られた勾配分布は-∞,0,+∞の三値しか取らないことに気付いた。
デルタ関数の近似方法の一つとして、
正規分布 - Wikipedia
https://ja.wikipedia.org/wiki/%E6%AD%A3%E8%A6%8F%E5%88%86%E5%B8%83
の分散値を0に近づけた極限がある。
これを使って得られる勾配分布は、無限に大きくなる数を底とし不連続点からの距離の二乗を冪指数とする関数を、当該ニューロンが各シナプスから受け取った各入力の重み付き総和に適用したものになり。
底が無限に大きくなるため、不連続点からの距離に少しでも差がある勾配の比は、片方が0で無い限りは無限に大きくなる。
なので、この勾配分布に正規化を掛け、シナプスからの重み付き総和が不連続点に最も近いニューロンの勾配を適当な有限値にしようとすると、それ以外のニューロンの勾配は無限に大きい値で除算されて0になってしまうのだ。
おまけに、無限値に加減算・乗除算を掛けても無限値のままなので、乗除算が不要になる。
普通のCPUでも乗除算命令は加減算やビット演算より遅いし、FPGAや組み込み向けのような極小CPUに至っては乗除算命令が省かれているのが普通だ。
パラメータの更新などに欠かせない乱数には
メルセンヌ・ツイスタ - Wikipedia
https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%82%BB%E3%83%B3%E3%83%8C%E3%83%BB%E3%83%84%E3%82%A4%E3%82%B9%E3%82%BF
のようにビット演算だけで高品質なものがあるので、-∞,0,+∞の三値化と合わせれば、RISC-VのRV32I命令セットのような低コストの演算だけで、深層学習ができることになる。

機械学習でお馴染みの
MNISTデータベース
http://yann.lecun.com/exdb/mnist/
で試したところ、275回もデータベースを舐めさせたとはいえ、7層全結合ニューラルネットワークでも訓練用データの3/4を学習できている。
勾配伝搬に浮動小数点数を用いる通常のニューラルネットワークでも7層での学習は困難を伴うことを考えれば、-∞,0,+∞の勾配伝搬三値化は十分実用に耐えるはずだ。
この7層用と動作確認向けに4層のコードをGitHubに上げたので
https://github.com/abo-junghichi/ternary-backprop
興味のある向きはコードを覗いてみてほしい。

累積レジスタマシンで命令単位JITコンパイラの夢を見る

この記事は
言語実装のカレンダー | Advent Calendar 2021 - Qiita
https://qiita.com/advent-calendar/2021/lang_dev
の1日目のために書いた。

以前の投稿で紹介した「累積レジスタ割付による仮想マシンの高速化」。
https://abo-junghichi.hatenablog.jp/entry/2019/11/14/001142
興味深いのは、そこで使われるJITコンパイルが仮想命令単位ということ。

世の中には既に、実行時コンパイラ作成ツールが幾つかある。
SLJITやlightningやLibJIT、MIR、LLVM、PyPy…。
これらのJITツールは、最小のコンパイル単位が関数だ。
ということは、それより小さいな単位でのJIT手法が書きにくいということだ。
例えば、仮想マシンエミュレータとして有名なQEMUで使われるJIT手法では、分岐命令に達するまでのコードである「基本ブロック」までは一気にコンパイルするが、分岐命令以後のコードからは、先のJIT済み基本ブロックを走らせて分岐結果が得られるまで、コンパイルを後回しにする。
そして基本ブロック末尾の分岐命令は、最初はJITコンパイラの呼び出しだが、後続ブロックのJIT後は、その後続ブロックへの分岐命令に書き換えられる。
最近Ruby本家にmergeされたYJITも、QEMUと同様に基本ブロック末尾の書き換えをしている。
なのでこれらのJIT手法を用いるには、関数よりも小さな単位でコードをコンパイルしなければならない。
上記JITツールでも、末尾呼び関数で基本ブロックのエミュレーションはできるだろうが、分岐命令の書き換えについては、本来は分岐命令ひとつ書き換えれば済むところ、基本ブロック丸ごとJITし直すハメになる。

そもそもJITで一番辛いのは、異なる物理命令セット間での移植性がないことだ。
逆に言えば、仮想ISAによる移植性確保以上の機能は、JITツールには不要じゃないだろうか?
少なくとも、実行時コンパイル負荷の増加とのトレードオフにはなるはずで、ならば、コンパイル負荷低減の方向に思いっきり振った、仮想ISAを命令単位で機械語バイト列に変換するJITツールの意義はあるはず。

そんな累積レジスタ割付だが、ひとつ問題がある。
C言語の標準ライブラリ関数など、標準的なコンパイラで生成されたサブルーチンを呼び出すと、一部の物理レジスタの内容が書き換えられてしまうことだ。

関数呼び出しには
呼出規約 - Wikipedia
https://ja.wikipedia.org/wiki/%E5%91%BC%E5%87%BA%E8%A6%8F%E7%B4%84
という約束事がある。
関数を呼び出す側と呼び出された関数側の双方でこの約束事を守ることで、呼び出し側のプログラムと呼び出される関数を別々にコンパイルしたり、極端な話、別々の言語処理系でコンパイルしても、呼び出し側のプログラムでその関数を利用できる。
JITツールを作成する場合も呼出規約を利用することで、自分で機械語バイトを並べずに外部のコンパイラに仮想ISAの実装を丸投げできるし、ライブラリなどJITツール外のコードをJITコードから呼び出せる。
丸投げの例としては、
YETI: a graduallY Extensible Trace Interpreter
http://www.cs.toronto.edu/~matz/dissertation/matzDissertation-latex2html/matzDissertation-latex2html.html
で確立された、仮想命令のうちジャンプや分岐を伴わないものは全て外部関数呼び出しで済ませるcontext threadingがある。
というより、外部関数を扱えないというのは、言語処理系として致命的だ。
ファイル操作からウィンドウの描画まで、コンピュータの持つ機能のほぼ全ては、それらの機能をユーザーに直接触れさせない現代的なOSでは、ライブラリ関数として提供されているのだから。

その約束事の中に物理レジスタの使い方も含まれており、格納している値が関数呼び出し後に不定になる物理レジスタが定められている。
ここでは、外部関数呼び出しで値が不定になる物理レジスタを「破壊レジスタ」、値が保たれる物理レジスタを「保存レジスタ」と呼ぼう。
関数呼び出しを跨ぐ値には破壊レジスタを割り付けられないので、通常のAOTコンパイラでは、外部関数呼び出しを跨いで使用される値と跨がない値とを区別して、跨がない値には優先的に破壊レジスタを割り付ける。
ここで注意したいのは、保存レジスタならば、関数呼び出しを跨ぐ値と跨がない値のどちらにも割り付けできることだ。
破壊レジスタ・保存レジスタの数を知っている通常のAOTコンパイラであれば、破壊レジスタを使い尽くしたか保存レジスタが余るかどうかを考慮して、保存レジスタを破壊レジスタの用途に転用できる。
しかし、破壊・保存レジスタの数以前に物理レジスタの数を知ることが出来ない累積レジスタマシン用のAOTコンパイラでは、この転用の判断ができない。

これを改善する一つの方法が、破壊レジスタを割り付けられた仮想レジスタには保存用のメモリ領域も同時に割り付け、どちらの領域を使うかは仮想命令のJIT毎に、レジスタ番号と同時に指定することだ。
保存レジスタを割り付けられた仮想レジスタや、物理レジスタを割り付けられなかった仮想レジスタでは、破壊レジスタか保存用メモリ領域かの選択を無視する。
値が外部関数呼び出しを跨ぐかどうかまではAOTコンパイラで把握できるので、それをJITコンパイラに伝達する仕組みだ。
割り付けられている物理レジスタの種類に依らず仮想レジスタの使い方が揃っているので、AOTコンパイラ側では破壊・保存レジスタの数を気にする必要がなくなる。
逆に言えば、どの仮想レジスタに破壊レジスタの割り付けられているかをAOTコンパイラは分からず、破壊レジスタが空いているのに、関数呼び出しを跨がない値に保存レジスタを割り付けてしまう。
もっと良い方法があるかも知れない。

日本の高レベル放射性廃棄物最終処分地は、活断層の真上に選定されるのが理性的

先に北海道寿都町で応募の動きがある、高レベル放射性廃棄物の最終処分地選定。
調査だけなら、莫大な交付金を貰ってお終いという、おいしい話だが。
選定されれば当然、安全なレベルに落ち着くのに何万年もかかる放射性物質を郷里に埋めることになる。
そんな恐るべき調査への応募の動きが、北海道神恵内村でもあるようだ。
一見すると危険な賭けのようにも見えるが、最終処分地決定までは進まない理由が、神恵内村にはある。
経済産業省資源エネルギー庁の公表した「科学的特性マップ」では、村域の大半を覆う「好ましくない特性が推定される地域」が、円を描いているからだ。
不適性地域が円を描いているのは、その中心に火山があることを意味している。
富士山の宝永火口のように、火山の火口の位置は移動することがあるので、円の境界の外側と内側で、安全性が極端に異なるわけではない。
村南部の一部のみが辛うじて不適性地域の範囲からはみ出ているが、大した意味はない。
将来、地元合意の原則を国が捨てて最終処分地選定を行っても、神恵内村が選ばれることは狂気の沙汰だろう。

よって、神恵内村の調査への応募は、賭けにもならない当然の結果だ。
神恵内村商工会、頭良い。
むしろ、先に動きのあった寿都町の方が、適性地域を抱える分だけ、神恵内村と異なり最終処分地の当たる危険な賭けだ。

最後に、神恵内村のように不適性地域で占められ、最終処分地になる危険を冒さず安全に交付金をゲットできる自治体は、中央構造線断層体上にある伊方町など、他にもある。
暫くは、最終処分地への応募はこれら不適性な自治体で占められ、徐々に、ギリギリ微妙な自治体へ調査が広がるのだろう。
最終処分地に最適な自治体は、最後まで応募しない。
もし、応募した自治体に処分地を限定したら、エネルギー庁側が短気であるほど、最終処分地は不適性な危険地域に選定され易くなるわけだ。
日本の原発依存度、つまり核のごみの生成速度を考えると、エネルギー庁側に残された時間は多くない。
地元合意を前提に考えると、日本の高レベル放射性廃棄物最終処分地は、活断層の真上とか火山付近とか、狂気の沙汰になるのが理性的なのだ。