世界を動かす技術を、日本語で。

Zigによる低レベル最適化

概要

Zig言語 は低レベル最適化に非常に適している点を解説。 comptime によるコンパイル時実行の強力さを実例とともに紹介。 最適化 の重要性とコンパイラを信頼する際の注意点を整理。 高水準言語 と低水準言語のパフォーマンス差の本質を説明。 comptime とマクロの違い、Zigの設計上の特徴にも言及。

Zigによる最適化の魅力

  • 最適化 は現代でも重要なテーマであり、パフォーマンス向上やコスト削減、システムのシンプル化に寄与。
  • Zig は低レベルな意図を明示的に記述できるため、コンパイラが最適な変換を施しやすい特徴。
  • 高水準言語 は抽象化のために意図が曖昧になりがちで、コンパイラが最適化しづらい傾向。
  • 低水準言語 は型やアラインメント、エイリアス情報などを明示でき、より効率的なコード生成が可能。
  • Zig はRustと同等レベルのアセンブリを生成できるが、アノテーションがないとRustのほうが有利な場合も。

コンパイラへの信頼と限界

  • コンパイラ最適化 は進化しているものの、常に最良のコードを生成するとは限らない。
  • LLVM などのバックエンドは強力だが、開発者が意図を明確に伝えることでさらに最適化できる余地。
  • 最適化のためには、コードを調整したり、場合によってはインラインアセンブリを使う必要も。

Zigのcomptimeとは

  • comptime はZigのコンパイル時コード実行機能で、定数生成や型生成、条件分岐などが実現可能。
  • comptime はメタプログラミングの一種で、C++のconstexprやRustのマクロよりも直感的に使える設計。
  • comptime はほぼ全てのZigコードをコンパイル時に実行でき、型のリフレクションやジェネリクスもサポート。
  • comptime はASTの直接操作やトークンペーストのようなマクロ的機能は持たないが、DSL構築などは可能。

comptimeとマクロの違い

  • comptime は通常コードとして記述・実行されるため、言語仕様に馴染みやすい。
  • マクロ はASTやソースコードそのものを操作できるが、可読性や保守性に課題。
  • Zig は可読性重視で、マクロのようなスコープ外変数生成やトークンペーストは意図的に排除。

comptimeによる最適化例

  • comptime を使うことで、例えば文字列比較関数の最適化が可能。
    • 一方の文字列がコンパイル時に既知の場合、より効率的なアセンブリコード生成が可能。
    • SIMDやブロック比較による高速化もcomptime情報を活用して実現。
  • comptime の利用例
    • DSLの構築(print関数のフォーマット解析など)

    • 完全ハッシュ関数生成

    • ASTパーサの構築

    • 参考リソース

      • String Matching based on Compile Time Perfect Hashing in Zig - Andrew Kelley
      • What is Zig's Comptime? - Loris Cro
      • Things Zig comptime Won’t Do - matklad
      • Zig Comptime - WTF is Comptime (and Inline) - Ed Yu
      • Comptime - Zig Language Reference
      • Comptime - zig.guide

まとめ:Zigでの最適化の新しい可能性

  • Zig は低レベルな最適化を追求する開発者にとって、強力な武器となる言語。
  • comptime の直感的な記述とパワフルな最適化能力は、C++やRustの既存手法にない体験を提供。
  • パフォーマンス重視 のソフトウェア開発において、Zigは新しい選択肢として注目。

Hackerたちの意見

Zigの冗長さが好きなんだ。私もZigが好きだけど、これはちょっとおかしい気がする :) 例えば、Cは確かにいろんなところで雑すぎるけど、Zigは(今のところ)逆に行き過ぎて、特に数学の式での明示的な整数キャストに関して「注釈ノイズ」が多すぎるかもね(これについてはここに少し書いたよ: https://floooh.github.io/2024/08/24/zig-and-emulators.html)。パフォーマンスに関して言えば、私の経験ではZigのコードが似たようなCのコードより速いのは、ZigのLLVM最適化設定がより攻撃的だからだと思う(例えば、Zigはデフォルトで-march=nativeでコンパイルし、全プログラム最適化を行うから、プロジェクト内のすべてのZigコードが一つのコンパイルユニットとしてコンパイルされる)。Cでも「到達不能」を最適化ヒントとして使う「トリック」はほとんど可能だけど、時には非標準の言語拡張を使わないといけないこともある。Cのコンパイラ(特にClang)は定数折りたたみにも非常に攻撃的で、深いコールスタックがあっても大きな定数折りたたみ可能なコードの部分を減らすことができるから、最終的にはコード生成に関してZigのコンパイル時間とあまり違いがないことが多いんだ(コンパイル時間の良いところは、もちろんランタイムコードに静かにフォールバックしないことだし、非コンパイル時間のコードもCと同じ定数折りたたみ最適化の影響を受けるからね。例えば、「純粋な」非コンパイル時間関数が定数引数で呼ばれた場合、コンパイラはその関数呼び出しを結果で置き換える)。要するに、CのコードがZigのコードより遅いなら、Cコンパイラの設定を確認してみて。結局、最適化の重労働はLLVMの中で行われてるからね :)

ああ、ちょっと説明が必要かもね。Zigのノイズが好きなわけじゃなくて、自分の意図やコードの詳細をはっきり表現できるところが好きなんだ。算術に関しては、今のところちょっと冗長すぎると思う。https://github.com/ziglang/zig/issues/3806の何らかのバリエーションがこれを解決してくれるといいな。あなたのTL;DRには完全に同意するけど、Zigではビルトインや到達不能が言語に組み込まれているから、同じ最適化を得るのが簡単だと思う。gccやllvmの内蔵関数(例えば__builtin_unreachable())が必要ないからね - https://gcc.gnu.org/onlinedocs/gcc-4.5.0/gcc/Other-Builtins.... LLVMがさらに改善されて、ポジティブな最適化変換を有効にするために追加の注釈が必要なくなることが私の夢なんだ。でもその時、低レベル言語を使う意味って本当にあるのかな?

新しいx86バックエンドがあれば、CとZigの間でパフォーマンスの違いが見られるかもしれないね。それがZigプロジェクトだけに起因するものだとしたら、確実に。

キャストの例についてだけど、キャストを関数でラップすることもできるよ: fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); } export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); } これで同じアセンブリにコンパイルされるし、再利用もできるし、意図もはっきりするよ。

Zigの冗長さには許容があるのに、Goにはないのはなんでだろうね。鶏と卵の話みたいなもんだよ。

明示的な整数キャストについては、近々クリーンアップが行われるみたいだよ: https://ziggit.dev/t/short-math-notation-casting-clarity-of-...

zigには面白いアイデアがあるし、記事はもっと低レベルの最適化についてだと思ったけど、実際は「コンパイル時と全プログラムコンパイルが素晴らしい」って内容だったね。俺も同意するよ。Virgilは2006年からコンパイル時にフル言語が使えるし、全プログラムコンパイルもできる。でもVirgilはLLVMをターゲットにしてないから、速度比較は二つのコンパイラバックエンドの比較になっちゃう。Virgilはコンパイルモデルによって可能になる到達性や特化の最適化に大きく依存してるんだ。例えば、メソッド呼び出しを積極的にデバーチャル化したり、到達不可能なフィールドやオブジェクトを削除したり、フィールドやヒープオブジェクトを通じて定数昇格を行ったり、ポリモーフィックコードを完全にモノモーフィックにしたりするんだ。

Zigのアロケーターのモデルが大好きなんだ。GoでGCの代わりにリクエストアロケーターみたいなものが使えたらいいのに。

Goでもカスタムアロケーターやアリーナは可能だけど、使いにくくてちゃんと使うのが難しいんだ。言語自体が所有権ルールを表現したり強制したりする方法がないから、結局は少し違う構文のCを書いて、うまくいくことを願うだけになっちゃう。C++の方がGCなしでGoよりもずっと安全だよ。

例えば、次のJavaScriptコードを考えてみて…このJavaScript(V8の下で)の生成されたバイトコードはかなり膨れ上がってると思う。これが良い比較だとは思わない。ZigやRustのコンパイラに、ターゲットとして非常にモダンなものを選ぶように言っているけど、V8は同じことをしているとは思わない。最適化JITは、条件が許せばベクトル化する方法を実際に知っているんだ。それに、参考までに、ほとんどのモダンな言語は文字列に対してあなたがやっているのと同じ最適化を行うよ。例えばC++ではこうだよ: https://godbolt.org/z/TM5qdbTqh

RustとZigのあの2つのGodboltの例で、targetを古いCPUに変更できるよ。JSのターゲットの制限について考えなかったのはごめんね。君のリンクはC++におけるclangの良い例だと思うけど、生成されたアセンブリがイマイチかもしれないね。特定のCPU向けにZigがコンパイルしても、やっぱりそうなると思う。https://github.com/RetroDev256/comptime_suffix_automatonのC++ポートが見てみたいな。これはC++コンパイラではきれいに推測できないコンパイル時の使い方だよね。

一般的には、JSとZigの異なる使い方を強調するには適切な比較だけど、ちょっと果物サラダみたいな感じだね。Zigの例は固定サイズの既知の型の配列を使ってるけど、JSのコードは実行時に「ジェネリック」なんだ(xとyはどんなオブジェクトでもOK)。それは確かにJSではコストがかかる部分だね。皮肉なことに、この特定の例では、JITに型情報を伝えるのが実際にはもっと良くできるんだよね。常に同じサイズのFloat64Arraysでこの関数を呼ぶことを保証すれば、JITはそれを知って速いループを生成するから(ベクトル化はされないけど、かなり良くなる)。実際には型付き配列は初期化が重いからあまり使わないけど、大きな型付き配列を一度割り当ててその後再利用するなら価値があるよね。だから、まあ、わかる!もう一つ気になる点があるんだけど、記事では例のJSコードがかなり膨れ上がってるって言ってるけど、実際にはJSのJITが65536が2つの配列の長さと等しいことを保証できないから、ガードを挿入する可能性が高いと思うんだ。誰もそんなふうにforループを書くことはないだろうし、iとして書くから、JITは少なくとも1つの配列チェックを最適化するんだよね。まあ、これはちょっと細かいことを言ってるけど。

文字列比較をインライン化したり展開したりするのにコンパイル時間は本当に必要ないよ。これもCでできるからね: https://godbolt.org/z/6edWbqnfT(編集: タイプミスを修正した)

そうだね、君の言う通り!最初の例はちょっと単純すぎたかも。もっと良い例はhttps://github.com/RetroDev256/comptime_suffix_automatonだね。ただ、君がリンクしたGodboltのコードは実際には2つのイマイチな例の1つを示してるよ。

最適化はめっちゃ大事だよ。時間が経つにつれてその効果は増していくからね。

ソフトウェアが実際に使われる場合だけだね。

実際、最新のコンパイラでも言語仕様を破ることがある(Clangは副作用のないループは全て終了する前提で動作する)。コンパイラが時々言語仕様を破ることは疑わないけど、その場合ClangはC11以降に関しては正しいと思う。C11からの引用: > 制御式が定数式でなく、入出力操作を行わず、volatileオブジェクトにアクセスせず、ボディ内や制御式、(for文の場合は)式-3で同期や原子操作を行わない反復文は、実装によって終了することが想定される。

C++は(未来のC++26が公開されるまで)全てのループについて言ってるけど、君が指摘したようにC自体はそうではなく、「制御式が定数式でないもの」だけだね。だからCでは単純な無限ループfor (;;);は実際には無限ループとしてコンパイルされるべきなんだ。Rustのより不透明なループ{}と同じようにね。ただ、LLVMはC++コンパイラを書いているわけではないことを忘れがちな人たちによって作られているから、Rustは「無限ループお願い」と言ったところで、LLVMは「おっと、C++ではそんなの起こらないから最適化するよ」と言っちゃうんだよね。でも、それは間違った言語なんだけど。

zigで一番興味深いのは、ビルドシステムの簡単さ、クロスコンパイル、そして高速なイテレーションの目標だね。俺はゲーム開発者だから、パフォーマンスに関しては要求があるけど、ほとんどの言語は俺の要求に対して十分なパフォーマンスを持ってるから、言語選びの一番の考慮事項ではないんだ。どの言語でも強力なコードが書ける気がするけど、目標は将来的にも保守しやすいモジュラーなコードを書くことだから、数十年にわたってメンテできるようにしたいんだ。C/C++はその普遍的なサポートからデフォルトの答えだったけど、zigもそれに匹敵すると思う。

最近、面白半分で、古いkindleデバイスでLinux 4.1.15を動かしてzigを試してみたんだ。面白い体験だったし、Zigの成熟度に驚いたよ。多くのことがすぐに動いて、古いGDBを使って変なバグをデバッグすることもできた。君と同じように、俺もZigにハマってるよ。ここに書いたんだ:https://news.ycombinator.com/item?id=44211041

Rustにちょっと手を出してみたけど、好きだったし、悪いって聞いたから一時中断したんだ。今また試してみて、やっぱり好きだな。なんでみんなそんなに嫌うのかよくわからない。醜いジェネリクス?C#やTypeScriptでも同じことだし。借用チェッカー?低レベルなことをやったことがあるなら納得できると思うけど。

zigがコンソールでどう動くのか気になるな。普通、コンソールはC/C++以外のものを嫌うけど、zigはCにトランスパイルできるから、完全に排除されるわけじゃないかも?

どんな言語でもパワフルなコードが書ける気がするけど、目標は将来的にも保守しやすいフレームワークのためのコードを書くことなんだ。数十年にわたってモジュール化されたものを維持できるようにね。Zigはすごく好きだけど、長期的な保守性とモジュール性は、個人的には弱点の一つだと思う。Zigはカプセル化に対して敵対的なんだ。構造体のメンバーをプライベートにすることはできないしね。 「プライベートフィールドやゲッター/セッターメソッドのアイデアはJavaによって広まったけど、それはアンチパターンだ。フィールドはそこに存在している。抽象化を支えるデータなんだから。」 「私のおすすめは、フィールドの名前を慎重に付けて、それをパブリックAPIの一部として残し、何をするかをしっかり文書化することだ。内部表現を隠せない限り、API契約(ソフトウェアのモジュール性の基盤)は合理的に形成できない。ユーザーを壊さずに内部表現を変更できる必要がある。」 「Zigの立場は、内部表現なんて存在すべきではなく、すべてのユーザーに対して公開し、文書化し、振る舞いを保証すべきだということだ。いつかZigがこの決定を覆して、プライベートフィールドをサポートしてくれることを願っている。」

ZigはシンプルなRustであり、より良いGoのように見えるね。話が逸れるけど、Zigの上に構築されたツールで、私が本当に尊敬しているのはbunなんだ。bunを使った後、どれだけ生活が楽になったかは言葉では表せないよ。同じことはRustで作られたuvにも言えるね。

あのforループの構文はひどいね。二つのリストが並んでいて、一方のリストのアイテムの位置がもう一方のリストのアイテムの位置と一致するって?目が痛くなるよ。現代の言語は、パーサーに「魔法」を追加したり、コードのあちこちに小さなシジルを散りばめたりして、間違った方向に進んだと思う。これを何時間も見続けたいとは思わないな。

高水準言語は低水準言語が豊富に持っている「意図」が欠けている。 この言葉は本当に正しいのかな?意図を表現することは、高水準と低水準のスペクトラムにおいてあまり関係ない気がする。むしろ、意図をより詳細に表現する方法が増えることで、より高水準になるんじゃないかな。

同意するよ、さらに言うと、高級言語と低級言語の根本的な違いは、高級言語では意図を表現できるのに対して、低級言語では基盤のメカニズムを表現するしかないってことだね。