Open9

Mojo 🔥

先日プログラミング言語 Mojo と呼ばれるもののアナウンスメントがあった。この言語のデザインが私のスイートスポットに刺さる感じだったので、今のうちから注目している。使いたいなというか、将来使うことになりそうな言語なので簡単に何ができそうかを調査してまとめておきたい。

https://www.modular.com/mojo

ウリとしては「C 並のパフォーマンスが出る Python」といったところだろうか。
k0kubun さんからコメントを裏でもらって、これって要するに並列化とか SIMD 化とか入れたら35,000倍のパフォーマンスが出るようだけど、これは Python の部分とは呼べなくて、素の Python 動かして本当にそういえるかは怪しくない?とのことで、判断保留します 🙇🏻‍♀️ k0kubun さんありがとう

言語のデザインとしては、AI 開発に向けたプログラミングを提供できるよう設計されていると感じる。表側は Python だが、細かくシステムプログラミング言語寄りの操作ができるように設計されている。これが言語仕様として一気通貫していることによって、従来の Python による AI プログラミングが抱えていた問題を解消できることを狙ったのだと思う。通常の Python とは異なり型付けがしっかり静的になされるようであるし、また Python を利用する上での最大のボトルネックである GIL もなくなる。Python との移植性もかなり意識されている。メモリ管理については Rust と同じでボローチェッカーを採用していると思われるが[1]、個人的には Rust を書いていた際に感じていた、ムーブセマンティクス周りで不明瞭だと思うポイントやムーブせマンティクス中心の言語設計からくる辛さを上手に回避できるデザインになっているのではないかと感じている。

現在は Playground への入場が waitlist 方式で管理されているようで、全員が触れる状態ではない。GitHub リポジトリにも何かコードが公開されているわけではないようで、今のところ Doc 以上の情報がない。私も waitlist には申し込んだもののまだ入れていない。というわけで、触れてもいない言語について書いているのでよくわかっていないことが多いです。

正直 Python に対する不満は文法というよりはツール周りや型付け、スピードなどに起因することが多いと思う。私も現場で Python を書いていてウッとなるのは文法そのものよりもそうした部分だ[2]。文法のわかりやすさは、これだけ世の中に広まってソフトウェアエンジニアでない人であっても利用するところから、「わかりやすい」の市民権を得ていると言ってもいいと思う。Mojo は、Python のよいところを残して使えるようにしつつ、課題点を改良するバランスの良さが面白いポイントかもなと思った。

このスクラップを残し始めたのは単にドキュメントに Owned とか Borrowed とかの字面が見えて、いわゆる所有権システムを別の言語で実現した新たな例なのかなと思ったのがきっかけです。

脚注
  1. 調べてみて徐々にわかり始めたが、どうやらムーブとコピーとは別のメモリ管理をしていそうな感じがする。Swift みたいに値型と参照型を分けて管理している可能性がありそう。正直なところ、Playground に入って何が起きているかを確かめてみないとよくわかりません。 ↩︎

  2. 昔はインデントベースの文法にウッとなったこともあったが、最近は VSCode を使うようになって自動でフォーマットしてくれるようになったしまったので、もう慣れてしまった。それよりはパッケージ管理とか配布方法にウッとなることが多い。 ↩︎

Mojo は基本的な路線では Python を全面踏襲する形をとっているが、Python を選んでいるのには下記のような理由があるらしい。

  • Python は ML やそのほかの領域において支配的なポジションをもつようになってきた。というのも、言語の文法自体が学習しやすく、コミュニティやエコシステムの充実度合いなどがあるから。
  • 動的型付けの恩恵を受けたよい API を提供するのにぴったりな言語だから。

https://docs.modular.com/mojo/why-mojo.html#why-python

一方で Python は Python で歴史の長い言語ということもあり、次のような問題を抱えていると指摘する。

  • two-world 問題: Python はシステムプログラミングには不向きなため、そちらを使用してパフォーマンスを得ようとする場合には C や C++ 等のシステムプログラミングに特化した言語の力を借りる必要がある。ただこれは、たとえば実装時に C や C++ 、cpython 等の内部を知っている必要があるなどの学習コストの面での問題がある。Python の世界とシステムプログラミングの世界が分離されていることから、エコシステムが複雑化している。
  • three-world や N-world 問題: 機械学習においてはさらに CUDA などが必要になるが、ここまで含めて一貫して利用できるデバッガやプロファイラのようなツールがなく、開発にはさまざまな制約があるといえる。
  • モバイルやサーバー周りのデプロイの問題: これは要するに Python のビルドシステムや配布方法について言っている?

https://docs.modular.com/mojo/why-mojo.html#whats-wrong-with-python

fn と def

どうやら Mojo には関数定義が2パターンあるらしい。

  • def: Python と完全互換ができる。動的性をもつ関数定義。
  • fn: def の "strict mode" にあたる。より pedantic で strict な(要するに安全側に倒した)def が fn になるらしい。

たとえば下記のような違いがある。逆にいうと def にはこれらが求められない:

  • 関数のボディ部分はデフォルトでイミュータブルになるらしい。内部の変数宣言とか操作とかが全部イミュータブルになるっていうことかな?で、可変な操作が関数内部にある場合やコピー不可能な型を引数として使用できるようになるらしい。
  • 引数には型の指定が必要になる。戻り値の型指定も厳密寄りになる。指定しないと None を返してると見なすようになる。
  • ローカル変数をちゃんと let と var を用いて宣言して使用する必要が出てくる。
  • def も fn も例外をサポートしているが、fn 側では raises を使用して明示的に宣言する必要がある。

要するに def (Python 側)で緩く設定されていたものがもう少し明示的かつ厳密な設定をしないといけないことを保証するのが fn ということなんだと思われる。Mojo 側の世界線で済ませる場合は基本は fn で定義しちゃう、みたいな使い方をするんだろうか。一見すると違いがわかりにくいといえばわかりにくいが、移植性を保つためにはおもしろい設計だと感じた。

let と var

両者共に変数宣言をする。イミュータブル = let。ミュータブル = var。どっちもレキシカルスコープなのと、シャドーウィングをサポートしている。

struct

データ型を宣言できる。Python の class とは違って静的。

struct MyPair:
    var first: Int
    var second: Int
    def __init__(self&, first: Int, second: Int):
        self.first = first
        self.second = second
    def __lt__(self, rhs: MyPair) -> Bool:
        return self.first < rhs.first or
              (self.first == rhs.first and
               self.second < rhs.second)

所有権周り

所有権周りについて知る前に、まずそもそもいくつか型の初期化の方法があるらしくて、それを整理しておく必要がありそう。

このセクションに詳しい話が載っている。以下は私が理解した内容を簡単にメモしたもの。
https://docs.modular.com/mojo/programming-manual.html#value-lifecycle-birth-life-and-death-of-a-value

まず Mojo の型には copyable と movable、そのどちらでもない概念が存在するらしい。

  • Non-copyable and non-movable: メモリアドレスが重要な型を定義する際に使用する。たとえばアトミック操作が必要なものなど。後述する __moveinit__ と __copyinit__ のどちらも定義されていない場合にこの型として判別されるらしい。
  • movable: いわゆるムーブする型。__moveinit__ を定義するとこれになる。
  • copyable: 文字通りコピーできる型だと思うけど、裏でどういう処理になっているのかはよくわからない。Python だとオブジェクトは全部参照でコピー可能だけれど、裏で参照カウントで管理されている。それと同じようなことを裏で行う可能性はあり。__copyinit__ を定義するとこれになる。

ベースのアイディアは Swift の作者なだけあって、Swift の value semantics と ARC あたりをこちらにも持ってきたとかなのかな?この辺りは正直処理系を実際に使ったり見たりしないとまだ詳細はわからない。

今回おもしろかったのが、たとえば move と copy を両方可能にした型を下記のように定義した場合

struct MyString:
    var data: Pointer[Int8]

    # StringRef is a pointer + length and works with StringLiteral.
    def __init__(self&, input: StringRef):
        self.data = ...

    # Copy the string by deep copying the underlying malloc'd data.
    def __copyinit__(self&, existing: Self):
        self.data = strdup(existing.data)

    # This isn't required, but optimizes unneeded copies.
    def __moveinit__(self&, owned existing: Self):
        self.data = existing.data

    def __del__(owned self):
        free(self.data.address)

    def __add__(self, rhs: MyString) -> MyString: ...

ムーブとコピーを明示的に下記のように選択できる点だと思う。

fn test_my_string():
    var s1 = MyString("hello ")

    var s2 = s1    # s2.__copyinit__(s1) runs here

    print(s1)

    var s3 = s1^   # s3.__moveinit__(s1) runs here

    print(s2)
    # s2.__del__() runs here
    print(s3)
    # s3.__del__() runs here

私の思う Rust の不満のひとつに、コピーとムーブの指定方法がまったく同じで、最初のうちはコピーの側の存在に気づきにくいと感じる点にあった[1]。このように区別をつけておくとどこでムーブをしたのかが一目瞭然でわかりやすいように感じる。Mojo は Python との互換性の問題からムーブセマンティクス指向な言語ではなさそう(というかできなそう)だから、コピーがデフォルトでムーブが ^ を使った特別操作を要求するのは納得。

脚注
  1. もちろん、Rust はムーブセマンティクスを中心に設計されたプログラミング言語であるため、ムーブがデフォルト動作であるのは問題ではないと思う。が、その観点から見るとコピーは特別操作にあたるため、もう少しわかりやすく区別してもよかったんじゃないかという気もする。 ↩︎

デストラクタ

Rust のムーブに慣れすぎていて最初下記のコードを見て「ん?」と思ってしまった。

fn use_strings():
    var a = MyString("hello a")
    var b = MyString("hello b")
    print(a)
    # a.__del__() runs here


    print(b)
    # b.__del__() runs here

    a = MyString("temporary a")
    # a.__del__() runs here

    other_stuff()

    a = MyString("final a")
    print(a)
    # a.__del__() runs here

other_stuff() を呼び出す直前で a のデストラクタが呼び出されるのがよくわからなかった。ムーブの場合、other_stuff() の直前で消えることはなくて、a を再代入した瞬間に消えそうな感じがする。

が、そもそも Mojo の裏側で行われるのはムーブじゃないっぽい。言うなれば「めっちゃアクティブなガベージコレクター (hyper-active garbage collector)」が走っているみたい。ドキュメントの中ではASAP ポリシーとも呼んでいる。どうやらコンパイラが、ブロック単位や関数呼び出しなどのタイミングでかなり厳密に「そのメモリ領域が不要になった」ことをチェックして、解放するようになっているらしい。強めのフロー解析がかかるように作られているのかもしれない。

print(a) の直後でも a.del() が呼ばれているので、関数内をフロー解析して各ローカル変数が使われなくなった最短位置でデストラクト呼ぶ、という感じなんですかね?

print(a) でムーブが起きるのかと思ってしまっていましたが、起きるならば Mojo 的には print(a^) と書きそうですね。可能な限り早く解放できるタイミングでデストラクタが呼ばれるという挙動を説明したいように確かに見えますね。

ログインするとコメントできます