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

Zigによる低レベル最適化

2025年6月7日原文(alloc.dev)

概要

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つの配列チェックを最適化するんだよね。まあ、これはちょっと細かいことを言ってるけど。

Hacker Newsで議論の続きを見る