Ubuntuパッケージを再ビルドして90%高速化する
(gist.github.com/jwbee)- 同じ
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倍 の性能を示した - プロファイルでは メモリ割り当てコスト が大きく、
TCMalloc、jemalloc、mimallocを比較したところ、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-18LDFLAGS="-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 が提供する
jemalloc、mimalloc、TCMallocをLD_PRELOADで比較した - この比較は次の環境変数を設定したうえで得られた結果
MIMALLOC_LARGE_OS_PAGES=1MALLOC_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 + mimallocはTHP + 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件のコメント
Hacker News のコメント
「Ubuntu パッケージを1つリビルドしてメモリアロケータを変えたら90%高速化」のようなクリックベイトは、TCP/IP 越しに一発殴りたくなるレベル。実際にはたった1つのパッケージで、改善の一部は再コンパイルのおかげですらなかった
とはいえ、jemalloc を
LD_PRELOADであるプログラムに差し込んでmalloc実装を変えてみたことがあり、結果はかなり良かった。性能は測っていないが、そのアプリケーションのメモリ使用量が安定し、メモリリークに見えていた問題も解消した。実際にはアプリ自体の問題というより、標準のmallocによるメモリ断片化だった可能性が高いfree()を呼んでも、例外的な状況でない限り、外部的にはメモリは実際には解放されないスレッドと CPU コアが多いほど、この問題は深刻になる。簡単な解決策の1つは、「魔法の」環境変数
MALLOC_ARENA_MAX=2を設定してキャッシュ数を制限すること。別の方法は、アプリケーションがmalloc_trim()を定期的に呼んでキャッシュを空にすることだが、これはソース修正が必要になるhttps://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
逆に、潜在的にバグがあり得る
-O3でコンパイルしていない点はありがたい。性能が重要な一部には良いかもしれないが、システム全体を-O3でコンパイルしてほしくはない昔、Mozilla と自分の Linux カーネルを好みに合わせてビルドし始めたが、たいていはそれなりの性能向上が得られた。Gentoo Linux ディストリビューションの目的全体も、たとえばすべてをソースから最適化してコンパイルすることで得られる性能向上にある
jq、grep、ffmpeg、ocrmypdfのようないくつかの一般的なユーティリティで CPU がボトルネックになっている場合はそうで、こうした一般的な Unix ユーティリティは、特定のアプリケーション向けではなく汎用ビルド対象として作られることが多い工学とはトレードオフである。記事で得られている利益の大半は、メモリアロケータを特化させたことによるもの。プロジェクトによってはマルチスレッドで、あるスレッドで割り当て、別のスレッドでデータを書き込み、3つ目のスレッドで解放することもある、という点を覚えておく必要がある
アロケータはこれを処理しなければならないため、あるプロジェクトでの高速化が別のプロジェクトではクラッシュにつながる可能性がある。再割り当て戦略も問題になる。あるプログラムは事前に割り当てて二度と
mallocに触れないが、別のプログラムは解放と再取得を繰り返す。フラグメンテーションをどれだけうまく扱えるか、稼働時間が10秒なのか10年なのかも重要だ。時には、アロケータの選択は長期安定性と短期的な速度の違いになる4K映像をテストしながらフレームをキャッシュする動画編集ソフトを作っていたとき、複数のアロケータを試した。1フレーム32MB、60fpsなら、1トラックあたり毎秒ほぼ2GBになる。すぐにアロケータの限界に突き当たり、少なくともデフォルトのglibcアロケータが長期安定性では最も優れていることに気づいた。ただし短いベンチマークでは最も遅い
もちろん速度向上の幅は変わり得るが、ベンチマークは概ね全体的な改善を示している。それが特定のアプリケーションにとって意味があるかどうかは、まったく別の問題だ。クラッシュについても、これらはいずれも汎用のマルチスレッド対応アロケータなので、glibcと違う動作をするわけではなく、バグはglibcにも同じようにあり得る
DEFAULT_MMAP_THRESHOLD_MAXを超えており、64ビットプラットフォームでは32MiBなので、malloptのマニュアルに書かれている通り、glibcにこれをキャッシュさせるよう説得できないことだ毎回
mmapでカーネルに直接メモリを要求し、munmapで返す。これらのシステムコールは少し遅く、自分の場合は初回アクセス時に各メモリページでページフォルトを起こすコストが、性能目標を満たせないほど遅かった。解決策は本当に単純だ。映像フレームに対してだけ、汎用アロケータやmmapの上に独自のフリーリストを使えばよい。まったく同じサイズの割り当てが非常に安定して繰り返されるので、うまく機能する[1] UYVY形式では64MiBより少し小さく、I420形式では48MiBより少し小さい
ところがこの親コメントを読むと、自分が記事を完全に誤解していたのではないかと不安になる。口調からすると、gistで言っていることは絶対にやってはいけず、こうした複雑さをすべて見落としたひどい提案だ、という意味に聞こえる。元のgistが良い記事なのか、妥当な点があるのか、まったく価値がないのかを理解できるよう助けてもらえるだろうか。このコメントを見るまでは価値があると思っていたが、自分には見分けられるほど賢くないのだと気づいた
データに合った圧縮アルゴリズムを使うと、人々はなぜ君が愚かで、別のアルゴリズムを使うべきだったのかを語る。最近、Dynamoに入れるために特定の長いJSON文字列を圧縮する必要があり、人気のあるアルゴリズムをすべて徹底的にテストしたところ、Brotliが大きく抜きん出ていた。それでも、通りすがりの人が皆zlibのほうが良いと言うのを止めることはできなかった。時々、かなり疲れる
Gentoo Linuxは、実質的にこうした人たちのために、自分のLinuxマシンを自分の用途に合わせて最適化できるよう作られたディストリビューションだ
初期設定の後はかなりシンプルで使いやすい。MatrixのGentoo Linuxチャンネルで友人がたくさんできたのを覚えているし、楽しい時期だった
https://www.gentoo.org/
面白い事実として、初期のChromeOSは基本的にカスタムGentoo Linuxインストールだった。今も内部的にGentoo Linuxを使っているかは分からない
Gentooを20年使ってきたが、性能のために使ったことはなかった。Gentooは、望む動作方式が分かっているときに優れていて、そこへ到達するのを助けてくれる
Gentooの目標は、事前にビルドされたバイナリパッケージの代わりに、すべてのプログラムをソースからビルドするOSを持つことだ。これにより高度な速度向上やカスタマイズが可能になるが、カーネルのような最も基本的な構成要素さえソースからコンパイルしなければならないという意味でもある。Linuxコミュニティでは、負担の大きいインストール手順のため非常に複雑なOSとして知られている。標準的なGentooインストールはすぐにコマンドプロンプトで起動し、ユーザーは自分でディスクをパーティション分割し、「Stage 3 tarball」というパッケージをダウンロードして展開し、パッケージを手動でインストールしながらシステムを作らなければならない。新規ユーザーや経験の浅いユーザーは、インストーラーに入ったのにグラフィカル画面がないと、何をすればよいか分からないことが多い。/g/のメンバーはしばしばGentooの価値を誇張し、新規ユーザーにインストールを試させるようだますことがある
一度や二度つまずくことはあるかもしれないが、全般的なLinux経験が十分にあれば、ビルドレシピに入って修正し、必要どおりに動くようにし、その修正をアップストリームに貢献できる可能性が高い。これは、ミニマリズムに執拗に集中し、あらゆる過剰設計を避けた結果だ。Gentooを使っている間ずっと恋しかった部分でもある。Gentooではいつも、他のユーザーにはあまり役に立たない形でUSEフラグやパッケージマスクをいじることになった。ビルドシステムがあまりに複雑で、長年かけてもきちんと習得し、根本原因のレベルで問題を修正してアップストリームに貢献するのが非常に難しかった。Voidは、システム全体をソースからビルドしたくはないが、ディストリビューション提供のバイナリと自分でソースからビルドしたパッケージを混ぜて使いたい場合にも、理想的な基盤になり得る
その後ArchLinuxに移り、自分にはおおむね問題なかった。かなり標準的なプロセッサを使っているなら、Gentooがそれほど大きな利点をもたらすとは思わない
こうすると、
jqだけでなく、正規表現パーサー依存関係であるoniguramaのセキュリティアップデートからも外れてしまう。以前oniguramaのセキュリティアップデートがあり、こうしたことがまた起きれば脆弱になり得る。jqは信頼できないJSONをパースするのによく使われる内容は「セキュリティアップデート:複数の不正なポインタ逆参照、範囲外書き込みメモリ破損、スタックバッファオーバーフローの修正」というもので、この件はCVE-2017-9224、CVE-2017-9226、CVE-2017-9227、CVE-2017-9228、CVE-2017-9229に関するものだった
libonig5という別パッケージに入っており、通常どおり更新されるはずだCVEシステムの価値を低く見ようというわけではないが、発見事項の間で実際の影響度に大きな差があることも否定しにくい
こういうことを扱ってから少し経つが、私の記憶では、アップストリーム開発者が使っているフラグを超えた瞬間、奇妙なバグと、バグが起きたときのものすごい無関心を買うことになる。ここで言っているのはディストリビューションのパッケージャではなく、アップストリーム開発者だ
libcではない
mallocは使ったことがないが、同じ原理が当てはまると思うしかし全員がそうすれば単一文化になり、単一文化は脆弱で悪いものだ。コードは、別の文脈、つまり別のプラットフォーム、コンパイラ、オプション、ライブラリなどでビルドされるからこそ、多少なりとも堅牢になる。プラットフォームやビルドフラグがたまたま落とし穴のすぐ横を通り、ほとんど触れられなかったバグも、やはりバグであり、見つけて直す方がコードにとってよい。個人としての私たちも、コードが全体として脆弱であるより堅牢になると、みな得をする
もちろん、
-march=nativeが自分の見ていた主な改善だとも思っていたが、この記事は必ずしもそうではないことを示している。浮動小数点を使うアプリケーションは、荒い部分がもっと多い可能性もありそうだ特定のワークフローでベンチマーク結果が良く出る別のアロケータで再ビルドしたものに近いとはいえる
mallocより良いものなら、ほとんど何でもあり得る。ディストリビューションが mimalloc や jemalloc ではなく、いまだに glibc のmallocを使い続けているのは、事実上の職務怠慢だ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
jq、gojqと比較しているが、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% 速いわけではない
そもそも時間単位を使うなら「faster」という単語は使わないだろう。「45% less time」と「45% faster」はまったく違う主張で、どちらもプログラミングの内外で意味を持つ
「何かを N% 減らした」と言うと、普通その N% は別の値ではなく、減らした対象そのものの N% だと想定する
こんな単純な変更で大きな速度向上が得られると読んで、最初に思い浮かんだのは jq の作者たちに知らせるべきだということだった。注意すべき落とし穴があるかもしれないし、テストしたうえで全員にとって速くできるかもしれない
結果がどうであれ、簡単に知らせてみるのは有用そうだ。ところが記事ではその選択肢を検討すらしていないように見えるし、ここのコメントでも見当たらない。何か見落としているのだろうか?
そこでも glibc のアロケータが標準なのかは分からない
https://en.m.wikipedia.org/wiki/Clear_Linux_OS