1 ポイント 投稿者 GN⁺ 2025-03-19 | 1件のコメント | WhatsAppで共有
  • 同じ jq ソースを再ビルドし、アロケータを変更すると、500MB GeoJSON の処理時間が 4.606秒から2.428秒 に短縮され、Ubuntu バイナリより 1.90倍 高速になった
  • ベンチマークは Ryzen 9 9950X 上で、Alameda County Assessor の parcel map を対象に TotalNetValue < 193000 条件の SitusCity を抽出する方式で行われた
  • 単純な再ビルドだけでも 2〜4% 改善し、clang-18-O3-flto-DNDEBUG の組み合わせは Ubuntu パッケージ比で 1.20倍 の性能を示した
  • プロファイルでは メモリ割り当てコスト が大きく、TCMallocjemallocmimalloc を比較したところ、LD_PRELOAD 実験では mimalloc が最速だった
  • 最終的な mimalloc リンクビルドは、別の 2.2GB JSON 処理事例でも 0.755秒 対 1.424秒 を記録し、ワークロードによってはディストリビューションのデフォルトビルドと大きな差が出ることを示した

基準ワークロードと測定方法

  • テスト対象は JSON 処理ツール jq で、入力データは Alameda County Assessor の parcel map を含む 500MB の GeoJSON ファイル
  • 実行したクエリは、parcel 一覧から TotalNetValue < 193000 条件を満たす項目の SitusCity を出力するもの
    • .features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
  • Ubuntu 標準の /usr/bin/jq は、ファイルがキャッシュされた状態で約 5秒 を要し、詳細なベンチマークは hyperfine で反復測定した
  • 実行中の変動を減らすため、taskset -c 2 で論理 CPU 2 に固定した
    • CPU 0 で動作するシステム割り込みや CPU マイグレーションの影響を避けるための設定

同じソースの単純な再ビルド

  • Ubuntu が使用している jq ソースコード を取得し、追加フラグなしで configure とビルドを実行した
  • この単純な再ビルドだけでも Ubuntu のバイナリパッケージより約 2〜4% 高速だった
    • 再ビルドバイナリ: 平均 4.517秒
    • Ubuntu /usr/bin/jq: 平均 4.641秒
    • 結果として約 1.03倍 の性能

clang と最適化フラグの適用

  • 次の段階では clang-18、より高い最適化レベル、LTO、デバッグ・プロファイリング関連フラグを組み合わせて適用した
  • 性能に影響した主要フラグは -O3-flto-DNDEBUG
    • -O3-O2 より高い最適化レベルを使用する
    • -flto はリンク時最適化を有効にする
    • -DNDEBUG はプロファイルで大きく現れていた assertion コストを削減する
  • 適用した configure の例は以下の通り
    • CC=clang-18
    • LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld"
    • CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections"
  • このビルドは Ubuntu バイナリより 1.20倍 高速だった
    • 最適化再ビルド: 平均 3.853秒
    • Ubuntu /usr/bin/jq: 平均 4.631秒

アロケータ置き換え実験

  • jq は複雑な C プログラムで、プロファイルでは メモリ割り当て が最大のコストとして現れた
  • まず Ubuntu パッケージとして提供される TCMalloc をリンクして再ビルドした
    • LDFLAGS-L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimal を追加した
    • 再ビルドバイナリ: 平均 3.253秒
    • Ubuntu /usr/bin/jq: 平均 4.611秒
    • Ubuntu バイナリ比で 1.42倍 高速という結果
  • 標準の Ubuntu バイナリでも LD_PRELOAD でアロケータだけを差し替えることで一部改善できる
    • 標準: 平均 4.601秒
    • TCMalloc プリロード: 平均 4.082秒
    • 標準比で 1.13倍 高速

動的プリロードと THP 設定

  • Ubuntu が提供する jemallocmimallocTCMallocLD_PRELOAD で比較した
  • この比較は次の環境変数を設定したうえで得られた結果
    • MIMALLOC_LARGE_OS_PAGES=1
    • MALLOC_CONF="thp:always,metadata_thp:always"
    • GLIBC_TUNABLES=glibc.malloc.hugetlb=1
  • 結果として mimalloc が最速だった
    • 標準 glibc: 平均 4.123秒
    • TCMalloc プリロード: 平均 4.130秒
    • jemalloc プリロード: 平均 3.510秒
    • mimalloc プリロード: 平均 3.154秒
  • THP の有効化は glibc allocator、jemalloc、mimalloc のすべてに利益をもたらした
  • THP + mimallocTHP + glibc より 31% 高速で、glibc のデフォルトより 48% 高速だった

mimalloc リンクビルドの最終結果

  • 動的プリロードは性能面で理想的ではないと判断し、最終段階では mimalloc をリンクして jq を再ビルドした
  • 最終ビルドは Ubuntu のバイナリパッケージより 1.90倍 高速だった
    • mimalloc 再ビルド: 平均 2.428秒
    • Ubuntu /usr/bin/jq: 平均 4.606秒
    • 各ベンチマークは 10 回実行を基準とした
  • 同じビルドを別のアプリケーションにも使用した
    • 13,000 個のファイルにある 2.2GB の JSON を処理した
    • 並列化には rush を使用した
    • mimalloc 再ビルド jq: 0.755秒
    • Ubuntu パッケージ jq: 1.424秒
  • この別事例でも速度向上はほぼ 2倍 に近かった

1件のコメント

 
GN⁺ 2025-03-19
Hacker News のコメント
  • 「Ubuntu パッケージを1つリビルドしてメモリアロケータを変えたら90%高速化」のようなクリックベイトは、TCP/IP 越しに一発殴りたくなるレベル。実際にはたった1つのパッケージで、改善の一部は再コンパイルのおかげですらなかった
    とはいえ、jemalloc を LD_PRELOAD であるプログラムに差し込んで malloc 実装を変えてみたことがあり、結果はかなり良かった。性能は測っていないが、そのアプリケーションのメモリ使用量が安定し、メモリリークに見えていた問題も解消した。実際にはアプリ自体の問題というより、標準の malloc によるメモリ断片化だった可能性が高い

    • glibc のメモリアロケータを調べてみたが、これはメモリ断片化ではなく、カーネルに決して返されないスレッドごとのキャッシュが原因だった。free() を呼んでも、例外的な状況でない限り、外部的にはメモリは実際には解放されない
      スレッドと CPU コアが多いほど、この問題は深刻になる。簡単な解決策の1つは、「魔法の」環境変数 MALLOC_ARENA_MAX=2 を設定してキャッシュ数を制限すること。別の方法は、アプリケーションが malloc_trim() を定期的に呼んでキャッシュを空にすることだが、これはソース修正が必要になる
      https://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
    • その通りで、自分も一瞬信じそうになった。ただ、Ubuntu をエラーの原因に仕立て上げるのも簡単だ。個人的には Ubuntu はパッケージの組み上げをかなりうまくやっていて、実際にスタック保護オプションも有効にしてコンパイルしている
      逆に、潜在的にバグがあり得る -O3 でコンパイルしていない点はありがたい。性能が重要な一部には良いかもしれないが、システム全体を -O3 でコンパイルしてほしくはない
    • グローバルにそんな改善が1本の記事の説明だけで達成できるはずがないので、明らかに誇張に見える。90%高速化はマイクロベンチマークの数字だ
    • 事前にパッケージ化されたバイナリ配布版のうち、どれだけが OS とハードウェアにとって最も安全なオプションでビルドされ、可能な最高性能は出せていないのか気になる。正直、ほとんどがそうだと思う
      昔、Mozilla と自分の Linux カーネルを好みに合わせてビルドし始めたが、たいていはそれなりの性能向上が得られた。Gentoo Linux ディストリビューションの目的全体も、たとえばすべてをソースから最適化してコンパイルすることで得られる性能向上にある
    • タイトルはクリックベイトだが、アプリ開発者にリビルドを試すよう促すのは良いことだ。特に jqgrepffmpegocrmypdf のようないくつかの一般的なユーティリティで CPU がボトルネックになっている場合はそうで、こうした一般的な Unix ユーティリティは、特定のアプリケーション向けではなく汎用ビルド対象として作られることが多い
  • 工学とはトレードオフである。記事で得られている利益の大半は、メモリアロケータを特化させたことによるもの。プロジェクトによってはマルチスレッドで、あるスレッドで割り当て、別のスレッドでデータを書き込み、3つ目のスレッドで解放することもある、という点を覚えておく必要がある
    アロケータはこれを処理しなければならないため、あるプロジェクトでの高速化が別のプロジェクトではクラッシュにつながる可能性がある。再割り当て戦略も問題になる。あるプログラムは事前に割り当てて二度と malloc に触れないが、別のプログラムは解放と再取得を繰り返す。フラグメンテーションをどれだけうまく扱えるか、稼働時間が10秒なのか10年なのかも重要だ。時には、アロケータの選択は長期安定性と短期的な速度の違いになる
    4K映像をテストしながらフレームをキャッシュする動画編集ソフトを作っていたとき、複数のアロケータを試した。1フレーム32MB、60fpsなら、1トラックあたり毎秒ほぼ2GBになる。すぐにアロケータの限界に突き当たり、少なくともデフォルトのglibcアロケータが長期安定性では最も優れていることに気づいた。ただし短いベンチマークでは最も遅い

    • MimallocはJEMalloc / TCMallocのような汎用アロケータである。glibcはかなり悪いアロケータとして知られており、MIMallocや新しいTCMalloc、つまりUbuntuに標準で入っている版ではないものは、glibcよりはるかに先を行っている
      もちろん速度向上の幅は変わり得るが、ベンチマークは概ね全体的な改善を示している。それが特定のアプリケーションにとって意味があるかどうかは、まったく別の問題だ。クラッシュについても、これらはいずれも汎用のマルチスレッド対応アロケータなので、glibcと違う動作をするわけではなく、バグはglibcにも同じようにあり得る
    • 自分も大きな8K映像フレームを扱っている [1]。フレーム自体の話であれば、毎秒60回の割り当てなど何でもない。glibcが遅い理由はただ1つ。各割り当てが DEFAULT_MMAP_THRESHOLD_MAX を超えており、64ビットプラットフォームでは32MiBなので、mallopt のマニュアルに書かれている通り、glibcにこれをキャッシュさせるよう説得できないことだ
      毎回 mmap でカーネルに直接メモリを要求し、munmap で返す。これらのシステムコールは少し遅く、自分の場合は初回アクセス時に各メモリページでページフォルトを起こすコストが、性能目標を満たせないほど遅かった。解決策は本当に単純だ。映像フレームに対してだけ、汎用アロケータや mmap の上に独自のフリーリストを使えばよい。まったく同じサイズの割り当てが非常に安定して繰り返されるので、うまく機能する
      [1] UYVY形式では64MiBより少し小さく、I420形式では48MiBより少し小さい
    • このコメントは少し理解しづらい。CやCコンパイラに詳しいわけではないが、gist全体を読んで多くを学んだし、価値もあったと感じた
      ところがこの親コメントを読むと、自分が記事を完全に誤解していたのではないかと不安になる。口調からすると、gistで言っていることは絶対にやってはいけず、こうした複雑さをすべて見落としたひどい提案だ、という意味に聞こえる。元のgistが良い記事なのか、妥当な点があるのか、まったく価値がないのかを理解できるよう助けてもらえるだろうか。このコメントを見るまでは価値があると思っていたが、自分には見分けられるほど賢くないのだと気づいた
    • だから世の中のすべてにアロケータを1つ使うのは悪い考えだ。シングルスレッドのアプリケーション、さらにはリソース管理を厳格に行っているマルチスレッドアプリケーションまで、全員がスレッド安全性のコストを払うのはひどい
    • よくある苦しみだ。プロジェクトに合った言語を使うと、人々はあれこれの理由で別の言語を使うべきだったと言う
      データに合った圧縮アルゴリズムを使うと、人々はなぜ君が愚かで、別のアルゴリズムを使うべきだったのかを語る。最近、Dynamoに入れるために特定の長いJSON文字列を圧縮する必要があり、人気のあるアルゴリズムをすべて徹底的にテストしたところ、Brotliが大きく抜きん出ていた。それでも、通りすがりの人が皆zlibのほうが良いと言うのを止めることはできなかった。時々、かなり疲れる
  • Gentoo Linuxは、実質的にこうした人たちのために、自分のLinuxマシンを自分の用途に合わせて最適化できるよう作られたディストリビューションだ
    初期設定の後はかなりシンプルで使いやすい。MatrixのGentoo Linuxチャンネルで友人がたくさんできたのを覚えているし、楽しい時期だった
    https://www.gentoo.org/
    面白い事実として、初期のChromeOSは基本的にカスタムGentoo Linuxインストールだった。今も内部的にGentoo Linuxを使っているかは分からない

    • その通りだが、ここでいう最適化が必ずしも性能を意味するわけではない点は指摘しておきたい
      Gentooを20年使ってきたが、性能のために使ったことはなかった。Gentooは、望む動作方式が分かっているときに優れていて、そこへ到達するのを助けてくれる
    • HN流の「install gentoo」ミームは初めて見た。確かにより洗練されている
      Gentooの目標は、事前にビルドされたバイナリパッケージの代わりに、すべてのプログラムをソースからビルドするOSを持つことだ。これにより高度な速度向上やカスタマイズが可能になるが、カーネルのような最も基本的な構成要素さえソースからコンパイルしなければならないという意味でもある。Linuxコミュニティでは、負担の大きいインストール手順のため非常に複雑なOSとして知られている。標準的なGentooインストールはすぐにコマンドプロンプトで起動し、ユーザーは自分でディスクをパーティション分割し、「Stage 3 tarball」というパッケージをダウンロードして展開し、パッケージを手動でインストールしながらシステムを作らなければならない。新規ユーザーや経験の浅いユーザーは、インストーラーに入ったのにグラフィカル画面がないと、何をすればよいか分からないことが多い。/g/のメンバーはしばしばGentooの価値を誇張し、新規ユーザーにインストールを試させるようだますことがある
    • 2003年からずっとGentooを使っていたが、ごく最近の2024年末にVoid Linuxを試して移行した。Voidでは、エンドユーザーがソースからビルドできることは明示された目標やアーキテクチャ機能ではないが、実際に動くようにできる可能性はかなり高い
      一度や二度つまずくことはあるかもしれないが、全般的なLinux経験が十分にあれば、ビルドレシピに入って修正し、必要どおりに動くようにし、その修正をアップストリームに貢献できる可能性が高い。これは、ミニマリズムに執拗に集中し、あらゆる過剰設計を避けた結果だ。Gentooを使っている間ずっと恋しかった部分でもある。Gentooではいつも、他のユーザーにはあまり役に立たない形でUSEフラグやパッケージマスクをいじることになった。ビルドシステムがあまりに複雑で、長年かけてもきちんと習得し、根本原因のレベルで問題を修正してアップストリームに貢献するのが非常に難しかった。Voidは、システム全体をソースからビルドしたくはないが、ディストリビューション提供のバイナリと自分でソースからビルドしたパッケージを混ぜて使いたい場合にも、理想的な基盤になり得る
    • Gentooをしばらく使っていたが、すべてを際限なくいじりたくなる誘惑のせいで、結局システムを壊しがちだった。Gentooのせいではなく自分のせいだ
      その後ArchLinuxに移り、自分にはおおむね問題なかった。かなり標準的なプロセッサを使っているなら、Gentooがそれほど大きな利点をもたらすとは思わない
    • 私の知る限り、GentooベースのChromeOSはAndroidに置き換えられつつある
  • こうすると、jqだけでなく、正規表現パーサー依存関係であるoniguramaのセキュリティアップデートからも外れてしまう。以前oniguramaのセキュリティアップデートがあり、こうしたことがまた起きれば脆弱になり得る。jqは信頼できないJSONをパースするのによく使われる
    内容は「セキュリティアップデート:複数の不正なポインタ逆参照、範囲外書き込みメモリ破損、スタックバッファオーバーフローの修正」というもので、この件はCVE-2017-9224、CVE-2017-9226、CVE-2017-9227、CVE-2017-9228、CVE-2017-9229に関するものだった

    • Gentoo Prefixのようなユーザー空間パッケージマネージャを使えば、このカスタムビルドをインストールしつつ、セキュリティアップデートを受け続けられる
    • それでも、プラットフォームに応じてビルドするかどうかを賢く決めるパッケージ管理システムの種のようなアイデアはあるのでは? 放置するには性能差がかなり大きく見える
    • 一般論としては正しいが、この場合は違う。gistで説明されているビルドは、今でもoniguramaを動的リンクしている。oniguramaはlibonig5という別パッケージに入っており、通常どおり更新されるはずだ
    • こういうCVEが一般にどれほど適用可能なのか気になる。家の中のドアを使うと金庫扉が提供するセキュリティを逃す、と言っているように感じる。間違いではないが、銀行のすべてのドアが金庫扉ではないのにも理由がある
      CVEシステムの価値を低く見ようというわけではないが、発見事項の間で実際の影響度に大きな差があることも否定しにくい
    • 確かにその通りだ。さらにアロケータを差し替えてコンパイラフラグを変えれば、特定のメモリ配置に依存する攻撃に対して偶然免疫ができることもあり得る
  • こういうことを扱ってから少し経つが、私の記憶では、アップストリーム開発者が使っているフラグを超えた瞬間、奇妙なバグと、バグが起きたときのものすごい無関心を買うことになる。ここで言っているのはディストリビューションのパッケージャではなく、アップストリーム開発者
    libcではないmallocは使ったことがないが、同じ原理が当てはまると思う

    • 相反する2つのことが同時に真であり得る。個人が少しも違わないようにしようとすれば、最も多くの人と同じ道にいて、短期的には最も成功する可能性が高い
      しかし全員がそうすれば単一文化になり、単一文化は脆弱で悪いものだ。コードは、別の文脈、つまり別のプラットフォーム、コンパイラ、オプション、ライブラリなどでビルドされるからこそ、多少なりとも堅牢になる。プラットフォームやビルドフラグがたまたま落とし穴のすぐ横を通り、ほとんど触れられなかったバグも、やはりバグであり、見つけて直す方がコードにとってよい。個人としての私たちも、コードが全体として脆弱であるより堅牢になると、みな得をする
    • 長年自分のemacsを自分でビルドしてきたが、まだ奇妙なバグに遭ったことはない。安全でない最適化さえ避ければ大丈夫だと思っていた
      もちろん、-march=nativeが自分の見ていた主な改善だとも思っていたが、この記事は必ずしもそうではないことを示している。浮動小数点を使うアプリケーションは、荒い部分がもっと多い可能性もありそうだ
    • 逆に、最適化が複数のプラットフォームで一貫して役に立つなら、アップストリーム開発者に直接実装するよう説得できる。必ずしも全プラットフォームである必要はなく、単一アーキテクチャで性能向上が十分に大きければ、そのビルド向けに設定を調整する理由になり得る
  • 特定のワークフローでベンチマーク結果が良く出る別のアロケータで再ビルドしたものに近いとはいえる

    • glibc の malloc より良いものなら、ほとんど何でもあり得る。ディストリビューションが mimalloc や jemalloc ではなく、いまだに glibc の malloc を使い続けているのは、事実上の職務怠慢だ
    • glibc の malloc がどんなワークロードに向いているのか、そもそも知られているのだろうか?
  • この Rust ベースの jq クローンと性能がどう比較されるのか気になる
    cargo install --locked jaq
    特定の CPU 系列向けの最適化を有効にするには、RUSTFLAGS="-C target-cpu=native" を追加することもできる。cargo install は、記事で説明されているような用途にぴったり合う、Rust の過小評価されている機能だ。ツールをソースからビルドするので、古い CPU との互換性のために通常のバイナリには含まれない、プラットフォーム固有の機能や命令を選択できる。リポジトリをクローンしたり、ビルド方法を調べたりする必要もなく、それがそのまま付いてくる
    jaq[1] と yq[2] は、jq を使っていて、速くて簡単な性能改善が必要になるたびに自分が選ぶ選択肢だ
    [1] https://github.com/01mf02/jaq
    [2] https://github.com/mikefarah/yq

    • ときどき jaqjqgojq と比較しているが、AoC 2022 day 13 に対する自分の jq 解法でテストしている
      https://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
      今でも両者に後れを取っている
    • おまけとして、知らない人もいるかもしれないが、リポジトリを直接使いたい場合、cargo install にはリポジトリ URL を指定できる --git フラグもある。公開パッケージがない場合や、まだリリースされていない最新コミットが欲しい場合に使える
      以前にも何度も使ったことがあり、特に個人的に急いで作ったツールをリポジトリにプッシュしたあと、リリース手順を作ったり、バイナリを自分の各マシンに直接コピーしたり、ビルドに使った正確なコミットを追跡したりしなくても、素早くインストールできる簡単な方法として役に立った
  • 実際にこうするなら、Ubuntu が想定している正確なソースパッケージを取得させればよい。この場合は apt-get source jq を使えばよい
    その後、パッケージ内に入って好きなように再コンパイルすればよい。配布や保管のために再パッケージ化することもできる。こうすれば、奇妙なエラーや不一致を大量に抱える代わりに、アップストリームの Ubuntu にかなり近い結果を得られる

  • タイトルは誤解を招く。短縮された時間の 90% という意味で、実際には約 45% 高速ということだ
    私たちが言葉をどう使うかに関心があるなら、少し興味深い点ではある。同じ時間で 90% 多くの仕事をこなす、と言うこともできるし、これは時速マイル、分あたり単語数、秒あたりビット数のような、私たちがよく使う他の速度単位とも合っている。しかしコンピュータ性能では、固定された作業量にかかる時間を測るのが慣例になっている。たいてい作業量は固定されていて、変わるのは待ち時間だからだと思う。このブログ記事の場合もまさにそうなので、時間を分子に置くことになる。記事自体はとても興味深くよく書かれているが、90% 速いわけではない

    • さらに誤解を招くのは、すべてのパッケージを 90% 高速化できるかのようなニュアンスだ。これは特定のパッケージ 1 つの話だ
    • ここでは時間単位ではなく、スループット単位としての「90% faster」を使うほうが筋が通るように思う
      そもそも時間単位を使うなら「faster」という単語は使わないだろう。「45% less time」と「45% faster」はまったく違う主張で、どちらもプログラミングの内外で意味を持つ
    • 関連記事: https://randomascii.wordpress.com/2018/02/04/what-we-talk-ab...
    • さらに考えてみると、なぜ誤解を招くのか分かった気がする。大きいほうの値の変化を、小さいほうの値に対する割合で述べているからだ
      「何かを N% 減らした」と言うと、普通その N% は別の値ではなく、減らした対象そのものの N% だと想定する
    • その通りだと思う。パッケージ、つまりコードが 45% 高速だと言うことも、パースのスループットを 90% 上げると言うこともできる。だが両方を混ぜると分かりにくい
  • こんな単純な変更で大きな速度向上が得られると読んで、最初に思い浮かんだのは jq の作者たちに知らせるべきだということだった。注意すべき落とし穴があるかもしれないし、テストしたうえで全員にとって速くできるかもしれない
    結果がどうであれ、簡単に知らせてみるのは有用そうだ。ところが記事ではその選択肢を検討すらしていないように見えるし、ここのコメントでも見当たらない。何か見落としているのだろうか?

    • Intel Clear Linux が命令セットのより新しいオペコードを使って、似たような利得を得ているのか気になる
      そこでも glibc のアロケータが標準なのかは分からない
      https://en.m.wikipedia.org/wiki/Clear_Linux_OS