Python のソース 2.0GB を 4.75MB にする — 圧縮ではなく「表現の変換」
901 リポジトリ 2.0GB のソースコードを 4.75MB に収めた手法を、セクション別の実測データとともに解説します。zip では到達できない 421 倍という比率がなぜ可能だったのか。
公開日: 2026-08-09 圧縮静的解析データ構造バイナリフォーマット
901 個の GitHub リポジトリから集めた Python のソースコードは 2.0GB ありました。 これを 4.75MB のファイルに収めています。421 倍です。
一般的な圧縮ツールでは、テキストの圧縮率はせいぜい 5〜10 倍です。 桁が 2 つ違います。種明かしをすると、これは圧縮アルゴリズムの 勝利ではありません。そもそも保存しているものが違います。
前提: コードの大半は捨ててよい
「AI にコードを書かせるための知識」として何が必要かを考えます。
requests.get() を正しく呼ぶために必要なのは、 「get という関数が存在し、url と params と timeout を受け取り、 Response を返す」という事実です。
get の実装は必要ありません。関数の中で何行のコードが動いているか、 どんな変数名が使われているか、コメントに何が書いてあるか—— 呼び出す側には関係ありません。
そこで、次のものを最初から保存しないことにしました。
- 関数やメソッドの本体
- コメント、空行、インデント
- テストコード、サンプル、ドキュメント
- ベンダリングされた第三者ライブラリのコピー
保存するのは「何が存在し、どう繋がっているか」だけです。 2.0GB のうち、この段階で残るのは数十 MB です。
保存するもの: ファクトグラフ
残った情報を、5 種類の関係に分解します。
defines モジュールが関数/クラスを定義する
contains クラスがメソッドを持つ
inherits クラスが別のクラスを継承する
calls 関数が別の関数を呼ぶ
imports モジュールが別のモジュールを取り込む
コードは (主語, 述語, 目的語) の三つ組の集合になります。 述語は 5 種類しかないので、3 ビットで表せます。
この時点で「コード」ではなく「知識」になっています。 元のソースには戻せません。一方向の変換です。
実測: セクション別の内訳
実際のファイルは 6 つのセクションからなります。 それぞれの圧縮前後のサイズはこうなりました。
| セクション | 内容 | 圧縮前 | 圧縮後 | 比率 |
|---|---|---|---|---|
| STRTAB | 文字列表 | 18,160,487 | 2,829,440 | 15.6% |
| SYMS | シンボル表 | 2,545,790 | 1,007,396 | 39.6% |
| INDEX | 転置索引 | 1,642,145 | 723,516 | 44.1% |
| EDGES | 関係グラフ | 541,632 | 167,496 | 30.9% |
| TEMPL | 鋳型 | 37,616 | 24,292 | 64.6% |
| META | メタ情報 | 406 | 328 | 80.8% |
| 合計 | 22,928,076 | 4,752,468 | 20.7% |
注目すべきは、バイナリ化の段階で既に 2.0GB → 22.9MB になっている点です。 ここが 87 倍。その後の圧縮が 4.8 倍で、掛け合わせて 421 倍になります。
つまり主要因は圧縮アルゴリズムではなく、表現の変換です。
効いている 4 つの機構
1. 文字列インターン
同じ文字列は 1 度しか保存しません。self、str、None、**kwargs は 全体で数万回出現しますが、実体は 1 つで、あとは整数 ID です。
STRTAB が最大セクション(18MB)なのは、修飾名がすべてここに入るためです。 逆に言えば、他のセクションは整数の列でしかありません。
2. varint と差分エンコード
整数を固定長 4 バイトで書くのは無駄です。小さい値は 1 バイトで足ります。 LEB128 形式の可変長整数を使い、さらにソート済みの ID 列は 差分だけを記録します。
生の ID: [1024, 1025, 1027, 1031]
差分: [1024, 1, 2, 4] ← 大半が 1 バイトに収まる
EDGES セクションが 541KB しかないのは、143,251 本の関係を この方式で書いているからです。1 本あたり 3.8 バイトです。
3. テンプレート畳み込み
Python のコードには、同じ形のシグネチャが大量にあります。
def __repr__(self): ...
def __str__(self): ...
def close(self): ...
これらは「引数なし、戻り値注釈なし、デコレータなし」という同じ形です。 形を 1 個だけ保存し、あとは「この形のメンバー」というリストにします。
実測では 126,529 個中 24,005 個(19%)が鋳型に畳み込まれています。 鋳型自体は 37KB しかありません。
4. セクションごとの圧縮方式選択
最後に、各セクションを複数の方式(lzma、zstd、辞書つき zstd、無圧縮)で 実際に圧縮してみて、最も小さかったものを採用します。 推測せず、測ります。
このファイルでは全セクションで lzma が勝ちました。 結果として、読み込み側は標準ライブラリだけで動きます。 外部依存がないぶん、起動も速くなりました。
捨て方の設計 —— ここが一番難しい
サイズを削るだけなら簡単です。全部捨てれば 0 バイトになります。 難しいのは何を残すかです。
当初は重要度スコア(PageRank)の低いものから捨てていました。 これは失敗しました。pandas の DataFrameGroupBy.aggregate のような 主力 API が消え、ビルドスクリプトが生き残るという結果になったのです。
理由は、PageRank が「呼ばれる回数」を測るからです。 公開 API はライブラリ内部からは呼ばれません。呼ぶのは外部の利用者です。
現在は保護層方式を採っています。 「公開されていて、docstring があるもの」は刈り取りの対象から外します。 この変更により、代表 API の保持率が 90.3% から 100% になりました。
詳しくは PageRank で公開 API を選ぶと失敗する に書きました。
結果として得られたもの
| 指標 | 値 |
|---|---|
| 収録リポジトリ | 901 |
| 収録 API | 126,529 |
| 元のソース | 2.00 GB |
| 最終サイズ | 4.75 MB |
| 圧縮率 | 0.237%(421.6 倍) |
| 検索応答 | 15 ミリ秒 |
| 外部依存 | なし |
4.75MB なら、サーバーレス関数のメモリに丸ごと載ります。 検索は 15 ミリ秒で返るので、データベースも要りません。 実際、このサイトはこのファイル 1 個で動いています。
この手法が向かないもの
正直に書いておくと、限界もあります。
- コードは復元できません。 一方向の変換なので、
バックアップやアーカイブの用途には使えません。
- 動的な呼び出しは追えません。
getattrやデコレータ越しの
ディスパッチは静的解析では解決できず、記録されません。
- 実装の中身は分かりません。 アルゴリズムを読みたい場合は
元のソースを見る必要があります。
用途を「API の存在とシグネチャを知る」に限定したからこそ、 この比率が成立しています。汎用の圧縮手法ではありません。
まとめ
421 倍という数字は、圧縮アルゴリズムの性能ではなく、 何を保存しないかの設計から来ています。
用途を絞れば、捨てられるものが決まります。捨てられるものが決まれば、 残りをどう表現するかも決まります。汎用の圧縮ツールは用途を知らないので、 この判断ができません。