ハクソク

世界を動かす技術を、日本語で。

巨大バイナリ

概要

  • 巨大バイナリ問題は、特大規模のコードベースでのみ顕在化する現象
  • **2GiB「リロケーションバリア」**がx86_64アーキテクチャの制約
  • 静的リンクによりバイナリサイズが急増し、ジャンプ命令の制限に直面
  • -mcmodel=largeで制限回避可能だが、命令肥大化などの副作用あり
  • さらなる解決策は今後の議論対象

特大規模コードベースにおける巨大バイナリ問題

  • PhD研究や論文投稿時に、超大規模な問題への理解不足を指摘される課題
  • Google等のメガコードベースでのみ発生する現象の観測
  • 25GiB超のELFバイナリ(デバッグシンボル含む)の実例
  • 静的リンクによる全コードの取り込みでバイナリ肥大化
  • デプロイや起動高速化のための静的ビルド選択

2GiB「リロケーションバリア」の正体

  • x86_64のCALL命令は32bit符号付き相対オフセットのみ許容
  • **2^31(約2GiB)**がジャンプ可能な最大範囲
  • 巨大バイナリでは関数間距離が2GiBを超えるケースが発生
  • 相対ジャンプの限界が「リロケーションバリア」と呼ばれる理由

実例:シンプルな相対ジャンプとリロケーション

  • C言語の関数呼び出しをgccでコンパイルし、objdumpで解析
  • **e8(CALL命令)**が4バイトの相対位置を指定
  • readelfによるリロケーション情報の確認
  • リンカスクリプトで関数を2GiB以上離すことで「relocation overflow」発生
  • エラーメッセージ例: relocation R_X86_64_PLT32 out of range

回避策:-mcmodel=largeの利用

  • -mcmodel=largeで64bit絶対アドレスジャンプに切り替え可能
  • 命令列が5バイト→12バイトに増加(MOVABS+CALL)
  • **汎用レジスタ(例: %rdx)**の消費増加
  • 命令肥大化によるバイナリサイズ増加・パフォーマンス影響
  • -fno-asynchronous-unwind-tablesで追加データによるオーバーフロー防止

mcmodel=largeのデメリット

  • 命令バイト数増加によるバイナリ肥大化
  • レジスタ消費によるレジスタプレッシャー増
  • 理論上IPC低下の可能性(実ベンチマークでは再現困難)

今後の展望と課題

  • 小規模コードモデルを維持しつつ制約を回避する新戦略の必要性
  • さらなる解決策や工夫は今後の執筆で検討予定

Hackerたちの意見

25 GiBのバイナリって、ちょっと怖すぎるよね。動的リンクが必要なんじゃないかな。
確かに、これはデバッグシンボル込みの話だよね。数年前のChromeのデバッグビルドは5GBくらいだったし、今はもっと増えてるだろうね。オブジェクトファイルのサイズが大きすぎて、リンク中にラップトップがRAM不足になったこともあったな。デバッグシンボルってなんでこんなに大きいんだろう?C++の場合、プログラム内のあらゆる型のインスタンスに対して詳細な型情報が含まれてるから、フィールドの型(再帰的に)、メソッドのシグネチャ、ローカル変数の型と位置など、すごい量のデータが生成されるんだよね。これって「普通」なプロジェクトでもかなりのデータ量になる。さらに悪いことに、C++では動的リンクのメリットがあまりないんだ。ヘッダーファイルで定義されたテンプレートは簡単に共有ライブラリに入れられないし、ABIの違いで動的ライブラリは同期して更新しなきゃいけないし、モジュール間の重複も避けられない(インライン関数やテンプレートのおかげで)。古いバージョンの.soが「詰まる」と、デプロイが完全に壊れちゃうこともあるから、単一のバイナリをデプロイするよりもずっと厄介だよね(新しいバージョンか古いバージョンしか手に入らない、壊れたサービスはダメ)。
確かに、彼らはGoogleで働いてたから、エンジニアリングの判断が普通じゃないんだよね。25 GiBのバイナリがスタート時の0.25%のスピードアップに価値があると判断するかもしれないし、それが数千万ドルの差につながることもある。誰もGoogleのやり方を真似するべきじゃないけど、考えるのは面白いよね。
ダイナミックリンクだからって全体のサイズが小さくなるわけじゃないよ。逆に言うと、DLLはデッドコード削除の障壁になるからね。25GBはヤバすぎるし、開発の初期段階で何かがひどく間違ったに違いないよ(デバッグ情報を含めて出荷するのも、そもそも意味がわからないし)。
どうせ全部が一種のコンテナ(Dockerじゃないけど)に入ってるから、全然変わらないよ。もし、アプリが動く可能性のあるすべてのボーグマシンにそのライブラリをベースイメージとして配布することを提案してるなら、それは明らかに無理だね。
> 「25GiBを超えるバイナリを見たことがあるけど、デバッグシンボル込みでどうして可能なの?」 こういう会社は、スタティックビルドでサービスを構築して、起動を早くしたりデプロイを簡単にしたりするのが好きなんだよね。世界最大のコードベースのいくつかで全てのコードをスタティックに含めるのは、巨大なバイナリを生むレシピだよ。いいスタティックバイナリを一つのアーティファクトとして配布したい気持ちはすごく分かるけど、果たしてそれが本当に価値があるのかって考えなきゃいけないよね。実行可能なコードが2GBにも収まらないなら、それは本当に一つのバイナリのコードなのか、それとも分けるべきアプリが何個かあるのか、考えた方がいいんじゃない?それとも、たまには送るアーティファクトがtarballやOCIイメージ、systemd用のEROFSイメージ、自解凍アーカイブなんだって受け入れるべきかもね。 [0] 実際、今やってるプロジェクトの一つは、太ったELFバイナリを作るのがそんなに難しいのかを調べてるんだ。 [1] https://systemd.io/PORTABLE_SERVICES/ [2] https://justine.lol/ape.html > 「PKZIPの実行ファイルは結構いいコンテナになる」
これ、Googleで働いてた時もずっと気になってたことなんだ。すごい計算とストレージのインフラがあって、年々クレイジーになっていったけど(パフォーマンス、スケーラビリティ、冗長性の面で)、バイナリのサイズが大きすぎて、運用が遅く感じてた。コマンドラインのバイナリを実行するのも遅いし、デプロイ用のバイナリをビルドするのも遅い。バイナリをデプロイするのも遅い。バイナリのサイズが増えるたびに「インフラをスケールアップしよう!」って答えが返ってくるだけで、「こんなクレイジーなことはやめよう」って考えにはならなかった。私が辞める頃には、後者に向けた新しい取り組みが始まって、「もっと早く制限を設けるべきだったかも」って感じがあったけど、既存の膨張に制限を後付けするのはすごく難しいことだった。
もし25GBの実行ファイルがあるなら、それが一つのバイナリ実行ファイルか100個かは関係ないと思う。何かがひどくひどく間違ってる。4GBのバイナリを見たことはないけど、PDBファイルが4GBに達して問題が起きたことはある。デバッグシンボルがそんなに大きくなるのは全然あり得ることだね。それについてはまあ、いいかな。
> https://systemd.io/PORTABLE_SERVICES/ Systemdとポータブル?
俺にとって驚きなのは、-gsplit-dwarfを使わないでデバッグ情報と「普通サイズ」のバイナリを分けないことだね。
> ただ、最も簡単な解決策は -mcmodel=large を使うことで、全ての相対CALL命令を絶対JMPに変えることだよね。理にかなってるけど、その後のアセンブリ出力にはJMP命令が一つもない。代わりに、CALLは64ビットレジスタにアドレスを入れてからCALLするように置き換えられてる。これも理にかなってるけど、じゃあなんでJMPのことを言うの?間違いなのか、それとも何か見落としてるのかな?(いくつかのCALLはJMPに置き換えられるのは知ってるけど、それは -mcmodel=large に関係なく行われることだよね)
CALLをJMPと呼ぶような緩い言語だと思うけど、大きなコードモデルを嫌う理由の2つのうち、レジスタプレッシャーはその特定のスニペットには関係ないよ。コールを実行してるから、ABIはコール間で保持されないレジスタを定義してるし、そのうちの1つに宛先を書き込んでもレジスタプレッシャーには影響しないよ。
著者は、構造が8バイトのJMP命令に似ていることを指摘しているだけだと思う。今のテキストはこうなってる:> しかし、最も簡単な解決策は、-mcmodel=largeを使うことで、すべての相対CALL命令を絶対64ビットのものに変えることだ。JMPみたいな感じだね。(戻りアドレスをプッシュするためにCALLを使う必要があるけど。)
デバッグシンボルのサイズがリロケーションジャンプ距離に影響を与えるべきじゃないよ。デバッグ情報には独自のELFセクションがあるから。FAANGかどうかに関わらず、実行しているものが2GBの大きさの.textセクションを必要とすることはないはず。もしその制限にぶつかってるなら、ビルドプロセスにリンクステップでデッドコードの排除が欠けてる可能性が高いよ。リリースビルドにはLTOを使うべきだし、従来の解決策(-ffunction-sectionsでオブジェクトファイルをコンパイルして、--gc-sectionsでリンクする)でも、関数レベルの粒度でデッドコードを排除するのに効果的だよ。
FAANGはLTOの設計に深く関わってるよ。例えば、https://research.google/pubs/thinlto-scalable-and-incrementa... などの参考文献もあるし。それでも…
Google Chromeは私のマシンで500MBのバイナリとして出荷されてるから、ウェブブラウザを埋め込むなら、最低でもそれくらいは必要だよね。アプリケーションに必要なものを加えると、気をつけないと2GBを超えるのは簡単にわかるよ。(ちなみに、ここで道徳的な判断をしてるわけじゃなくて、単に可能だと言ってるだけ。実際にそうなるべきかは別の話。)
> 「私たちは小さなコードモデルを維持したいです。他にどんな戦略を取れるでしょうか?ホットBBを近くにまとめるってことですよね?」 Facebookの解決策: https://github.com/llvm/llvm-project/blob/main/bolt%2FREADME... Googleの: https://lists.llvm.org/pipermail/llvm-dev/2019-September/135...
でも、x86_64の場合、今のところ、もし1回の呼び出しで31ビット以上が必要なら、全体のコードセクションを大きなコードモデルにアップグレードしなきゃいけないんだ。BOLTは、ホットコードを近くに置くことでキャッシュのローカリティを重視していて、2GiBの壁を本当に破るわけじゃないと思う。
25GBは過剰に思えるけど、基本的なコンパイルツールチェーンは静的にコンパイルされた実行可能ファイルとして持ってるよ。何かがうまくいかないときは、これが単純にうまく機能するんだ。
> 「他にどんな戦略を取れるでしょうか?」 サンクやトランポリンを使うことができるよ。lldは一部のアーキテクチャ用にそれを作れるし、おそらくx86_64用にもできるはず。ただ、なぜあなたのケースではそうならなかったのかはわからないけど。大きなコードモデルと同様に、トランポリンを追加するのは高コストになることがあるよ。icacheのパフォーマンスや、特にホットパスにトランポリンがある場合の実行においてね。
ある意味で、PLTもそういうもんだよね。次の投稿でそれを掘り下げるつもり。GOTに関していくつか問題があって、解決策を探さなきゃいけない。これは主に自分のために書いてるんだ。サンクがあるときのコードモデルのアイデアは、正直言って不要に感じる。
フォローアップ: https://fzakaria.com/2025/12/29/huge-binaries-i-thunk-theref...
> 私の出版物の提出に対する反応は、こういった問題は存在しないと主張することが多かった。 ソフトウェアエンジニアのコミュニティでもよく見かけるけど、スケールの制限を知らない人たちが、研究は必要ないって言ってるのを見ると、ちょっとイラッとする。
確かに!でも、ここには数字のトリックがあって、デバッグ情報付きの25GBのバイナリと、.textセクションの最大オフセットが2GBの話をしてるんだよね。その25GBのバイナリのうち、たぶん24.5GBはデバッグ情報だよ。>2GBの呼び出しが問題になるのは、本当に巨大なバイナリに入ってからだと思う。(LTOビルドがここで賢いことができるかどうかは気になるけど、特に洞察はない。ほとんどの呼び出しはローカルだけど、少数の遠くの呼び出しは高価なスペルを使うことができる。)
Googleでは、約25GBのストリップされた統計集約バイナリで作業してたんだけど、分散ビルドシステムはデバッグ版すらビルドできなかったんだ。オブジェクトファイルの最大サイズを超えちゃってたからね。誰かがそれを別のパイプラインに分けようとしたかどうかは聞かなかったけど、直感的には、ビジネスロジックをそんな風に分けるのはオーバーヘッドが大きすぎて意味がないと思う。必要な入力ログがメモリに入ったら、データサイズとコードサイズの比率が劇的に大きいから、必要なことは全部やっちゃった方がいいよね。
> […] .textセクションの最大オフセットは2GB … x86 ISAでは、32ビットのジャンプ/コールオフセットがオペコードに直接エンコードされているからね。ほとんどのRISCアーキテクチャではPC相対分岐を許可しているけど、オフセットは比較的小さい。32ビットのオペコードには大きなオフセットを収める余裕がないから。「長い」ジャンプやコールは、レジスタを介して行われる間接分岐/コールで、64ビット全体が使える(RISCアーキテクチャではアドレス整列ルールが適用される)。ただし、ターゲットアドレスは事前にロードまたは計算しておく必要があるよ。RISCとx86の64ビットアーキテクチャで利用可能。
注意してほしいのは、SHF_ALLOCフラグのないセクション、例えば`.debug_*`セクションは、再配置距離の圧力には寄与しないってこと。10GiB以上のバイナリ(おそらくスプリットDWARFを使ってないから)でも、コード+データはずっと小さくて、限界には全然近くないかもしれない。でも、Google、Meta、ByteDanceは、巨大なC++サーバーバイナリでx86-64の再配置距離の問題に直面してる。私の知る限り、他のドメインの業界ユーザーはこの問題に遭遇してないみたい。この問題に対処するために、Googleは約2年前にサニタイザーとPGO計測ビルドのために中間コードモデルを採用したんだ。CUDAファットバイナリも問題を引き起こした。孤立セクションのためのリンカースクリプト`INSERT BEFORE/AFTER`(https://reviews.llvm.org/D74375)が重要な緩和策として機能したと思う。AArch64/Powerに似た範囲拡張サンクABIがx86-64 psABIのために定義されることを願ってる。今のところ、-mcmodel=largeでの長い分岐のペシミゼーションよりはマシだと思う。--- 誰もこの.eh_frame_hdrの実装制限に遭遇していないみたいだね。* `.eh_frame_hdr -> .text`: GNU ldとld.lldは、2025年12月時点で32ビットオフセット(`table_enc = DW_EH_PE_datarel | DW_EH_PE_sdata4;`)しかサポートしていないよ。