egglog入門
はじめに
egglogはDatalog[1]っぽいプログラミング言語で、e-graphによって実行されます。
汎用的なプログラミング言語ではありませんが[2]、数式とかプログラムの最適化が得意でherbie[3]でも使われたりしています。
以下は有向グラフの2点間の最短距離を求めるegglog
のサンプルプログラムです。
ちなみにこれだけなら別にめちゃめちゃ最適なアルゴリズムで実行されるというわけでもなくそんなに有用ではないですが、雰囲気はわかると思います。
; ;から行末まではコメント
; egglogの文法はLispっぽいが見た目がそうなだけ
; 二点間の距離を定義する
(function edge (i64 i64) i64)
; 二点間の最短距離
(function path (i64 i64) i64 :merge (min old new))
(rule ((= (edge x y) d))
((set (path x y) d)))
(rule ((= (path x y) d1) (= (edge y z) d2))
((set (path x z) (+ d1 d2))))
; 辺のデータを入れていく
(set (edge 1 2) 1)
(set (edge 2 3) 2)
(set (edge 3 4) 3)
; EqSat実行
(run 100) ; 最大100回までイテレーションする。正直100という数字は適当だが文法上必要なので入れている。別に1000とかでも良い
; 1-4間の最短距離を出力
(extract (path 1 4)) ; 6
; 1-4間にもっと短い経路があることを入力
(set (edge 1 4) 5)
; EqSat実行
(run 100)
; 1-4間の最短距離を出力
(extract (path 1 4)) ; 5
Equality Saturationとe-graph
egglog
自体e-graph
をうまく使うためのインターフェースといった感じがあり、Equality Saturation
とe-graph
の理解は外せないのでここで説明します。
Equality Saturationが解く問題
手元にあるS式[4]があり、それをあらかじめ決めたルールに従って書き換えていってより良い形[5]にするという問題を考えます。このとき書き換える前と後はなにかの基準で同じ意味を持つようにルールを作ります。
例えば、数式を表すS式(/ (* a 2) 2)
を以下のルールに従って書き換えていきます。
ご存知の通りa
に書き換えられればOKです。
ルール
x
, y
, z
は任意のS式を表します。
-
(* x 2)
を(<< x 1)
に書き換える。<<
は左シフトの意味 -
(* x 1)
をx
に書き換える。 -
(/ (* x y) z)
を(* x (/ y z))
に書き換える。 -
x
が0
でない場合、(/ x x)
を1
に書き換える[7]。
上のルールを適切な順番で適用していくと、(/ (* a 2) 2)
をa
に書き換えられますが、順番が重要です。
- ルール1を最初に選んだ場合
(/ (* a 2) 2)
➡️(/ (<< a 1) 2)
...これ以上適用できるルールがない - ルール3を最初に選んだ場合
(/ (* a 2) 2)
➡️(* a (/ 2 2))
ルール4を適用 ➡️(* a 1)
ルール2を適用 ➡️a
...これはOK🎉
つまり適当にルールを適用していく(そして元の式を消す[8])だけでは最適な結果になるとは限らないのです。
これは最適化問題の頻出系なのでとりあえずビームサーチすればどうにかなりそうに見えますが、この問題に対してはもっと良いアルゴリズム(Equality Saturation)とそれを可能にするデータ構造(e-graph)があります。
Equality Saturation
Equality Saturationはこの問題を次のように解きます。
- 手元のS式に対して適用できるルールを全部適用して適用前、適用後の情報を全部覚えておく
- 1を繰り返す。そのうちどのルールをどう適用しても新しい情報を得られなくなる(Saturationする😎)[9]ので、そしたら次に行く
- 手元にあるS式の中から(なにかの基準で)一番良いものを選んでそれを出力する
https://blog.sigplan.org/2021/04/06/equality-saturation-with-egg/ から引用した図
実質すべての可能性を探索しているので必ず最適な解が得られます💪
ただ、過程で出てきたすべての情報を覚えなければいけないのでデータ構造を工夫しなければなりません。それがe-graph
です。
e-graph
e-graph
は上で言うS式とその合同[10]関係をグラフにして保存します。グラフの表現力はすごいので、指数関数的に増えていくS式を効率的に表すことができます。
e-graph
はnode
とe-class
を保存します。node
の子はe-class
で、e-class
は合同なnode
の集合です。また、node
は常にただ一つのe-class
に属します。
上の例でいうとnode
はS式を表し、e-class
は合同なS式の集合を表します。例えば(* a 1)
とa
はルール2によって合同だとみなせるので同じe-class
に属すはずです。
e-graph
に対してルールを適用していくと、効率的に空間を使いながら、今までの情報を失うことなく新しい情報をe-graph
に足していくことができます。
egg
のサイトにある図. (/ (* a 2) 2)
を書き換えていく様子を表している
図の実線で囲われている四角はnode
を表し、点線で囲われている四角はe-class
を表しています。
node
から伸びている線はすべてe-class
につながっていることに注意してください。
上の例でいうと(/ (* a 2) 2)
は図の左端のように表現できます。例えば2
はそれ自体がnode
で2
と合同なnode
は今のところ存在していないので2
一つで一個のe-class
です。(/ (* a 2) 2)
には2回2
が出てきますがどちらも同じnode
を指しているので同じe-class
に線が来ています。
(* a 2)
を(<< a 1)
に書き換えたのが左から二番目です。(* a 2)
と(<< a 1)
はルール1によって合同なので同じe-class
に属しています。
そういう具合にe-graph
にルールを適用していくと最終的にこれ以上ルールを適用できなくなり図の右端のようになります。(/ (* a 2) 2)
とa
が同じe-class
に属しているので、(/ (* a 2) 2)
はa
になりました🎉。
e-graph
を具体的にどう実装するかは本記事では触れませんがegg: Fast and extensible equality saturationによく書いてあるので興味があれば読んでみてください。
egg
eggはe-graph
のライブラリです。egglog
自体はegg
に依存していませんが実質同じようなものがegglog
に入っていると考えていいです。
egg
の特徴は、ユーザーが明示的にEquality Saturationを実行するように設計されていることです。
古典的なe-graph
の実装ではユーザーがe-graphになにか操作するたびに自動的にEquality Saturationを実行するのですが、egg
ではユーザーのタイミングでまとめてEquality Saturationを実行するのでより効率的になっています。詳しくはegg: Fast and extensible equality saturation。
なのでegglog
でも上の方にあるサンプルコードのように、Equality Saturationを明示的に(run 100)
みたいなやつで実行する必要があります。
egglog
前置きが終わったのでegglog
の話に入ります。
環境構築
手軽に試したい場合はWeb上で動くデモがあります。
ローカルで実行する場合は特にバイナリ等はまだ配布されていないので、cargo
をでビルドします。
cargo
はRustの開発環境についてきます。
cargo install --git https://github.com/egraphs-good/egglog
自作ですがVSCodeの拡張もあるので入れておくと便利です。公式の拡張も一応ありますが全然更新されてないのでおすすめしません。
egglog-language
実行方法
他の言語処理系と同じように、egglog
にソースコードを渡すと実行されます。拡張子は.egg
にするのが慣習です。
egglog ${file}
egglog -h
で他に何が出来るかわかりますが、特に重要なのは以下です。
-
egglog --desugar ${file}
: 糖衣構文を展開して出力する -
egglog --to-dot ${file}
: 実行結果のe-graph
のグラフを出力する
VSCodeの拡張を入れている場合は糖衣構文の展開結果をマウスオーバーで見ることができます。
(datatype BDD (ITE i64 BDD BDD))
は(sort BDD) (function ITE (i64 BDD BDD) BDD)
に展開されることがわかる
egglog --to-dot path.egg
の結果。わかりやすい
リファレンス
rustdocにあるドキュメントが実質リファレンスとして使えます。
egglog
のサンプルコードが読みたい場合はtestsを読むと良いです。
以下では重要かと思われる機能を紹介します。
上の方でegglog
自体e-graph
をうまく使うためのインターフェースといった感じがあると書きましたが、以下で説明する機能はすべてegglog
処理系がグローバルに持っているe-graph
になにか操作するものだと思うとわかりやすいです。
文法
egglog
のパーサーはlalrpopで実装されているので比較的短いBNFっぽいDSLを見れすべての文法がわかります。
組み込みのデータ型と関数
src/sortに組み込み型と関数が定義されています。例えばi64
って何ができたっけ?となった場合にはsrc/sort/i64.rsに目を通すと良いです。
以下に現在サポートされている組み込み型を列挙します。これで全てです。
-
Unit
いわゆるユニット型。リテラルは()
-
bool
真偽値。リテラルはtrue
とfalse
-
i64
符号付き64bit整数 今のところ符号なしの整数型はない -
f64
64bitの浮動小数点数 -
Rational
有理数(rational 1 2)
とかで作る(この例は1/2
を作る) -
String
文字列 -
set
集合 本記事では扱わない -
map
連想配列 本記事では扱わない -
vec
配列 本記事では扱わない。再帰的なデータ構造を使えばそれで足りる場面も多い。
egglog
にはジェネリクスのような機能がまだないのでset
、map
、 vec
の使い方は少し特殊になっています。テストコードを見ると使い方がわかります。
Sort
Sort
はe-class
の識別子のような概念です。たぶん数学的に意味のある用語なのでしょう。同じSort
を持つnode
は同じe-class
に属します。組み込み型もSort
ですが、後述するunion
ができないという制限があります。
Sort
は以下のように宣言します。
(sort NameofSort)
function
Sort
だけあってもしょうがないので、function
でSort
を持つnode
を定義できます。
他のプログラミング言語の関数とはだいぶ違います。単に引数のSort
列をキーに、返り値のSort
を値に持つ連想配列を宣言していると考えるほうが近いです。そういう意味では後述するrule
が関数の本体に近いです。
例
(sort Math)
(function Const (i64) Math) ; (Const 123)とかでMathのnodeを作れる
例えば、ある2つのConst
があったとき、引数が同じe-class
なら結果も常に同じSort
(Math
)になります。つまり、(Const 0)
と(Const 0)
は同じSort
を指します。
union
union
を使うと2つのSort
をマージすることができます。
組み込み型には使えません。例えば0
と1
をunion
して同じSort
になってしまうとめちゃくちゃになってしまいます。
例
(sort Math)
(function Const (i64) Math) ; 定数
(function Var (String) Math) ; 変数
(union (Var "x") (Const 123))
上のコードのe-graph
set
set
はnode
に値をセットします。set
を使うと組み込み型を返すfunction
に値をセットすることができます。これはunion
ではできません。
(function F (i64) i64)
(set (F 0) 1)
; (union (F 0) 1) はエラーになる
union
とset
の使い分けはごちゃごちゃしている部分がありますが、間違えたら適切なエラーメッセージが出てくるのでそれに従って書き換えればだいたいなんとかなります。
rule
正直これまでに説明した機能だけだと、だから何なんだ?って感じですが、rule
を使うと面白くなってきます。
rule
は上で説明したe-graph
のルールに相当します。
rule
はe-graph
にパターンマッチしてなにかアクションを起こす機能です。
そしてunion
とset
はアクションです。
後述するrun
コマンドを実行するとe-graph
に繰り返しrule
を適用していきます。
文法の該当部分を抜粋するとこんな感じです。
LParen "rule" <body:List<Fact>> <head:List<Action>> <ruleset:(":ruleset" <Ident>)?> <name:(":name" <String>)?> RParen => ...
...
pub Fact: Fact = {
LParen "=" <mut es:Expr+> <e:Expr> RParen => ...
<CallExpr> => ...
}
...
NonLetAction: Action = {
LParen "set" LParen <f: Ident> <args:Expr*> RParen <v:Expr> RParen => ...
LParen "delete" LParen <f: Ident> <args:Expr*> RParen RParen => ...
LParen "union" <e1:Expr> <e2:Expr> RParen => ...
LParen "panic" <msg:String> RParen => ...
LParen "extract" <expr:Expr> RParen => ...
LParen "extract" <expr:Expr> <variants:Expr> RParen => ...
<e:CallExpr> => ...
}
pub Action: Action = {
LParen "let" <name:Ident> <expr:Expr> RParen => ...
<NonLetAction> => <>,
}
例
(sort Math)
(function Const (i64) Math) ; 定数
(function Var (String) Math) ; 変数
(function Add (Math Math) Math) ; 足し算
(rule
((= expr (Add (Const x) (Const y)))) ; 文法上はFactのリスト。`=`でマッチした対象を変数に束縛している
((union expr (Const (+ x y)))) ; Actionのリスト ; `+`はi64の足し算
)
; 式を定義する
; e-graphにこの式を追加するというような意味合いもある。知らないものはEquality Saturationできないので。
(let e (Add (Var "a") (Add (Const 1) (Add (Const 2) (Const 3))))) ; a + (1 + (2 + 3))
; Equality Saturationを実行
(run 100)
(extract e) ; (Add (Var "a") (Const 6)). a + 6
上のコードのe-graph
上の例ではrule
を使ってAdd
の引数がどちらもConst
の場合にはそれを事前に計算してしまうようにしています。rule
にないVar
とかは無視して出来るところだけやってくれるのでコンパイラで言うConst Propagationみたいなことを実質やっています。
実際これはすごいことです!例えばこれを拡張していけばきれいにsympy.simplifyやTI-Nspire™ CX IIについてるCASのようなものを作ることができます(破綻しないようにルールを追加していくのは非常に難しいですが😰)。しかも計算が間に合えば常に最適な結果を出力してくれます。
:merge
:merge
によってfunction
に違う値が設定されたときの挙動を変化できます。:merge
はfunction
の宣言のときに指定します。
(function F (i64) i64 :merge (min old new)) ; :mergeの中では`old`と`new`が使えるので複数の値がある場合にどうするかを指定できる。この場合は小さい方を採用する
(set (F 0) 0)
(set (F 0) 1) ; (min 0 1) => 0 なので意味ない
let
もう上で使ってしまいましたが、let
は変数に値を束縛します。再代入はできません。
この文法じゃあグローバルな名前空間がぐちゃぐちゃになってしまうじゃないかと思うかもしれませんが回避策もあります。
それが、push
とpop
です。この2つのコマンドでスコープを作ることができます。スコープを作るときにはpush
を、スコープから抜けるときにはpop
を使います。
(push) ; スコープを作る
(let x 0)
(pop) ; スコープから抜ける
(let x 1) ; 上でxを定義したスコープはすでにないのでOK
run
run
はEquality Saturationを実行します。run
を実行するとrule
に従ってe-graph
を書き換えていきます。run
は最大で引数にいれた回数だけ実行されます。saturateしたらその時点で終わります。
他にも色々オプションがありますが、とりあえず適当に(run 100)
とかやっておけばいいです。
extract
extract
はe-graph
から引数のe-class
の中で一番低コストなnode
を出力します。何をもって低コストかをコントロールする方法もあるのですが本記事では触れません。
糖衣構文
いくつか便利な糖衣構文を紹介します。
datatype
datatype
はsort
とfunction
まとめた糖衣構文です。
(datatype Math
(Const i64)
(Var String)
(Add Math Math)
)
は
(sort Math)
(function Const (i64) Math)
(function Var (String) Math)
(function Add (Math Math) Math)
に展開されます。
rewrite
rewrite
はrule
の糖衣構文です。
簡単なrule
はrewrite
で書くことができます。
(rule
((= expr (Add (Const x) (Const y))))
((union expr (Const (+ x y))))
)
と
(rewrite
(Add (Const x) (Const y))
(Const (+ x y))
)
は同じ意味です。
実際には機械的に
(rule ((= v2___ (Const x))
(= v4___ (Const y))
(= rewrite_var__ (Add v1___ v3___))
(= v2___ v1___)
(= v4___ v3___))
((let v6___ (+ x y))
(let v5___ (Const v6___))
(union rewrite_var__ v5___))
:name "(rewrite (Add (Const x) (Const y)) (Const (+ x y)))")
と展開されます
relation
function
は連想配列のようなものだと書きましたが、relation
は値がユニット型の連想配列、つまりセットのようなものを宣言する糖衣構文です。
(relation Prime (i64)) ; 素数かどうかを表したいとする。単純に素数かそうでないかを表すだけなので返り値の意味はない。ユニット型でいい。
は
(function Prime (i64) Unit :default ())
に展開されます
(Prime 5) ; :default ()が設定されているので、これでPrimeのnodeを作ることができる
(set (Prime 2) ()) ; :default ()が設定されていないとこうしなければならない
ちなみにユーザーが定義したsort
はe-graph
にないものが必要になった場合新しいsort
が自動で作られますが、ユニット型はそうはいかないので:default
が必要になっています。
(sort S)
(function F () S)
(F) ; 自動で新しいSが作られる
declare
declare
は新しいSort
を変数に割り当てます。
(sort S)
(declare x S)
は
(sort S)
(function v1___ () S)
(let x (v1___))
に展開されます。
ただ、現状可視化したときには関数名しか出てこずx
がどこにあるかわからないのであまりおすすめしません。
(sort S)
(function x () S)
(x)
現状上のように書かないとこの図はでてこない
おわりに
本記事ではegglog
にある機能の一部を紹介しました。
もっと色々知りたい方はソースコードやテストコードを読んでみてください。
宣伝
egglog
でコンパイラの最適化パスを実装してみたい方は一旦CFGをRVSDGに変換するとやりやすいです。
個人的にbriltで実験しているので興味のある方はぜひ🙇
-
Datalogについてはよくわからないので本記事では触れません🙇 ↩︎
-
チューリング完全ではあると思うが例えばwebサーバーを書くといったことには適さないという意味 ↩︎
-
浮動小数点数の演算をより誤差が少なくなるように書き換えるプログラム ↩︎
-
S式みたいな雰囲気のもの。
(x . y)
みたいなやつは考えない ↩︎ -
例えば葉の数が少ないとか ↩︎
-
は絶対にオーバーフローしないとします ↩︎a \times 2 -
の場合、x=0 となってしまいとても危ないので明示的に禁止している。ゼロ除算#ゼロ除算に基づく誤謬のようなことがそのまま起きる。え、じゃあルールを作るのってめっちゃ難しくない!?と思ったあなた、その通りです。ルールを自動で導出しようという研究もあるので興味のある方はRewrite Rule Inference Using Equality Saturationがおすすめです ↩︎0/0=1 -
ASIAN KUNG-FU GENERATIONの名曲が思い出されます ↩︎
-
実用的にはSaturateしなくてタイムアウトで終了することもある ↩︎
-
congruence。"等しい(equal)"とは少し意味合いが違うがとりあえず特に気にしなくて良い ↩︎
Discussion