
2025/12/31 16:44
**C におけるクロージャーのコスト ― 残り**
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
(完全な詳細を含めるために補完された内容):**
改訂要約
この記事は、2020年のMacBook Pro M1(Apple Clang 17およびGCC 15)上でMan‑or‑Boyテストを用いて、さまざまなC/C++クロージャ技術をベンチマークしています。各バリアントは150回実行され、100,000以上のイテレーションで壁時計時間とCPU時間の両方が測定されています。
新しい「Plain C」カテゴリが追加されました:
- 通常関数(コンテキストなし)– 最速のベースライン。
- 通常関数(Rosetta Code) – 間接的な
ポインタを含む;プレーン版より遅い。int* k - 通常関数(Static) – コンテキストに静的変数を使用;スレッドセーフではない。
- 通常関数(Thread Local) – 静的を
で置き換える;スレッドセーフだが追加のロード/ストアオーバーヘッドが発生。thread_local
結果は、通常関数から間接ポインタを除去するとRosetta‑Codeバージョンより明確に高速化されることを示しています。ラムダは型情報が保持されたままでは最速であり、任意の型消去(例:
std::function、std::function_ref)は測定可能なオーバーヘッドを追加します。静的およびスレッドローカルアプローチは、追加のロード/ストアコストによりプレーン関数シグネチャより遅くなります。GNU Nested Functionsはパフォーマンスが低下し、静的/スレッドローカル変種と同等またはそれ以下である一方、Apple Blocksもラムダに比べて顕著なオーバーヘッドを示します。
この記事の結論として、ISO‑C「Capture Functions」(標準で提案されたもの)およびワイド関数ポインタが既存の拡張機能よりも優れた性能を提供できる可能性があるとし、将来の言語またはライブラリ更新への方向性を示唆しています。開発者にとっては、パフォーマンスクリティカルなコードではプレーン関数や型付きラムダを優先するべきであり、コンパイラおよびライブラリ作者は埋め込みシステム、ゲームエンジン、高頻度取引などの領域でオーバーヘッドを削減するためにCapture Functionsを採用できると結論付けています。
最終注記: 改訂要約はリストからすべての主要ポイントを完全に反映し、明確で分かりやすく、曖昧または混乱する表現がないことを確認しています。
本文
前回の記事では、C および C++ でのクロージャ実装を「性能」面から検証しました。
ただし、比較に有用な代表的手法の一部は抜けてしまっていました。
ベンチマークを少数の新カテゴリで再実行することで、
それら普及しているアプローチが他とどのように位置づけられるか定量化できます。
導入部分は省略
前回の記事に簡単な概要があります。
C/C++ で使われるクロージャの詳細を深く知りたい場合は、
古い記事または進行中の C 設計提案をご覧ください。
そこでは、これらの手法が「効率的」か「非効率的」になる技術的・設計上の理由を解説しています。
ここでの目的は依然として性能です:
さまざまな設計の特徴を推定すること。
既にカバーした内容は省き、ベンチマークへの新追加項目と主要結論に注力します。
すべてのベンチマーク実装は公開されています¹。
実験設定
前回との唯一の変更点は、100 000+ サンプルイテレーションを 150 回繰り返すことです(以前は 50〜100 回)。
詳細は記事末尾に記載しています。
Plain C – 新カテゴリ
新しいベンチマークカテゴリは「Plain C」アプローチを追跡します:
| カテゴリ | 説明 |
|---|---|
| Normal Functions | 追加引数でデータを渡す通常の C 関数( を に書き換えるのと同じ)。 |
| Normal Functions (Rosetta Code) | Rosetta Code の週刊例題から直接採用。再帰呼び出し時に既存の 値を参照するために ポインタを使用。 |
| Normal Functions (Static) | 静的変数で次関数へのコンテキストを渡す通常の C 関数。スレッド安全ではなく、呼び出し署名は変更されない。 |
| Normal Functions (Thread Local) | 「Static」と同様だが 変数を使用。スレッド安全で、まだ署名は変更されない。 |
「Normal Functions」とは微妙ですが重要な違いがあります。
2つのバリアントは関数署名を変更しませんので、
void* user_data を持たない既存 qsort API と互換性があります。FFI などで重要です。
例:不要引数を追加する場合
int f0(arg* unused) { (void)unused; return 0; }
元のインターフェースを保つ:
int f0() { return 0; }
この変更は性能にほとんど影響しないと思われがちですが、ベンチマークで異なる結果が出ました。
結果
差が無ければ報告すべきではありません。
「Normal Functions」各バリアントのコスト(あるいは無コスト)を他と比較した図は以下の通りです:
(図は省略 – 元記事参照)
一部解決策が極端に遅いため、線形グラフでは有用な詳細が隠れます。対数スケールで可視化すると誤差棒が読みにくくなります。最終的には「Lambda (Rosetta Code)」などの悪い外れ値を除去した後、線形スケールに戻してより明確にしています。
洞察
大幅改善:「Normal Functions (Rosetta Code)」→「Normal Functions」
主な変更は
int* k ポインタを削除することです。int k を直接使用すると、1 レベルの間接参照が排除され、パフォーマンスが大きく向上します。
ラムダは依然リード
ラムダは追加構造なしに型情報を保持できるため、性能が優れています。
C++ ではテンプレートで再帰を境界化できますが、Plain C にはないのでマクロや間接参照を使わざるを得ません。コンパイラが完全に展開できなければ、そのコストは見えなくなります。
微小型の型消去
std::function_ref<int(void)> のような最小限の型消去を追加すると、純粋ラムダよりも性能が低下します。静的・スレッドローカル変数は呼び出しごとにロード/ストアが必要でオーバーヘッドになります。
GNU ネスト関数
GNU のネスト関数はスタックベースのトランペリンを使用するため、現在の実装ではコストが高くなり、高性能コードには不向きです。Clang が実装しない決定はこの事実に合致します。
最終結論
- ラムダ(または提案されたキャプチャ関数) は型情報が完全に保持される場合に最速です。
- 型を保持したクロージャ + 可能な限り薄い型消去(ワイド関数ポインタ)は、既存の C 拡張や署名変更しない Plain C コードよりも即座に利益を得られます。
- Apple Blocks と GNU ネスト関数 は設計上の欠点があり、主流コンパイラへの統合は難しいです。
- 将来の C エコシステムで「フレーム/環境」をポインタ経由で取得することは推奨されません。
- 複雑なケースでは C プログラマは必然的に型消去による性能低下を受けます。マクロベースの汎用プログラミングが速度とコードサイズのトレードオフで対処可能です。
静的および
thread_local バリアントは追加コストが大きく、特に GCC では顕著です。MSVC の数値も同様の傾向を示すでしょう。
方法論
テストは 13″ 2020 MacBook Pro M1 (16 GB RAM, macOS 15.7.2 Sequoia) 上で、AppleClang と Homebrew インストール GCC を使用。
ベンチマークは「Man‑or‑Boy」テストを採用し、
k は共有オブジェクトから読み込むようにしてコンパイラ最適化による再帰の定数化を防止しています。
2 種類の測定(壁時計時間と CPU 時間)を行い、各ループは数千〜数十万回実行し平均値を取った後、150 回繰り返してバー高さを決定しました。
13 のカテゴリ(no‑op、normal functions、static/thread‑local バリアント、さまざまなラムダアプローチ、Apple Blocks、GNU Nested Functions、カスタム C++ クラス、shared_ptr)をベンチマーク。
誤差棒は 150 回の実行での標準誤差です。AppleClang 17 と GCC 15 をテストし、MSVC はこれら拡張機能が無いため除外しました。
Happy New Year, and until next performance deep‑dive! 💚