- Rustの現在の
extern "Rust" 呼び出し規約はLLVMのC呼び出し規約パスに依存しており、複雑な値の受け渡しでレジスタ活用が保守的なため、より良いコード生成を取りこぼしている
- クレート単位フラグ
-Zcallconv で現在方式の legacy と、新しいレジスタ中心方式の fast を分け、最適化ビルドでより攻めたABIを使う構想が中核となる
- LLVMに新しい呼び出し規約を直接追加しなくても、固定されたLLVM関数シグネチャと
poison 値で未使用レジスタ引数をコストなしで空けておき、引数配置を制御できる
- 構造体、enum、union、
bool、Result のようなRust型は、パディングを除いた有効サイズ、平坦化、ビットパッキング、スタック/レジスタ分割ヒューリスティックによって、より高密度に受け渡しできる
- 関数本体、borrow checker情報、プロファイル情報をABI決定に反映すれば、さらに強い最適化が可能だが、rustcのABIコード生成の複雑さとLLVM専門知識の不足が現実的な障壁として残っている
Rustが現在取りこぼしている呼び出し規約最適化
- 呼び出し規約(calling convention) は、関数引数と戻り値をどう渡すか、どのレジスタを使うか、プロローグ/エピローグやアンワインドをどう処理するかを定めるABIの一部である
- Rustは独自に未規定の呼び出し規約を定義しているが、実際にはLLVM内蔵の C呼び出し規約 に落とし込まれ、LLVMのプロローグ/エピローグ生成に依存している
- rustcはClangが生成しそうなLLVM関数シグネチャを作ろうとして保守的に振る舞う
- デバッガが壊れる可能性を減らせる
- Clangがあまり使わないABIコード生成経路でLLVMのバグを踏む可能性を下げられる
- ELFベースのシステムではDWARFがLinux C ABIを固定していないため、この記事の範囲ではデバッグ可能性を中心的な問題とは見なさない
- 単純な例として
fn extract(arr: [i32; 3]) -> i32 では、12バイト配列がレジスタではなくポインタで渡される
extern "C" を付けると、同じ [i32; 3] が rdi, rsi にpackedされて渡される
- Rustのデフォルト経路がLinux C ABIよりさらに保守的な例である
-Zcallconv: legacyとfastを分ける方式
extern "Rust" の現在の呼び出し規約は維持しつつ、クレートのコンパイルフラグ -Zcallconv で使用する呼び出し規約を選ぶ
-Zcallconv=legacy: 現在方式
-Zcallconv=fast: 新たに設計するレジスタ中心方式
-O が自動的に -Zcallconv=fast を設定する可能性もある
fast 呼び出し規約は引数をC ABI順に配置しないため、x86の慣用的なレジスタ順を期待する人には混乱のもとになり得る
- WASMのように レジスタ やspillingの概念がないターゲットでは
-Zcallconv=fast がサポートされない可能性がある
- 最適化を切ったdebugビルドでは、
fast がより悪いコードを作る可能性があり、有効化が適切でない場合がある
- 関数ポインタと
extern "Rust" {} ブロックには別の制約が必要になる
- フラグはクレート単位だが、関数ポインタはどの
extern "Rust" バージョンを使うかを表現しにくい
- 関数ポインタ呼び出しは遅く稀な経路と見て
-Zcallconv=legacy を強制できる
- 必要なら呼び出し規約を変換するshimを生成する
- unmangledシンボルを呼び出せる経路があるため、
#[no_mangle] シンボルにもlegacy呼び出し規約を使わせられる
LLVMを迂回的に操る方法
- 理想的にはLLVMに「この引数はこのレジスタ、この戻り値はあのレジスタ」のように直接呼び出し規約を指定したいが、LLVMに呼び出し規約を追加するには大量のC++コードを書く必要がある
- 代わりに、次の手順で独自呼び出し規約に近い効果を得られる
- ターゲットtripleごとに、レジスタで渡せる値の最大数を決める
- 戻り値が出力レジスタに入るか、それとも
sret 属性付きの追加 ptr 引数によるby-reference返却にするかを決める
- 大きすぎるby-value引数はby-referenceに落とす
- どの引数をレジスタに送るかを決めて レジスタ空間使用率 を最大化する
- 残りの引数はスタックに置く
- LLVM IR関数シグネチャは
i64, ptr, double, <2 x i64> のようなnon-aggregate引数で構成する
- 関数プロローグでレジスタ入力をRustレベルの引数にデコードする
- 関数終了ブロックで戻り値を必要な出力形式にエンコードしてから
ret する
- アドレスが取れるnon-polymorphicかつnon-inline関数にはlegacy shimを作って関数ポインタ同一性を保つ
- どの値をレジスタに入れるかを決める問題は ナップサック問題(knapsack problem) に似ていてNP-hardであり、実装にはヒューリスティックが必要になる
- この情報は遅い段階で計算し直さず
rmeta に入れて再計算を避けられる
- RustはリリースごとにABIが壊れるため、異なるRustコンパイラが生成したコード同士をリンクできないようにする条件は、すでに現状と整合している
LLVMが許すレジスタ渡しの限界
- LLVMはaggregateなby-value引数を関数に渡すとき、可能な限り多くをレジスタへ“explode”しようとする
- x86では、LLVMがレジスタで渡せる入力はおおむね次の通りである
- 整数6個
- SSEベクタ8個
- 戻り値はその半分の整数3個とベクタ4個
aarch64-unknown-linux では入力・出力ともに整数8個、ベクタ8個が可能である
- x86のすべての
-Zcallconv=fast 関数が同じ数のby-register引数を持つように設計できる
- 整数レジスタ用引数6個
xmm0 から xmm7 までベクタ引数8個
- 実際にポインタを渡すときは対応する
i64 を ptr に置き換える
double を渡すときは <2 x i64> の枠を置き換える
- ほとんどの関数が176バイトも渡さないとしても、未使用引数に LLVM
poison を渡せば追加コストを避けられる
- LLVMは
poison をその時点で最も都合のよい値と見なせる
- レジスタ引数として
poison が渡されると、「すでにそのレジスタに入っていた値」のように扱えるため、レジスタを触る必要がない
- 例では
load_rcx() がポインタを rcx で受け取り、残り13個のレジスタに poison を積んでも、最適化後は何のコードも生成しない
- この方法によって引数受け渡しをほぼ完全に制御できるが、入力と出力に同じレジスタを使うのが理想かどうかはアーキテクチャごとに異なる
- ARMとRISC-Vは入力と出力に同じレジスタを使う構造に近い
- x86はそうではないが、レジスタ割り当て順を違う前提にすることで不要なレジスタ移動を減らせる
Rust型をレジスタにより適合させる
- Rustの構造体やunionを扱う際には、rustcがすでにユーザー型を基本aggregateやunionとして扱っているとみなし、どの部分をレジスタに置くかを決める
- 戻り値では、構造体の総サイズより パディングを除いた有効サイズ の方が重要である
[(u64, u32); 2] は全体で32バイトだが、8バイトがパディングである
(u64, u32, u64, u32) に平坦化し、サイズ順に (u64, u64, u32, u32) と並べ替えると24バイトになる
- x86の整数戻り値レジスタ3個に収まる
- 有効サイズはnon-
undefビット数として定義される
[(u64, u32); 2] は192ビット
bool は1ビット
char は技術的には21ビットだが、単純化のため u32 の別名のように扱う
bool が多い構造体は、複数の bool を1レジスタに ビットパッキング して返せる
- 引数側はより難しく、次のようなヒューリスティックを適用できる
- 有効サイズがby-register入力空間全体より大きい引数はby-referenceに落とす
- x86基準で入力空間全体は176バイト、1408ビットである
- enumはdiscriminantとunionの組に変換する
Option<i32> は内部的に (union { i32, () }, i1) のように見なせる
Option<Option<i32>> は (union { i32, (), () }, i2) のように見なせる
- unionは未初期化ビットを任意に触れられるため、通常は
u8 配列のように渡す
- non-empty variantが1つしかないunionはそのvariantで置き換える
- 変換後の引数はポインタ、整数、float、boolのようなprimitiveに平坦化する
u128, f64 のように小さな引数レジスタより大きいフィールドは分割できる
- primitiveのリストを有効サイズ順に並べ、レジスタに入る最大のprefixを選ぶ
- 残りはスタックに置く
- スタックに行く部分がポインタサイズの小さな倍数より大きければ、メモリトラフィック削減のためpointer-on-the-stackに落とす
- レジスタ渡しの値は大きいものから配置し、
bool は1レジスタあたり最大64個までビットパッキングする
複雑なRust関数の例と現在のrustcの限界
Option<usize>, &dyn Context, &str, [char; 6], Options 構造体を受け取る do_thing の例では、平坦化と並べ替えの後、rawなLLVM引数はすべてレジスタに収められる
- 例のraw argument LLVM型は次のようになる
gprs: i64, ptr, ptr, ptr, i64, i32, i32
xmm0: i32, i32, i32, i32
xmm1: i32, i1, i1, i1, i1
- 関数プロローグはprimitiveを取り出した後、Rustレベルの値へ再構成する
Option<usize> は { i64, i1 }
- trait objectは
{ ptr, ptr }
&str は { ptr, i64 }
[char; 6] は [6 x i32]
Options は { i32, i1, i1, i1 }
- 引数値を実際にmaterializeする命令に
!dbg メタデータ を付ければ、gdbが引数値を表示するときにより良い結果が得られる
- 現在のrustcは同じ関数に対してLLVMへポインタサイズのパラメータを8個渡しており、その結果、整数レジスタ6個を使い切ったうえで値2個をスタック渡しにしている
戻り値とResultの最適化余地
- この設計が可能な呼び出し規約最適化をすべて扱っているわけではない
- 場合によってはx86のAVXレジスタのような追加レジスタを使える
- 構造体をレジスタとスタックに分割して渡す方法も考えられる
Result の返却には別個の最適化余地がある
? によって複数の関数階層をまたぐと、重複したレジスタ移動が多くなり得る
Result がレジスタに収まらないほど大きい場合、各 ? 呼び出しスタックでok bitをメモリからロードして検査しなければならない
- 代案として、errorはout-parameterポインタに置き、ok variantのpayloadとis-ok bitは
Option<T> として返せる
? が Into 呼び出しを伴う細部の処理は難しいが、実装は可能である
最適化依存ABI
- RustはCと違い、
-Zcallconv=fast で呼び出し側が見るABIを作る際に 関数本体 を参照できる
- クレートは関数ごとに、レジスタ受け渡しの観点で正確なABIを公開できる
- 最も単純な最適化は未使用引数をABIから落とすことだ
- 関数がどのパラメータも使わないなら、その引数にレジスタを割り当てない
&T 引数が保持されずrawポインタにも変換されず、T が小さく T: Freeze なら、参照の代わりにpointee自体をby-valueで渡せる
HashMap::get() のようなAPIが候補になる
- keyが
i32 のような型なら、現在は整数をスタックにspillしてそのポインタを渡す必要がある
- このメモリトラフィックは避けられる
- プロファイルベースABIはさらに攻めた形である
- よりhotな引数をレジスタ割り当て順で優先できる
- 大きな構造体を参照で受け取る場合でも、hotな
i64 フィールド3つを呼び出し側が先にロードして、ポインタとレジスタの両方で渡せる
- calleeはいずれにせよそのloadを行うので、追加コストを負わない
- instrumentation profileは、ABIだけが異なる関数複製を正当化することすらあり得る
なぜまだ実現していないのか
- RustはC++よりABI制約が少なく、より良いコードを生成できる可能性があり、このアイデアは Go register ABI が実際に採用している方式とも通じている
- 最初の障害は ABIコード生成の複雑さ である
- LLVMは有用な制御ノブをほとんど提供していない
- rustc内部でも扱いやすい領域ではない
- 実装を誤ると使い勝手に悪い結果を招き得る
- もう1つの障害は専門知識の不足である
- rustcコントリビュータの中で、LLVMの意味論とコード生成特性を十分理解し、良いコードを出しつつLLVMをクラッシュさせないようにできる人は少数しかいない
- コンパイル時間も負担になり得る
- 関数シグネチャが複雑になるほど、LLVMが処理すべきプロローグ/エピローグコードが増える
- ただし
-Zcallconv は最適化有効時にのみ使う想定なので、決定的な欠点とは見なされていない
- RustのABIコードはbus factorが低い領域であり、LLVM知識はRustコンパイラチームがさらに最適化されたコードを作る助けとして直接活用できる
1件のコメント
Hacker News のコメント
呼び出し規約を最適化する際の核心は、良さそうな形を頭の中で考えることではなく、性能を測定することです。
コードは速くてこそ良いのであって、速そうに見えるから良いわけではありません。
筆者が悪いコードと呼んでいるものが、まったく直感的でない理由で最速になることもあり、それは大規模なベンチマークで測ってみないと分かりません。
悪そうに見える呼び出し規約がうまく機能する理由の一つは、引数レジスタを節約してレジスタアロケータを少し楽にしているためです。
また現在の CPU は C コンパイラが生成する命令の流れに最適化されているため、特に MSVC のようにスタック渡しを意外と頻繁に行う C コンパイラ風のコードを生成すると、CPU の最適なポイントに合うことがあります。
インライン化が非常にうまく働くため、ホットパスでの呼び出しはまれな境界になり、その境界が多少汚くても、他の部分を単純にできるなら問題ありません。
ここでの変更が悪いという意味ではありませんが、奇妙に見えるコードだけを見て、測定なしに論じるのはおかしいです。
JavaScriptCore で呼び出し規約の最適化を仕事にしていましたが、実際の大きなコードでは、悪そうに見えるスタック渡しのコードが勝つことが驚くほど頻繁にありました。
ただし、性能測定の結果だけが唯一の基準であるべきではないと思います。
「現在の」CPU が最適化されているという表現で重要なのは現在という言葉であり、CPU は変わり続けるので、呼び出し規約は長期的な設計であるべきです。
そのため残念ながら、C++ が行っている方式からあまり離れないほうが有利です。今後のプロセッサ最適化もそちらを狙う可能性が高いからです。
同時に、引数レジスタを節約することのように簡単には変わらない一般原則を考慮して、呼び出し規約を堅牢で将来志向のものにするのがよいでしょう。
Rust はここ数年、奇妙さの許容度(https://steveklabnik.com/writing/the-language-strangeness-bu...)という面で保守的になりすぎているように感じるので、こう言うのは少し妙ではあります。結局、違いを出さなければ、より良くはなれません。
関数が開始直後に引数のアドレスを取り、それを未知の関数に渡すなら、いずれにせよスタックへ退避させる必要があります。
関数本体に基づく呼び出し規約最適化を見ると面白そうです。C の static 関数なら、アドレスを取らない限り安全そうです。
JIT はアセンブリを 1 行生成する前から、実際に実行中の CPU に関する情報を多く集めている状態なので、この問題では有利です。
純粋な静的コンパイルコードでは、ランタイムのアーキテクチャ機能セットを知ることができないため、最も最適化したいコードでむしろインライン化の壁に頻繁にぶつかることになります。
現在の Rust は小規模プラットフォームでこの点が弱く見え、呼び出し規約が
Resultの返却に関連して助けになるかもしれません。それでも、スタック渡しの経験的な利点が、レジスタの多い ARMV8 CPU や RISC-V に移っても引き続き当てはまるのかは気になります。
妥当な草案ではあるが、caller-saved/callee-saved の区別が抜けており、入力レジスタの一部を出力に割り当てるというありがちなミスがある。
デバッガが C とは異なる呼び出し規約を理解するだろうという期待も楽観的だ。DWARF が何をエンコードできるかに関係なく、実際にはひどく失敗する可能性が高い。
最適化設定に応じて ABI を変えると、分離コンパイルと非常に相性が悪い。
引数をビンパッキングのように再配置する方式は動くだろうが、コンパイラの複雑さが大きく増し、左から右へ最初に適合する場所へ配置する方式に比べて価値があるのか分からない。開発者が引数の行き先を予測するのも難しくなる。
アドレスが外へ漏れる関数とそうでない関数に別々の呼び出し規約を設けるという大きな方向性は妥当だ。インピーダンスマッチングを行うプロローグを切り出す方式もうまく機能する。
Rust は C とは異なる呼び出し規約を持つ意思があるべきだが、すべての関数が使うハードコードされた単一の規約であるべきかは分からない。型システムに入れるのが自然に見えるし、開発者が呼び出し規約を制御できるようにすると、アセンブリの性能上の利点の一つが失われる。
呼び出し側からすれば、いずれにせよ 2 つの関数呼び出しの間で出力レジスタを空けておく必要があり、システムコール規約でもかなり広く使われている。
呼び出される側が入力値をそのままにして出力値を用意しやすくするためなのかもしれない。そうだとすれば、出力レジスタを入力順の末尾に置いて重なりを避けようとする程度なら理解できるが、どんな重なりも完全に禁止しなければならない理由はよく分からない。
Function AがFunction B、Function C、Function Dを呼ぶような連鎖で、中間の関数の引数を別の規約に変えてオーバーヘッドを減らす最適化が同時に妨げられる。そのような最適化を維持しつつ制御権も許すセマンティクスとは何なのか、実際には幻想にすぎないのではないか疑問だ。
実際、アセンブリはほとんどのコンパイラ最適化の対象ではないため、性能上不利な点がある。「動作を調べて完全に重複していると判断し、丸ごと削除する」ような最適化も受けられない場合が多く、もはや 1990 年代ではない。
ただし、そうした最適化を考慮することすらできない場合なら、インラインアセンブリが確実に劣る場面はプロファイルに基づく最適化くらいだと思う。アプリケーション開発者はコードの動作を完全に知っているが、コンパイラ開発者は知らないからだ。
呼び出しオーバーヘッドは、関連するホットな境界を覆うまでアセンブリをさらに書けば取り除ける。
boolの場合は依存関係の連鎖を作り得る。x64 で
boolたちはまずレジスタに入れ、シフトし、結果に OR するより良い方法が特にないように見える。単純な方式では長さ 64 の依存関係の連鎖を作り、64 サイクルのペナルティが出る可能性があるが、うまくやれば 6 サイクル、現実的には 12 サイクル程度まで減らせるかもしれない。
しかし 64 個の
boolがどこから来るのかも問題だ。レジスタはそんなに多くないので、結局スタックから読み直すことになる。Rust ABI が構造体内の
boolをすでにこのようにぎっしりパックしているなら、いずれにせよ必要な作業だろうが、詳しくは分からない。そして呼び出し側はまた全部展開しなければならない。
コンパイラに値をスタック上の結果領域へ流すよう教える方が簡単で、性能も良い可能性が高い。
だとすれば、値をレジスタに入れることが実際にどれほど役立つのか疑問だ。
C の呼び出し規約はちょっといまいちだ。
C の呼び出し規約を変えられないのは事実だが、だからといって残念さが薄れるわけではない。
利用可能なすべての caller-saved レジスタを引数と戻り値に使うべきなのに、伝統的な SysV ABI では戻り値にレジスタを 1 つ、時には 2 つしか使わない。
struct Point3D { long x, y, z }を返す場合、Point3Dをrax、rdi、rsiに入れられるのに、スタックにスピルする。他のシステムには別の工夫もある。記憶が正しければ、SBCL では関数が複数の値を返すとき、終了時にキャリーフラグを立てる。たとえば
Resultがエラーを含んでいるかを示すのにキャリーフラグを使うと良いのではないかと思う。C の呼び出し規約は、実質的に C がサポートするもの、つまり引数 1 つの戻り値をサポートしている。構造体の戻り値でさえ、きちんとはサポートしていない。
C では「そうなると思わなかったのか」に近く、C++ では「ただインライン化すればいいじゃないか」という話になる。
一方で、メモリへのスピルは実際に起こる。たとえば SPARC の豊富なレジスタ空間とウィンドウは、単純な関数で未使用レジスタを多く残し、レジスタリングをスピルするとキャッシュを壊す大きなスタック使用量につながった。
x86 でデータを「必要な場所」へ再配置する
movが多くても、結果としてより速い場合はよくあった。呼び出される側のコードだけを見ると、「この引数はここに、あの戻り値はあそこにあれば必ず速い」と言いたくなるが、呼び出し側のことは分からない。
引数の準備がそのまま通過するか、戻り値がホットに消費されるかは保証できない。たとえば
struct Point { x: i32, y: i32, z: i32 }を引数/戻り値として使い、呼び出し側がループでmystruct.deepinside.point[i] = func(mystruct.deepinside.point[i])のようなことをするなら、レジスタに入れたり出したりすることがオーバーヘッドになったり、ベクトル化を妨げたりするかもしれない。呼び出される側はこれを知ることができず、コンパイラが両側を見てインライン化できる場合だけが例外だ。
呼び出し関連で最も手近な改善点は、ほぼすべての C ABI に埋め込まれている関数はプリミティブ値を 1 つ返すという前提をなくすことのように思える。残りについては、多くのベンチマークとコード生成統計が必要だ。
Rust には、構造体が望むより大きくなってしまう別の惜しい細部がある
NoneまたはSome(u8)であるOptionフィールドを 8 個持つFoo構造体を考えると、C では 1 ビットのbool8 個とuint8_t8 個で、合計 9 バイトで表現できるRust では 1 バイトの判別子と
uint8_tが 8 回繰り返され、16 バイトになる理由は、構造体がフィールドの借用を提供できなければならないため。
&Fooがあれば、コンパイラは&Foo::some_field、つまり&Optionを作れなければならず、この&Optionはプログラム内の他のすべての&Optionと同じ形でなければならないしたがって内部の
Optionは、プログラム内の他のOptionと同じレイアウト、つまり自身の判別ビットをバイトに切り上げたものとu8を持つ必要がある。実際には&Foo::some_fieldを作らなくても、構造体がこのコストを払うもっと大きな型の
Optionを考えるとさらに悪くなる。Optionフィールドが 8 個ある構造体では、各判別子が 2 バイトに切り上げられて合計 32 バイトになり、4 分の 1、判別子の未使用ビットまで含めるとほぼ半分が中間のパディングとして無駄になる。C の同等構造なら 18 バイトで済むOptionを使うと Rust の構造体は 128 バイト、C の構造体は 72 バイトになり得るもちろん、パックされた判別子用の
u81 個とMaybeUninit8 個を持ち、&FooからOption<&T>、&mut FooからOption<&mut T>へマッピングする関数を自前で作れば、C と同じ表現を実装できる。ただし&Optionや&mut Optionにはできないhttps://play.rust-lang.org/?version=stable&mode=debug&editio...
実質的には
Option8 個を含むユーザー定義型を説明しているのであり、性能を気にし始めるなら内部のOption処理は自分で回す必要があるRust が目的に合うときに選んで使える便利な機能を提供しているからといって、それを欠点と見るのは難しい
説明されているユースケースは比較的まれで、実際の性能ボトルネックなら、Rust で少し余分に時間をかけて実装することは大きな問題ではない
一般的な利用では
Option<_>型がもたらす利点が非常に大きいので、これを Rust の「惜しい細部」と見るのは難しい非多相・非インライン関数のアドレスが関数ポインタとして取得され得る場合、
-Zcallconv=legacyを使う shim を作り、実際の実装を即座に末尾呼び出しする、という内容がある。関数ポインタの同一性を保とうとする意図は理解できるしかしレガシー shim が Rust 呼び出し規約の関数を末尾呼び出しすると、呼び出し規約の戻り値の違いは直せないのでは?
少し別の話だが、現在 Go と Rust の相互運用が可能なのか気になる
以前、間に Zig を挟んで実現した事例を見た記憶があるが、見つけられない。レガシーな Rust コードがあり、少しずつ Go に移したい
extern "C"FFI を使って Rust 関数を呼び出せるGitHub コード検索でどう使っているかを RustConf 2023 で発表したし(https://www.youtube.com/watch?v=KYdlqhb267c)、その後 1Password のようなところでも同じようにやっていると聞いた
C 相互運用の境界をまたいで型を移す作業は面倒なので楽しくはないが、可能であり、コードの再利用もできる
extern "C"として宣言し、その後 Go から C を呼ぶように呼べばよい逆方向はよく分からない
管理コードでは、解放または移動されるメモリを所有できなければならず、非管理コードはメモリがいつ解放または移動されるかを推論しなければならない
cgoのようなものは、Go の管理コードから非管理メモリへの FFI 呼び出しを混ぜられるようにするが、その代償がある互いに呼び出す言語がガベージコレクタを共有しない実装では、常にこの問題が起きる
管理/非管理コードを混ぜるのは古いアイデアでありながら、今なお活発に研究されているテーマでもある
組み込みランタイムがそのために設計されている場合でなければ、非管理コードから管理コードを呼び出すのはほぼ常に悪手で、通常は間にシリアライズ層が入る
これが本業なら良くないと思うかもしれないが、数週間ごとにコードへ戻ってきたとき、何をどうしているのか思い出せないことに疲れた
状態を持つ Rust クロージャを Go コードへコールバックとして渡して Go 標準ライブラリ関数に入れ、Rust クロージャ内部の panic の巻き戻しまで含めていた
https://github.com/Voultapher/sort-research-rs/commit/df6c91...
セクション見出しがどうやって斜めに設定されているのか突き止めようと、かなり長いあいだ要素を検証してみたが、Safari のツールでは行き詰まった。いったいどうやっているのか?
.post-title要素にある:transform: skewY(-2deg) translate(-1rem, -0.4rem);element()関数(https://developer.mozilla.org/en-US/docs/Web/CSS/element)を使っているのかと思ったが、実際には記事本文を非常に小さく縮小したコピーだったh1, h2, h3, h4, h5, h6にtransform:skewY(-2deg) translate(-1rem,0rem);、transform-origin:top;、font-style:italic;、text-decoration-line:underline;、text-decoration-color:goldenrod;、text-underline-offset:4%;、text-decoration-thickness:.25exが適用されている対照的に、2019年の記事 “How Swift Achieved Dynamic Linking Where Rust Couldn't” がある
https://faultlore.com/blah/swift-abi/
Rust がまだ Rust レベルのセマンティクスのための呼び出し規約を持っていないのは残念だが、同時にその記事は、そこに至るまでに必要な作業量が膨大であることも示している
Apple は、Swift をアプリケーションが依存できる実用的なシステム言語にすることに強い動機を持っていたが、Rust にはそのような後ろ盾がない
HN での議論: https://news.ycombinator.com/item?id=21488415
Rust にもこのトレードオフに対するサポートの選択肢がもっとあるとよく、https://github.com/rust-lang/rfcs/pull/3470 のようなものだけに限定される必要はない
現在の Rust コンパイラが積極的にインライン化してから最適化するなら、これが手間に見合うのか疑問だ
呼び出される関数が小さければインライン化されるだろうし、大きければ関数内でかなり時間を使うはずなので、呼び出しオーバーヘッドは小さいだろう
dyn Traitはインライン化できないので、このような変更は役に立つ呼び出しを安くできれば、それほど積極的にインライン化しなくてもよくなり、コードサイズとコンパイル時間にも有利になり得る
インライン化に適さない複雑な関数は、メモリに何度かアクセスする可能性が高く、そのアクセスがボトルネックになる可能性が高い
スタック経由の受け渡しは、キャッシュ圧迫とロード/ストアが増えるため、そのボトルネックをさらに締め付ける
Rust がかなりの割合の関数呼び出しで引数を最適に渡せるなら、L1 アクセスの数サイクルを避けられるだけでなく、CPU が本質的なメモリボトルネックにより早く到達できるようになるかもしれない
数パーセント程度の利得はあるかもしれないが、今はワインを飲んでいて計算はしていない
x86 の参考資料に出てくる “Diana’s silk dress cost $89” という記憶法が何なのか説明してもらえる?