🐎

Odin で自動微分してみた感想

に公開

Odin という言語があります。これは PC ゲーム向けではありますが、近頃流行っている C や C++ の代替となるネイティブコンパイル汎用言語です。 Rust ともポジションが近く気になっていたので、今回は自動微分シリーズに Odin を使いました。

https://github.com/msakuta/odigrad

自動微分シリーズはこれまでに Rust, Zig, Scala, Swift でやっています。

出力例

output

graph

Odin の簡単な紹介

Odin そのものが(特に日本では)非常にマイナーな言語なので、少しだけ紹介します。

Odin の特徴は、低レイヤでありつつもモダンな言語機能を取り入れていることで、思想的には Zig が最も近いと思います[1]。 Ginger Bill 氏の個人開発言語であり、 LLVM を使った多くの言語のうちの一つです[2]

そのほか私の印象に残った特徴を挙げると次のようになります。

  • 豊富なベンダーライブラリ(追加パッケージなしにゲームが作り始められる)
  • 未定義動作は「定義する」ことによって排除する
  • 暗黙に渡されるコンテキスト変数
  • 配列プログラミング

ベンダーライブラリ

Odin の最大の特徴かつ売りが、ベンダーライブラリです。これはコンパイラにバンドルされているライブラリ群で、 raylib や SDL などゲームのグラフィックスをサポートするのに役立つ多くのライブラリが含まれています。

その代わりと言ってはなんですが、 Odin にはパッケージマネージャがありません。自作のライブラリを配布する良い方法があるのかどうかわかりません。 Git のサブモジュールでも使うのでしょうか。

未定義動作はない

2番目の「未定義動作は「定義する」ことによって排除する」については少し説明が必要かと思います。

ネイティブコンパイル言語では未定義動作がよく問題になります。未定義動作にはさまざまありますが、よくあるのは未初期化のメモリを参照した時です。 C や C++ を始めとする最適化を施す言語では、未定義動作を引き起こした後に何が起きてもコンパイラの責任ではない、いわゆる「鼻から悪魔」という状態になります。 Rust では型システムによってこの未定義動作をコンパイル時に防いでいます。

Odin の (というよりも Ginger Bill 氏の思想の)中では、動作を定義してしまうことによって未定義動作を防ぐこととしています。たとえば、ヌルポインタを参照外しすればアクセス違反になり、整数がオーバーフローすればラップアラウンド (mod n) します。

これに関して私の感想は #未定義動作に関する感想 に後述します。

暗黙に渡されるコンテキスト変数

Odin にはコンテキストと呼ばれる概念があり、全ての関数にデフォルトで渡されます。コンテキストを取る関数は独自の呼び出し規約を持ち、 C の関数の呼び出し規約とは互換性を持ちません。このため、 C のライブラリにコールバックなどを渡すときは、 "contextless" 呼び出し規約で宣言する必要があります。これは MSVC の __thiscall__ 呼び出し規約に似ています。

このコンテキストは構造体として定義されており、執筆時点では次のように定義されています。

Context :: struct {
	allocator:              Allocator,
	temp_allocator:         Allocator,
	assertion_failure_proc: Assertion_Failure_Proc,
	logger:                 Logger,
	random_generator:       Random_Generator,

	user_ptr:   rawptr,
	user_index: int,

	// Internal use only
	_internal: rawptr,
}

allocatortemp_allocator がメモリアロケータを指します。これらが暗黙に渡されるため、 Zig のように引数にアロケータを繰り返し書かなくても済みます。

公式ドキュメントには次のような使い方が示されています。

	c := context // copy the current scope's context

	context.user_index = 456
	{
		context.allocator = my_custom_allocator()
		context.user_index = 123
		supertramp() // the `context` for this scope is implicitly passed to `supertramp`
	}

context がコンテキスト変数を表すキーワードとして使われており、値のコピーを作ることでコンテキスト変数のスタックをプッシュする効果があることが分かります。

配列プログラミング (array programming)

Odin の配列は何もしなくても配列演算をしてくれます。

a := [3]i32{1, 2, 3}
b := [3]i32{4, 5, 6}

fmt.printfln("a + b = {}", a + b) // a + b = [5, 7, 9]

もちろんスカラー倍もサポートします。

a := [3]i32{1, 2, 3} * 3

fmt.printfln("a * 3 = {}", a) // a * 3 = [3, 6, 9]

入れ子になった配列や複素数、四元数もサポートします。

ゲーム向けらしい機能として swizzling があります。これはシェーダープログラミングなどでおなじみです。

a := [3]i32{1, 2, 3}
b := a.zyx

fmt.printfln("a.zyx = {}", b) // a.zyx = [3, 2, 1]

これらの機能は 3D グラフィックスをやっている人からすれば福音と言えるでしょう。わざわざ float[2]Vector2 を別の型として定義して演算子のオーバーロードをしなくても済むのです。 Swizzling に関しては、演算子のオーバーロードでも対応できません。

また、コンパイラが配列プログラミングであることを認識しているため、ベクトル演算化の恩恵にも期待できます。

残念ながら、自動微分においてはこれらの配列プログラミングの線形性を活用することはできません。自動微分にはグラフの構築が必要であり、プリミティブ型の演算のベクトル化だけでは対応できないからです。

導入方法

Odin の導入方法は驚くほど簡単です。 Releases の中からプラットフォームごとのバイナリをダウンロードして展開し、 Odin 実行ファイルを実行するだけです。私は Windows で試しましたが、追加で必要になるパッケージなどはありませんでした。公式ドキュメントによると MSVC の Windows SDK が必要らしく、私の環境にはすでに存在していたと思われます。

raylib を使って簡単な GUI アプリケーションを作ってみましたが、すんなりと動きました。 Rust で raylib を使おうと思ったら、システムに CMake や clang のインストールを要求されるので、特に Windows においては苦行でしかありません。 Odin は GUI を使えるネイティブコンパイル言語としては最高級の始めやすさだと思います。

感想

例によって Rustacean のバイアスにご注意ください。

コンテキスト変数に関する感想

コンテキスト変数は Jai にも存在する概念らしいですが、カスタムアロケータをコードノイズを増やしすぎずに使う手段としては興味深いです。 Zig は正反対の思想を持っており、全ての引数が明示されているべきという立場なので、このような仕組みは実装されないでしょう。

私の見解では、 Zig のようなピュリズムは長い目で見ると困難に直面すると思います。結局のところ、プログラミング言語というのは抽象化なので、多かれ少なかれ暗黙の動作はするものです。コードの冗長性と理解しやすさはトレードオフであり、ピュリズムは時間の試練によって現実主義に屈するのが歴史の教訓です。 C のような低水準言語でも引数付きマクロのようなメカニズムが存在することがそれを物語っています。

その意味では Odin のコンテキスト変数は悪くない手法です。構造体として定義されているので将来の拡張にも対応できます。ただ、個人的には Odin の思想が強く反映されすぎているような気もします。ユーザが拡張したいと思っても user_ptruser_index 等決め打ちのフィールドしかないので、ライブラリから使うのは干渉が怖くてできません。 アロケータやロガーなど、「Odin が考えるコンテキスト変数の使い方」に合わせざるを得ません。

あと不思議に思っているのは、スレッドローカルを使えばコンテキスト変数への暗黙のポインタはいらないのではないか、ということです。スレッド間で関数ポインタを渡すことを考えても、 Odin にはクロージャが存在しないので問題はないような気がします。

この考えが正しければ、コンテキスト変数は一つと言わずユーザが好きなだけ定義できるはずです。あたかも関数の呼び出しスタックのほかに複数のコンテキスト変数のスタックがあり、ユーザの好きなタイミングでプッシュ・ポップできるイメージです。これができれば C++ のクラスの this ポインタのような使い方もできるようになり、抽象化の幅が広がります。現実味があるかはわかりませんが、夢のある話です。

未定義動作に関する感想

前述のとおり Odin は未定義動作に対してはかなりユニークなスタンスをとっていますが、このアプローチには一長一短あると思います。 Ginger Bill 氏は「最適化の結果は未定義動作にする必要はなく、定義できる」という思想を持っていますが、これには一理あるとは思いつつも全面的には賛同できません。

まず、最適化の方法は時代とともに変化するものです。最近の流れではメモリのアクセス時間がパフォーマンスのボトルネックになりがちなので、キャッシュメモリのヒット率を重視した最適化が重要になっています。また、最近の CPU に備わっている SIMD を活用するには、昔の CPU の最適化を下方互換を保ったままにすることはできません。 Odin は比較的新しい言語なので、そのような時代の変遷の試練を受けていないだけともいえます。 C や C++ がこれほどまでにも長寿なのは、コンパイラ側にも調整のゆとりを持たせる未定義動作があってこそといえるでしょう。

もう一つの問題は、 Odin が主要な CPU アーキテクチャ、 x86 と ARM しかサポートしていないことです。 C や C++ の時代は、様々な珍妙な CPU アーキテクチャが乱立しており、未定義動作なしには互換動作のために非常に高いコストを払うことになりかねませんでした。たとえば符号付き整数の負の数を表現するのに 1 の補数を使う CPU だった場合、 2 の補数でのラップアラウンドの動作を再現するには比較的込み入った計算をする必要があり、それもオーバーフローを起こしうるすべての演算 (加算、積算、減算)にコンパイラが導入することになります。このような場合は「符号付き整数のオーバーフローは未定義動作である」としてしまったほうが合理的でしょう。もちろん現代のコンピュータで符号付き整数に 2 の補数を使っていない CPU はほとんど存在しないでしょうが、「ほとんど存在しない」CPU をもサポートするのが C や C++ の価値だといえます。

C や C++ はまた、複数のベンダーの実装を持つ言語であり、リファレンスが言語の定義です。これは Odin のような実装が一つしかない言語では実装が言語の定義となるのとは根本的に異なります[3]。複数のベンダーが同じ言語を実装するには、実装の自由度を持たせる未定義動作は不可欠です。

ツール類に関して

ツール類の充実度でいうと、 Rust はもちろん Zig よりも低いと言わざるを得ません。パッケージマネージャは(意図的に)ありませんし、公式の linter や formatter もありません。私は試していませんがデバッグも楽ではないらしいです。また、マイナー言語あるあるですが、 LLM のサポートも弱く、 Copilot はコンパイルできないコードサンプルを自信満々に出してきます。

ほぼ個人開発の言語なので仕方のないことではありますが、ツールに頼らず自分の手でコードを書く気力のある人向けと言えます。

また、自動微分のサンプルコードの一つで次のような謎のエラーが生じることがありました。

D:\a\Odin\Odin\src\llvm_backend_expr.cpp(3849): Assertion Failure: `e != nullptr`

これは私の PC のパスではなく、ビルド環境のものと思われます。 LLVM の内部のエラーを踏んでいるようです。これ以上のエラーのコンテキストはないので、解決のしようがありません。このため、 Rust, Zig, Scala, Swift ではやっていた三角関数の微分のグラフは odigrad ではプロットできませんでした。

関数型言語機能について

Odin は全く関数型言語ではありません。 Zig のように全力で命令型です。

このため、自動微分のようにツリーを辿るロジックでは、 Rust ではパターンマッチを使って次のように複数の変数を一度に判別できていたところを、

    let ret = match nodes[idx as usize].value {
        // ...
        Add(lhs, rhs) => {
            let lhs = gen_graph(nodes, lhs, wrt, cb, optim);
            let rhs = gen_graph(nodes, rhs, wrt, cb, optim);
            match (lhs, rhs) {
                (Some(lhs), None) => Some(lhs),
                (None, Some(rhs)) => Some(rhs),
                (Some(lhs), Some(rhs)) => Some(add_add(nodes, lhs, rhs, optim)),
                _ => None,
            }
        }
        // ...

次のように書かざるを得ません。

    #partial switch v in tape.nodes[node].uni {
        case TapeOp:
            lhs := tape_gen_graph(tape, v.lhs, wrt)
            rhs := tape_gen_graph(tape, v.rhs, wrt)
            lhs_v, lhs_ok := lhs.(int)
            rhs_v, rhs_ok := rhs.(int)
            switch v.op {
                case .Add:
                    if lhs_ok && rhs_ok {
                        return tape_add(tape, lhs_v, rhs_v)
                    }
                    if lhs_ok {
                        return lhs_v
                    }
                    if rhs_ok {
                        return rhs_v
                    }

ループに関しては Ranged for loop のように書けますが、インデックスも必要なら取れます。

    for node, i in tape.nodes {
        // ...
    }

命令型言語としては十分でしょうが、関数型言語の filter, map, reduce などのコンビネータを使うとやはり物足りなく感じます。

コンパイル速度に関して

コンパイル速度はかなり速いと思います。特にベンダーライブラリに相当する機能をほかの言語がパッケージマネージャを介して解決しなければならないときに差が際立ちます。

簡単な raylib アプリケーションで zig build runodin run を試してみましたが、後者の方が明らかに速いです。

Rust でも raylib で比較してみましたが、 Windows 上ではビルドできなかったので WSL でビルドしたため、公平な比較にはなりません。とはいえ、 Rust は最近ずいぶん速くなり、キャッシュが効いていれば Odin と遜色ないと思われます。

思想に関して

総評して Ginger Bill 氏の思想が強く反映された言語であり、彼の考えに同調できるならば良い言語と感じられることでしょう。とはいえ、 Ginger Bill 氏は現実主義的であり、こだわりが強すぎると感じることはありませんでした。

例えば、彼は Pascal および Go 言語に強く影響を受けており、言語設計にもその片鱗が幾つも見られます。それでも begin と end は(ありがたいことに)使いませんし、 Pascal 文字列のような現代では奇妙な構造もありません。

ただし、「主な使い方」を想定してそれ以外をサポートしない思想は広く受け入れられるには障害になると思います。例えば、 Odin には演算子のオーバーロードはありませんが、それは「ほとんどのケースでは、配列プログラミングで十分である」という理由からです。ところが私の実装した自動微分はまさに「配列プログラミングでは十分ではない」例であり、演算子のオーバーロードがあれば次のように書けたところを、

    x := og.tape_variable(&tape, "x", 1.)
    param := -(x * x)

次のように書かざるを得ません。

    x := og.tape_variable(&tape, "x", 1.)
    x2 := og.tape_mul(&tape, x, x)
    param := og.tape_neg(&tape, x2)

自動微分のようなマイナーな使い方を想定しないのは、それ一つであれば大きな影響はありませんが、プログラミングの世界は多様性が増していくばかりなので、このような「主流な使い方だけ手厚くサポートする」考え方では取りこぼすロングテール効果が大きいと思います。

とはいえ、これは思想の問題というより、開発チームのリソースの問題に近いかと思います。 C++ のような膨大なユーザーベースを抱える言語と充実度を比較するのはフェアではありません。ユーザー人口が増えて開発への投資ができるようになれば改善する可能性は十分にあります。

結論

Ginger Bill 氏は Odin をゲーム向けには限らない言語として位置づけていますが、私の触った限りではやはりゲームのプロトタイプに最も向いていると思います。特にネイティブ GUI アプリケーションの構築の簡単さは秀でています。

ゲーム向けという観点では、PC以外のプラットフォームへの移植ができるのかは少し気になりました。

それ以外にも、コンテキスト変数や独特な関数オーバーロードなど、ちょっと変わった概念を学習する機会として面白い言語だと思います。

これに対し、再利用可能なソフトウェアのパッケージを作るという目的には(まだ)適しているとは思いません。ベンダーライブラリのサポートしている範囲内でなら良い開発者体験が得られますが、それを超えたら(いずれ必ず超えます)かなり変わってくる気がします。前述の「主流の使い方を重視する」考え方にも関わってきますが、「主流」というのは人によって定義が異なります。

また、メモリ管理は全て手動であり、 defer 文はあるものの、 Web サーバのような信頼性とメモリ安全性が重視されるアプリケーションを書こうとも思いません。

それはともかく、今後が楽しみな言語ではあります。

脚注
  1. Jai にもかなり近いと思うのですが、 Jai はクローズドベータであり直接使うことはできないので、比較対象からは除外します。 ↩︎

  2. Ginger Bill 氏は LLVM をあまり好んでいないのですが、コンパイラの個人開発の敷居を下げるという意味では評価しており、 Odin にも「仕方がなく使った」という立ち位置のようです。将来的にはバックエンドは自前実装にしたいそうです。参照:https://www.youtube.com/watch?v=0mbrLxAT_QI&ab_channel=WookashPodcast ↩︎

  3. Rust も現状では実装が定義です。 gcc のフロントエンドに Rust を実装するという動きや、言語仕様のドキュメント化のプロジェクトがあり、これが実現すれば複数の実装が参照するリファレンスが定義となりますが、まだ時間がかかりそうです。 ↩︎

GitHubで編集を提案

Discussion