1 ポイント 投稿者 GN⁺ 2024-09-02 | 1件のコメント | WhatsAppで共有
  • {fmt}は型消去によってテンプレートの肥大化を抑えてきたC++フォーマットライブラリであり、今回の実験では単純なfmt::print実行ファイルを75kBから14kBまで縮小
  • 中核となる構造は、formatがテンプレートではないvformatに処理を委譲し、出力型もバッファAPIで隠蔽する方式で、バイナリサイズとビルド時間を同時に削減できる
  • aarch64 Ubuntu 22.04とGCC 11.4.0において、{fmt} 11.0.2のstrip後実行ファイルは75kBで、localeの無効化と組み込み型の縮小、サイズ最適化マクロにより71kB → 31kB → 27kB → 23kBまで減少
  • C++ランタイムの除去は、例外をFMT_THROWabort処理し、-fno-exceptions-nodefaultlibs-lcでビルドした後、basic_memory_bufferのデフォルトアロケータをmalloc/freeベースに変更することで可能になった
  • 最終的な実行ファイルは14kBで、同じシステム上の空のC mainが6kBであることを踏まえると、{fmt}が追加するサイズは10kB未満であり、lddでもC++ランタイム依存は見られない

{fmt}が小さなバイナリを実現する仕組み

  • {fmt} formatting libraryは、IOStreams、Boost Format、tinyformatといった代替手段よりも、関数呼び出しごとに生成されるコードが何倍も小さいことが多い
  • 核心は、複数の層で型消去(type erasure) を適用してテンプレートの肥大化を抑える構造にある
  • フォーマット引数はformat_argsによって型消去される
    • formatテンプレート関数は実際の処理をテンプレートではないvformatに委譲する
    • 出力イテレータやその他の出力型も、別のバッファAPIを通じて型消去される
  • テンプレートの使用は最上位の薄い層に限定されており、この構造がより小さなバイナリとより高速なC++コンパイル時間に貢献している

printfに近いコードサイズと、より強い安全性

  • 例のプログラムはfmt::print("The answer is {}.", 42);を呼び出すだけ
  • コンパイル結果はIOStreamsよりはるかに小さく、printfの例と同程度の水準
  • printfと異なり、{fmt}は実行時の型安全性を提供する
    • フォーマット文字列の誤りはコンパイル時に検出できる
    • フォーマット文字列が実行時に決まる場合でも、例外によってエラーを処理し、未定義動作、メモリ破損、潜在的なクラッシュを避けられる
  • Cの可変引数と相性のよくない位置引数(positional arguments) を使う場合、{fmt}の呼び出しの方が一般的に効率的

基準サイズとlocaleの除去

  • 2020年のライブラリサイズ最適化では、{fmt}を100kB未満、-Os -flto基準で約57kBまで縮小していた
  • その後、{fmt}はJunekey Jeonが貢献したDragonboxアルゴリズムを浮動小数点フォーマットに使うようになった
  • 今回の測定はエンドユーザーが体感する実行ファイルサイズを基準とし、aarch64 Ubuntu 22.04とGCC 11.4.0で実施された
  • {fmt} 11.0.2の基準ビルドは-Os -flto -DNDEBUGstrip後で75kB
    • この4年間でさまざまな変更はあったが、サイズは大きく後退していない
  • localeサポートをFMT_STATIC_THOUSANDS_SEPARATORで無効化すると、バイナリサイズは71kBに減少
    • {fmt}のフォーマットはデフォルトでlocale非依存
    • localeはLフォーマット指定子で任意に利用できる

組み込み型の縮小と「使わないものにはコストを払わない」モデル

  • Bloatyの分析では、数値フォーマット、特に浮動小数点フォーマットがバイナリサイズの大きな部分を占めている
    • 浮動小数点フォーマットではテーブルも使用しており、そのテーブルはBloatyの出力には現れない
  • 根本的な負担は、フォーマット関数がフォーマット可能なすべての型を知っていなければならない点にある
    • この方式はC標準のprintfには適しているが、{fmt}では必須条件ではない
    • {fmt}は、型集合全体を事前に把握していなくても任意の型をフォーマットできる拡張APIをサポートしている
  • 実験的実装では、FMT_BUILTIN_TYPES=0を設定してintだけを特別扱いし、その他の型は汎用の拡張APIに送る
    • intは動的な幅と精度の処理に必要
    • 例: fmt::print("{:{}}\n", "hello", 10);"hello "を出力する
  • この方式は使わない型にはコストを払わないモデルを提供するが、呼び出しごとのバイナリサイズはやや増える
    • 浮動小数点や他の型を実際にフォーマットすれば、関連コードは依然としてビルドに含まれる
  • FMT_BUILTIN_TYPES=0適用後、例のバイナリは31kBまで減少
  • その後、残っていたlocale関連の痕跡をe582d37b3ccc2dで除去し、FMT_USE_LOCALEマクロでより明確に無効化できるようにした結果、サイズは27kBになった

速度とサイズの選択、そしてC++ランタイムの除去

  • ライブラリ内には、速度のためにサイズを使っている箇所がいくつもある
  • 10進の桁数を計算するdo_count_digitsは256バイトのテーブルを使う
    • この実装を無条件に変更すると、他のユースケースに悪影響を与える可能性がある
    • __builtin_clzを使えないconstexprのような場合に備えたfallback実装もすでにある
  • FMT_OPTIMIZE_SIZEマクロを追加し、ユーザーがfallback実装を使うかどうかを制御できるようにした
    • この調整といくつかの類似した変更により、バイナリサイズは23kBになった
  • C++標準ライブラリへの依存をなくすため、例外はFMT_THROWで無効化できる
    • 例ではFMT_THROW(s)=abort()-fno-exceptionsを使用
    • 一般には推奨されないが、ほとんどのエラーがコンパイル時に検出される一部のユースケースでは問題ない場合もある
  • -nodefaultlibs -lcでビルドすると、残るC++ランタイム依存はfmt::basic_memory_bufferから発生する
    • このバッファは小さなスタック確保バッファで、必要に応じて動的メモリに拡張される
    • fmt::printは通常、FILEバッファへ直接書き込めるため、動的確保は不要
  • より一般的な解決策として、デフォルトアロケータをnew/deleteではなくmalloc/freeベースに置き換えた
    • この変更後、最終的なバイナリサイズは14kBになった
    • 同じシステム上の空のC mainプログラムは6kBなので、{fmt}が追加するサイズは10kB未満
  • ldd a.outの結果にはlibc.so.6とローダーだけが表示され、C++ランタイム依存は現れない
  • 最終結果は、組み込み環境やメモリ制約のある環境で{fmt}をより小さく使えることを示している

1件のコメント

 
GN⁺ 2024-09-02
Hacker News のコメント
  • これは実のところ委員会の傾向に近い問題なので、サードパーティライブラリである fmt が必ずしも悪いデフォルトを持つとは期待していません。
    驚いたことに、この機能が C++20 の std::format として標準化されたとき、委員会は標準の他の多くの部分にあるこの過ちを再び入れませんでした。
    なので、C++を「一貫した」ものにするという理由で不必要に悪化させないでほしいと訴える提案者にも、少しは希望があります。

  • 浮動小数点フォーマットに必要なコード量を見ると、かなり衝撃的です。
    リンクされている Dragonbox [1] プロジェクトも読む価値があり、ほとんど使われない分岐までかなり最適化されています。
    [1] https://github.com/jk-jeon/dragonbox

    • 最近 Zig の作業をしていて、浮動小数点フォーマットにどれほど多くのコードが必要かを知りました。
      通常、Zig コンパイラは Windows で C ランタイムに依存しないため、MSVC より小さいバイナリを作れますが、今回はツールがしていることに比べてバイナリが妙に大きかったのです。
      Binary Ninja で開いてみると、コードの大半が浮動小数点フォーマットのサポート用で、出力前に浮動小数点数を整数にキャストしたところ、期待していたサイズまで縮みました。
    • https://github.com/jk-jeon/dragonbox/discussions/57#discussioncomment-9340182
      サイズ最適化の実験をしており、現在は 8ビット AVR で約 3k まで削減できます。
      単精度 binary32 向けの実装とテーブルだけを含む状態で、倍精度にははるかに多くが必要ですが、同時に肥大化のかなりの部分は AVR の制約によるものです。
      x64 のようなプラットフォームではずっと小さくできますが、それでも 3k はまだ大きいと言えます。
    • 速くしたいなら、多くのコードが必要です。
      基準実装も結局は任意精度算術の実装ですが、そこまで悪くはありません。
      [1] https://research.swtch.com/ftoa
      [2] https://go.dev/src/strconv/ftoa.go
    • {fmt} には古い Dragon4 アルゴリズムの任意実装があり、コードサイズはより小さいものの速度は遅くなります。
    • ほとんどのユースケースでは、出力する小数桁数を制限すると思います。
      小数桁数分だけ掛けてから整数に変換し、itoa() を通した後、適切な位置に小数点を挿入する方式のほうが効率的なのか気になります。
  • C++初心者として気になるのですが、libc++ のデフォルトアロケータ、つまりデフォルトの new/delete 実装は、内部で libc の malloc/free を呼ぶのと実際に何か違うことをしているのでしょうか? そうだとすれば、なぜですか?

    • C++にすごく詳しいわけではありませんが、new[] はメモリを得るために new 演算子を呼んだ後、各要素のコンストラクタを実行しようとします。
      delete[] はメモリを解放する前に各要素のデストラクタを実行しようとします。
      delete[] が動作するには、C++ はどこかで割り当てサイズを追跡する必要があり、この情報は割り当て領域の近くに置くことも、別の構造に置くこともできます。
      別構造を使えば、オブジェクトの後方メモリを誤って使った場合に情報が上書きされる可能性は低くなりますが、検索コストと追加コードが必要になります。
      まともな C++ ライブラリならもっと多くのことをするでしょうが、new/delete が malloc/free と同じではないという感覚はつかめるはずです。
    • ISO C++ は、new/delete のデフォルト実装が malloc()/free() を呼ばなければならないとは要求していません。
      多くの実装がそうしているのは、すでに存在していて使いやすいからにすぎません。
    • アラインメント付きアロケーションのオーバーロードを除けば、基本的には違いません。
      ただしアプリケーションは、ELF シンボルインターポジションに相当する機能がないプラットフォームでも、標準ライブラリのデフォルト operator new を独自実装で置き換えられます。
    • malloc に変える主な理由は、new が std::bad_alloc を投げるため、それを使うと C++ ランタイムにリンクしなければならないからです。
  • 小さく、文字列と整数を出力できるように設計されたフォーマットライブラリなら、およそ 50バイト 程度であるべきではないかと期待していました。
    文字列はヌル終端文字の検査、文字出力、2段階戻る分岐くらいで、約4命令で済みます。
    整数は、負数の確認後に '-' を出力して符号反転し、1000000000 を R1 に入れて除算と剰余の保存、ASCII '0' の加算、文字出力、R1 を 10 で割る、剰余を入力に入れる、R1=0 になるまで繰り返す程度なので、約20命令で済みます。
    浮動小数点は多くのプログラムでは使わないので、必要なときだけコンパイルされるべきですし、16進数・ポインタ・前方ゼロ埋めも同様です。
    コード空間が 2KB のマイクロコントローラ向けコードを書くとき、14KB の文字列フォーマットライブラリは入れません。

    • これは修飾子のない遅い整数・文字列出力ライブラリではなく、機能の多いフォーマットライブラリです。
      機能が多く、速く、しかも小さいライブラリを同時に作ることはできません。
    • マイクロコントローラ向けライブラリの設計と、一般的なエンドユーザーアプリケーション向けの「同等の」ライブラリ設計は、ほぼすべての主要な点で異なります。
      これが fmt だけに当てはまる話というより、公の場での一般的な不満と何が違うのかよく分かりません。
      Dragonbox や Dragon4 のようなアルゴリズムコードだけで、すでにサイズ予算を超えるため、「任意」機能はそれほど重要ではありません。
      そしてそれは、人々が欲しがる20ほどの機能のうちの1つにすぎません。
    • それなら実際に使っているライブラリを公開し、どんなフォーマット機能をサポートしているのか文書化するのが筋ではないかと思います。
      そうすれば、他の人たちがより多くの機能をもっと賢く詰め込む方法を見つけられるかもしれません。
      そうでなければ、要点がよく分かりません。
    • 特定のプログラミング分野のニッチな要件が、言語にそのような形で影響を与えるべきではないと思います。
      その要件は妥当ですが、言語仕様ではなく、最低スペックのマイクロコントローラ用コンパイラが解決すべきことです。
    • このライブラリは小さくすることを主目的にしているのではなく、完全な文字列フォーマットライブラリを作りつつ、サイズを重要な副次目標にしているものです。
      基本機能すらサポートしない代わりに極端に小さくなければならないなら、もっと良い選択肢は確実にあります。
      コード空間が 2KB しかないなら、これを使うべきではありません。
      幸い、現代のマイクロコントローラの多くはそれよりはるかに大きく、例えば esp32 は 1MB から始まるので、14KB のフォーマットライブラリを使うのも十分に合理的です。
  • 少し宣伝すると、出力バッファリング付きの libc を含めても、1008バイトの実行ファイルprintf(Hello, World!\n"); が可能です: https://github.com/pts/minilibc686
    もちろん直接比較すれば、リンゴとオレンジを比べるようなものです。

    • それはコンパイラがこれを fputs に変換するからです。
  • 「空の main 関数を持つ C プログラムがこのシステムで 6kB なら、{fmt} は今やバイナリに 10kB 未満しか追加しない」という部分が興味深いです。
    こういうテストはしたことがありません。

    • C ライブラリを動的リンクするか静的リンクするか、アプリケーションと C ライブラリをどうビルドしたかによって大きく変わります。
      どの C ライブラリを使うかも重要で、ELF を使うのか別のコンテナを使うのかも多少影響します。
  • いつも fmt が問題です。
    十分な数の数値、特に浮動小数点と decimal のフォーマット/パースに触れると、リンカが浮動小数点と BigInt 関連のコードを大量に引き込んでバイナリサイズが大きくなる、ということが今では .NET でもまったく同じように起きるのがとても笑えます。

    • Native AOT でも Delphi のような体験をまだ期待していて、幸い少しずつ良くなっています。
  • とても面白いです。
    こういう発想の転換的な最適化が好きです。

  • 自分が鈍いのかもしれませんが、タイトルの「14k」が 14kB を意味するのだと気づくまで少し時間がかかりました。

    • 他に何を意味し得るのか、という気もします。
      少なくとも歴史的には、k は kB の一般的な略記です。