低レベル最適化とZig
(alloc.dev)- 低レベル最適化とは、コンパイラがコードの意図と制約をよりよく理解できるようにする作業であり、Zigは型・アラインメント・エイリアス・コンパイル時情報を明示しやすく、この目的に適している
- LLVMのような最適化コンパイラも常に最善のコードを生成するわけではないため、ボトルネック箇所では生成コードの確認とコード調整が依然として必要
- Zigは
noalias、align、固定配列サイズ、要素型をコンパイル時点で渡すことで、JavaScriptの例より小さなベクトル化コードを生成できる comptimeは通常のZigコードをコンパイル時点で実行し、定数生成、ジェネリック実装、型リフレクション、文字列比較の最適化といったメタプログラミングを可能にする- Zigの強みはASTを直接書き換えるマクロではなく、言語に統合されたコンパイル時実行にあり、一部のランタイム値もコンパイル時特殊化関数にディスパッチできる
コンパイラを信頼しつつ確認すべき理由
- 最適化は単に高速なプログラムを作る技術にとどまらず、コスト削減、スケーラビリティ向上、システムの単純性維持にもつながる
- 最新のコンパイラはLLVMのようなバックエンドで印象的な結果を出すが、一部の状況では依然として下位最適なコードを生成する
- 低レベル言語が速い理由は、ガベージコレクションやインタプリタのオーバーヘッドが少ないからだけでなく、コンパイラが理解できる意図の情報をより多く表現できるからでもある
- コンパイラはアルゴリズムやプログラミングパラダイム自体を変えることはできず、概してループのような限られた範囲で最適化を行う
JavaScriptとZigの配列最大値の例
- JavaScriptの例では
x[i] = y[i] > x[i] ? y[i] : x[i]という形で、2つの配列の要素ごとの最大値をxに保存する - 人間には明快なコードだが、V8が生成したバイトコードは肥大化している
- Zigの例では、関数引数に最適化に必要な情報をより具体的に明示する
noalias x:xは他のポインタとエイリアスしない*align(64): 64バイト境界にアライン[65536]f64: 配列サイズと要素型const: 読み取り専用の引数
- この情報のおかげでコンパイラはより良いコードを生成でき、例ではベクトル化されたアセンブリが生成される
- 同等のRustコードもほぼ同じアセンブリを生成する
Zigが最適化に有利な点と限界
- Zigは冗長な記述を許容し、LLVMにコード情報を多く渡せる
- 最適化に関連してZigが提供する主な要素は次の通り
- Rustのメモリモデルでは、関数引数がエイリアスを作らないとコンパイラが常に仮定できるが、Zigではそれを明示的に指定する必要がある
- コンパイラがZig関数引数の非エイリアス性を把握できない場合、注釈のないZig関数はRust関数より遅くなることがある
- 適切に注釈されたLLVM IRだけを基準にしてもZigは良い結果を出すが、より大きな強みはコンパイル時実行にある
comptimeの役割
- Zigの
comptimeは、コンパイル時のコード生成のための機能 - コンパイル時に可能な作業は次の通り
- 定数を生成してバイナリに含める
- 複数のデータ型に対して同じhashmap構造を繰り返し書かずに済ませる
- コンパイル時に判明しているデータに基づき、不要なコードを削除するよう最適化を促す
- 型を検査・リフレクト・生成してジェネリック実装を行う
comptimeコードはコンパイル時に実行される通常のZigコードであり、ネットワークI/Oのような副作用は持てない- コンパイル時のエミュレーションマシンはコンパイル対象と一致する
- ほぼすべてのZigコードは
comptimeでコンパイル時に実行でき、すべての型はコンパイル時に検査・リフレクト・生成できる
マクロとの違い
comptimeの目的はマクロに近いが、動作方式は異なる- 一部のマクロは生のテキストを書き換え、一部はプログラムのASTを直接変更する
- Zigの
comptimeはASTを直接変更せず、トークン連結マクロのような機能もない - Zigは読みやすい言語を目指しているため、無関係なスコープに変数を作成・変更するようなマクロスタイルとは相性がよくない
- マクロならできてもZigの
comptimeでは直接できない作業は次の通り- 別のマクロ定義
- ASTの変更
- ミニ言語やDSLの直接実装
- ただしZigでもDSLは作成でき、Zigの
print関数はcomptimeでフォーマット文字列を解析して、データを直列化する関数グラフを構築する - 例として、TigerBeetle account testing DSL、comath、zilliamがある
comptimeによる文字列比較最適化
- 一般的な文字列比較は、長さが異なれば
falseを返し、長さが同じなら各バイトを順番に比較する - この方式では、2つの文字列からそれぞれバイトを読み出して比較する必要がある
- 片方の文字列がコンパイル時に既知であることが多いため、Zigでは一方の引数を
comptimeで要求できるfn staticEql(comptime a: []const u8, b: []const u8) bool
"Hello!\n"のような静的文字列と比較するとき、コンパイラは長さ比較と各バイトの定数比較で構成されたコードを生成する- このセクションの目的は、コンパイラが自動で行える最適化を示すだけでなく、
comptimeへの変換を強制することで、コンパイラが見逃す機会を開けることを示す点にもある
より大きな単位での比較とSIMD活用
- 単純な
comptime文字列比較でも、依然としてバイト単位の比較を行う - 改善版では
std.simd.suggestVectorLength(u8)または@sizeOf(usize)を使って比較ブロックサイズを決める - 文字列長を先に確認したうえで、比較可能な大きなブロック数と余りのバイト数を計算する
- 各ブロックは
std.meta.Int(.unsigned, block_len * 8)で作った整数型に@bitCastして比較する - 残ったバイトも別の整数型で比較する
"Hello, World!\n"の例で生成されるアセンブリは、より大きなレジスタを使い、条件分岐の数を減らす- より長い文字列比較では、より大きなSIMDレジスタを使うアセンブリが生成される
ランタイム値とコンパイル時特殊化の併用
- Zigの
comptimeは、コンパイル時に分かっているデータだけに限定されない - 単純なケースでは、複数の手続きをコンパイル時に生成しておき、ランタイム値に応じて適切な手続きへ動的ディスパッチできる
- 例のコードでは
switch (runtime_val)でinline 0...100範囲の値をstaticFn(comptime_val)へ送り、それ以外はruntimeFn(runtime_val)で処理する - バイナリサイズの増加を望まないなら、完全なランタイム実装へフォールバックできる
結論
- Zigの
comptimeは、テンプレート、マクロ、ジェネリクス、手動コード生成を置き換える役割を果たす - 他の言語でも似たことはできるが、Zigでは
comptimeが言語により自然に統合されている - Zigは、実際に有用な状況で高性能なコードを書きやすくし、何でもできるが興味深いことは難しいTuring tar-pitとの対比をなしている
- 言語戦争については、チューリング完全性だけで十分だという大きな見方と、同時に人々が好みの言語を持てるという立場の両方が残る
- 「CはPythonより速い」のように言語自体をベンチマーク対象とみなす言い方は誤りになりうる。実際にベンチマークされるのは言語ではなく、特定のコードと実装である
1件のコメント
Hacker Newsのコメント
Zigで最も魅力を感じるのは、ビルドシステムの容易さ、クロスコンパイル、そして高速な反復開発を目指している点
自分はゲーム開発者なので性能要件はあるが、ほとんどの言語は必要十分な性能を出せると考えているので、言語選定の最優先事項ではない
どんな言語でも強力なコードは書けるが、重要なのは数十年にわたってモジュール式コードを維持できる将来対応性の高いフレームワークを選ぶこと
C/C++はどこでもサポートされるという理由で基本的な答えだったし、Zigもその水準に到達できそうに感じる
Zigはカプセル化に敵対的で、構造体メンバーを非公開にできない: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
核心となる引用は、「非公開フィールドとgetter/setterはJavaが広めたアンチパターンであり、フィールドは存在するデータなのだから慎重に命名し、公開APIの一部として文書化すべきだ」という立場
内部表現を隠せなければ、ソフトウェアモジュール性の基盤であるAPI契約を適切に作るのは難しく、利用者コードを壊さずに内部表現を変更できるべき
Zigの立場は、内部表現というものを別に持つべきではなく、表現そのものを公開・文書化・保証すべきだという方向に見え、いつかこの決定を見直して非公開フィールドをサポートしてほしい
多くのものがそのまま動作し、古いGDBで奇妙なバグまでデバッグできた
自分もZigに納得したし、関連してここに書いた: https://news.ycombinator.com/item?id=44211041
なぜそこまで嫌われるのかよく分からない
見た目のよくないジェネリクスはC#やTypeScriptにもあるし、借用チェッカーは低レベル作業をしたことがあるなら納得できる概念だ
Zigの上に構築されたツールで本当に感心したのはbunで、bunを使ってから生活がずっと単純になった
Rust製のuvにも同じようなことが言える
普通、コンソールはC/C++以外を嫌うが、ZigはCに変換できるので、完全に排除されないかもしれない
「最新のコンパイラでさえ言語仕様を破る(Clangは副作用のないすべてのループが終了すると仮定する)」という主張については、コンパイラが時々仕様を破ること自体は疑わないが、この場合Clangは少なくともC11以降の基準では正しい
C11には、制御式が定数式ではなく、入出力・volatileアクセス・同期・原子操作を行わない反復文については、実装が終了すると仮定してよいと書かれている
そのためCの単純な無限ループ
for (;;);は実際に無限ループとしてコンパイルされるべきで、Rustのより明示的なloop {}も同様であるべきところがLLVMには、自分たちが常にC++コンパイラを作っているわけではないことを忘れた人たちが作ったせいで、Rustが「無限ループをください」と言うとLLVMが「C++によればそんなものはないので最適化する」としてしまう箇所があり、それは他の言語への誤った適用だった
文字列比較をインライン化して展開するために、必ずしもcomptimeは必要ではない
Cでも可能: https://godbolt.org/z/6edWbqnfT
タイポは修正した
より良い例は https://github.com/RetroDev256/comptime_suffix_automaton
ただし、リンク先のgodboltコードは実際にはあまり良くない2つの例のうちの1つも示している
JavaScript の例と Zig/Rust の例を比較するのは、あまり良い比較ではないと思う
Zig と Rust のコンパイラでは非常にモダンな対象 CPU を選ぶようにしているのに、V8 は同じ条件ではないように見える
最適化 JIT も、条件が合えばベクトル化できる
ちなみに、ほとんどの現代言語は文字列に対して同じ最適化を行っており、C++ の例はここにある: https://godbolt.org/z/TM5qdbTqh
Zig の例は固定サイズの既知の型配列を使っている一方、JS のコードは実行時には「ジェネリック」で、
xとyはどんなオブジェクトでもあり得るJS ではそのコストを払うのは当然だが、皮肉なことにこの特定の例では JIT に型情報をよりうまく伝えられる
常に同じサイズの Float64Array でこの関数を呼ぶようにすれば、JIT はそれを把握してより高速なループを生成できる。ベクトル化ではなくても、かなり改善される
ただし型付き配列は初期化コストが高いため、大きな型付き配列を一度確保して何度も再利用するのでなければ、実際にはあまり使われない
また記事では JS バイトコードがかなり膨らんだとしていたが、おそらく JIT が
65536が 2 つの配列長と等しいことを保証できず、ガードを挿入する部分が大きいのだと思うそれでも実際には誰もそのように
forループは書かず、i < x.lengthと書くだろうし、この場合 JIT は少なくとも配列チェックを 1 つは削除するtargetをもっと古い CPU に変更できるJS 対象の制約を考慮していなかったのは申し訳ない
リンクされた C++ の例は、Clang が C++ で何をできるかを示す良い例だが、Zig が特定の CPU を対象にコンパイルしている点を考慮しても、生成されたアセンブリはやや物足りなく見える
https://github.com/RetroDev256/comptime_suffix_automaton の C++ ポートを見ると、とても興味深いと思う
これは C++ コンパイラではきれいに推論できない comptime の活用である
「高水準言語には低水準言語に豊富にある意図が欠けている」という文が本当に正しいのかは分からない
意図の表現は高水準/低水準スペクトラムの要素ではないように思えるし、むしろ意図をより詳しく表現する手段が多いほど、より高水準に近いはずである
何を達成したいかというより、機械に何をさせたいのかに関する意図である
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;のようなコードは意図に満ちているが、最終的にどのような機械語が出るかは分からないあの
forループ構文はひどいリストが 2 つ並んでいて、片方のリストの項目位置がもう片方のリストの項目位置に対応しているということか?
見ているだけで目が痛い
現代の言語は、パーサにさまざまな「魔法」を追加し、コードのあちこちに小さな記号をばらまき始めてから、間違った方向に進んでいるように思う
何時間も眺めていたい形ではない
だからこそ Zig が、まさにその作業をやりやすくしつつ、何が起きているかを明確に見せる構文を提供しているのは自然である
個人的にはかなりうまくできていると思うが、なぜ目が痛いのかは気になる
Zig の アロケータモデル が本当に気に入っている
Go でもガベージコレクションの代わりに、リクエスト単位のアロケータのようなものが使えればいいのにと思う
言語自体に所有権ルールを表現して強制する方法がないため、結局は文法が少し違う C を書いて、うまくいくことを祈るようなものになる
ガベージコレクションがないという条件では、C++ ですら Go よりはるかに安全である
Rust のメモリモデルでは、コンパイラは関数引数が決して エイリアス参照 しないと常に仮定できるが、Zig ではこれを手動で指定しなければならない
このようなエイリアス指定は避けている
理解している人が少なく、間違って使うとコードに理解しにくいバグが入り込む可能性があるからである
Zig の comptime の柔軟性が他言語に良い改善をもたらしたという主張については、コンパイル時関数実行 と定数引数を受け取る関数は D が 2007 年に導入しており、複数の言語が似た仕組みを採用するきっかけになった
https://dlang.org/spec/function.html#interpretation
Zig は好きだが、「Zig の冗長さが良い」という言い方には違和感がある
C がいろいろな面で緩すぎるのは確かだが、現時点の Zig はその反対側にやや振れすぎていて、コメントのような型注記ノイズが多くなりすぎることがある
特に式の中での明示的な整数キャストがそうで、関連してここに少し書いた: https://floooh.github.io/2024/08/24/zig-and-emulators.html
性能面で Zig のコードが似た C コードより速いなら、それはたいてい Zig のより積極的な LLVM 最適化設定によるものだ
たとえば Zig はデフォルトで
-march=nativeと全プログラム最適化を使い、プロジェクト内のすべての Zig コードを 1 つのコンパイル単位としてコンパイルするunreachableを最適化ヒントとして使うようなほぼすべての「トリック」も C で可能で、ただし場合によっては非標準の言語拡張が必要になるC コンパイラ、特に Clang も定数畳み込みを非常に積極的に行い、深い呼び出しスタックがあっても、定数に畳み込める大きなコード領域を縮約できる
そのためコード生成だけを見れば、Zig の comptime と大差ないことも多い
comptime の利点は、静かにランタイムコードへ後退しない点にあり、comptime でないコードも C と同じように定数畳み込み最適化の対象になる
たとえば「純粋な」非 comptime 関数が定数引数で呼ばれれば、コンパイラは依然として関数呼び出しをその結果に置き換える
要するに C コードが Zig コードより遅いなら、C コンパイラの設定を確認すべきだ。結局、最適化の重い仕事はすべて LLVM の下で行われる
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); }同じアセンブリにコンパイルされ、再利用可能で、意図も明確になる
その点には同意する
Virgil は 2006 年から言語全体をコンパイル時に使うことができ、全プログラムコンパイルもサポートしていた
ただし Virgil は LLVM をターゲットにしていないため、速度比較は結局 2 つのコンパイラバックエンドの比較になる
Virgil は、このコンパイルモデルによって可能になる到達可能性解析と特殊化最適化に大きく依存している
たとえばメソッド呼び出しを積極的に脱仮想化し、到達不能なフィールドやオブジェクトを除去し、フィールドやヒープオブジェクトを通じて定数昇格を行い、多相コードを完全に単相化する
AI がより扱いやすくなることだけが理由だ
コーディングに AI を使うのが良い考えか、どの程度まで良いのかは別問題だが、多くの開発者はそう信じており、言語は彼らを受け入れようとするだろう