要旨
長期運用しているクローラーでは、保存データ量がそのままストレージ費用に反映されます。圧縮率の改善は、インフラ費用とアーカイブ運用の両方に効く、継続的に取り組む価値のあるテーマです。
今回の検証では、まず Zstandard(以下 zstd)を辞書なしで使う標準方式を参照値として測定しました。そのうえで、サイト単位で学習した辞書を各レコードに適用するサイト単位辞書方式を比較しました。
結果として、サイト単位辞書方式は標準方式と比べてアーカイブ本体サイズ比を 25.7〜66.1% 削減しました。さらに、共有辞書の学習サンプル、辞書サイズ、学習条件を調整すると、辞書込み実効サイズ比は元データの 7.689〜14.251% になりました。
zstd の共有辞書は、クローラーの蓄積データに対して保存効率を改善する有力な選択肢だと考えられます。一方で、辞書を使う方式では、書き込み、読み込み、再学習、互換性管理で考慮点が増えます。圧縮率だけで採用を決めず、保存形式全体の設計として評価するのが現実的です。
共有辞書の基本
zstd の辞書圧縮は、代表的なサンプルから辞書を学習し、その辞書を圧縮時と展開時の両方で参照する方式です。辞書には、対象データに繰り返し現れるバイト列や構造が入ります。圧縮対象のレコードが短い場合でも、辞書側に共通パターンがあれば、個々のレコードをより短く表現できます。
クローラーが保存するページには、HTML テンプレート、メタデータ、定型的な属性値、本文の書式、サイト固有のマークアップなどが繰り返し含まれます。共有辞書方式の狙いは、こうした共通部分を各ページの圧縮データに繰り返し持たせるのではなく、辞書側に寄せることです。
ここでいう共有辞書とは、1 つのページ専用の辞書ではなく、複数のページで共有する辞書です。たとえば、あるサイトの一定期間のページ群から辞書を学習し、そのサイトの保存レコードに同じ辞書を使います。圧縮データには、復元に必要な辞書 ID や辞書世代を記録します。
この方式では、圧縮データだけでは展開できません。読み込み時には、圧縮時に使った辞書を解決し、展開処理に渡す必要があります。したがって、共有辞書は圧縮率の改善手段であると同時に、保存形式に新しい管理対象を追加する設計です。
クローラー保存データでの償却
共有辞書を使う場合、辞書ファイルそのものも保存対象になります。したがって、評価では「各レコードの圧縮後サイズ」だけでなく、「共有辞書のサイズをどの保存量に負担させるか」も見る必要があります。
たとえば 20MiB の辞書を 1GiB の保存データだけで使う場合、辞書の負担は相対的に大きくなります。同じ 20MiB の辞書を 16GiB の保存データで共有できる場合、1 レコードあたり、または 1GiB あたりの辞書負担は小さくなります。この考え方を、本稿では辞書サイズの償却と呼びます。
今回の比較では、まずアーカイブ本体サイズ比を見ます。これは、評価対象レコードを圧縮した後のアーカイブ本体サイズを、圧縮前の本文サイズで割って求めます。
アーカイブ本体サイズ比 = 圧縮後のアーカイブ本体サイズ / 圧縮前の本文サイズ
共有辞書を使う方式では、別に辞書込み実効サイズ比も見ます。これは、アーカイブ本体に共有辞書の償却分を加えた実効保存量を、圧縮前の本文サイズで割った比率です。
辞書込み実効サイズ比 = (圧縮後のアーカイブ本体サイズ + 共有辞書の償却分) / 圧縮前の本文サイズ
この指標を見ることで、アーカイブ本体だけでは有利に見える大きな辞書が、辞書サイズ込みでも採用できるかを確認できます。
検証方法
対象は 3 種類のサイトデータです。本文ではサイト名を匿名化し、Site A、Site B、Site C と表記します。
| ケース |
主な言語 |
サイト種別 |
| Site A |
日本語 |
小説投稿サイト |
| Site B |
日本語 |
小説投稿サイト |
| Site C |
英語 |
百科事典型サイト |
検証では、データを 3 つの役割に分けます。辞書を作るための学習データ、候補辞書を比べるための選定用サンプル、最後に結果を確認するための評価データです。評価データは辞書学習や候補選定には使わず、選んだ候補を全件に適用してアーカイブ本体サイズ比を測るために使います。
まず、標準方式とサイト単位辞書方式を比較します。標準方式は、辞書管理を持たない通常の zstd 圧縮です。辞書方式との比較条件をそろえるため、圧縮レベルは level 22 にしています。
サイト単位辞書方式は、サイト単位で学習した辞書を各レコードの zstd 圧縮に適用する構成です。以下では短く、サイト辞書とも呼びます。辞書方式を導入したときに、まず到達しやすい基準と考えられます。
標準方式とサイト辞書の比較
辞書を使わない標準方式を参照値として置きます。標準方式には共有辞書がないため、ここではアーカイブ本体サイズ比だけを示します。
| ケース |
アーカイブ本体サイズ比 |
| Site A |
26.604% |
| Site B |
32.554% |
| Site C |
14.011% |
次に、サイト辞書を同じ償却対象データ量で見ます。サイト辞書でも共有辞書を保存するため、辞書込み実効サイズ比は償却対象データ量によって変わります。
| ケース |
償却対象データ量 |
アーカイブ本体サイズ比 |
辞書込み実効サイズ比 |
共有辞書サイズ |
| Site A |
1GiB |
9.014% |
9.112% |
1.00 MiB |
| Site A |
8GiB |
9.014% |
9.026% |
1.00 MiB |
| Site A |
16GiB |
9.014% |
9.020% |
1.00 MiB |
| Site B |
1GiB |
15.083% |
15.181% |
1.00 MiB |
| Site B |
8GiB |
15.083% |
15.095% |
1.00 MiB |
| Site B |
16GiB |
15.083% |
15.089% |
1.00 MiB |
| Site C |
1GiB |
10.415% |
10.659% |
2.50 MiB |
| Site C |
8GiB |
10.415% |
10.446% |
2.50 MiB |
| Site C |
16GiB |
10.415% |
10.430% |
2.50 MiB |
この結果から、zstd 辞書方式では、まず辞書の有無が結果に影響していることが分かります。特に Site A と Site B では、単純なサイト辞書だけでアーカイブ本体サイズ比が半分以下になっています。
Site C は英語の定型的な HTML データで、標準方式の時点でも Site A、Site B より小さく圧縮されています。ただし、これは「英語のほうが常に縮みやすい」という単純な話ではありません。文字種の違いに加えて、HTML テンプレートの反復量、ページ長の分布、メタデータや属性値の比率が重なった結果として見るのが妥当です。
圧縮と展開のフロー
辞書なしの標準方式では、圧縮データだけで展開できます。保存形式としては単純で、データの寿命が長いアーカイブでも扱いやすい構成です。
書き込み:
HTTP レスポンス本文
-> zstd 圧縮
-> 圧縮データを保存
読み込み:
圧縮データを取得
-> zstd 展開
-> HTTP レスポンス本文
共有辞書方式では、圧縮時と展開時の両方で同じ辞書が必要になります。保存データには、圧縮データ本体に加えて、どの辞書で圧縮したかを示す辞書 ID を記録します。
書き込み:
HTTP レスポンス本文
-> 対象サイトまたは対象データ群の辞書 ID を決定
-> 対応する共有辞書を使って zstd 圧縮
-> 圧縮データと辞書 ID を保存
読み込み:
圧縮データと辞書 ID を取得
-> 辞書 ID から共有辞書を解決
-> 共有辞書を使って zstd 展開
-> HTTP レスポンス本文
方式ごとの違いを整理すると、次のようになります。
| 観点 |
標準方式 |
共有辞書方式 |
| 圧縮時 |
レコード単体で圧縮できる |
辞書 ID の決定と辞書ロードが必要 |
| 展開時 |
圧縮データだけで復元できる |
圧縮時と同じ辞書が必要 |
| 保存メタデータ |
圧縮方式とレベルが中心 |
辞書 ID、辞書世代、互換性情報が必要 |
| 再学習 |
不要 |
新しい辞書を成果物として管理する |
| 障害時 |
圧縮データがあれば復元可能 |
辞書欠損時のリカバリが必要 |
この違いは、圧縮率の改善と引き換えに発生するアーキテクチャ上のコストです。共有辞書方式を採用する場合は、圧縮後サイズだけでなく、辞書 ID の管理、辞書の配布、辞書キャッシュ、古い辞書の保持期間を合わせて設計します。
圧縮・展開ベンチマーク
共有辞書方式では、圧縮時と展開時の両方で、辞書がどこにあるかによって性能特性が変わります。特にランダムアクセスが多いワークロードでは、辞書キャッシュが効く場合と効かない場合の差が出ます。
参考値として、Site A の評価データから 200 件を取り出し、標準方式と共有辞書方式の圧縮・展開時間を測りました。圧縮レベルは level 22、共有辞書サイズは 2.00MiB、測定は 5 回実行し、中央値を示しています。測定対象の raw データは 10.61MiB、1 レコードあたり平均 53.1KiB です。
| 方式 |
辞書状態 |
圧縮中央値(200件) |
展開中央値(200件) |
圧縮後サイズ比 |
| 標準方式 |
辞書なし |
2,155.9ms |
5.5ms |
26.36% |
| 共有辞書方式 |
メモリ上に辞書あり |
4,060.7ms |
2.9ms |
9.35% |
この測定では、共有辞書方式の圧縮は標準方式より遅くなりました。一方で、展開は辞書がメモリ上にある場合、標準方式より短くなっています。これは、このサンプルでは辞書方式の圧縮後サイズが小さく、展開する入力バイト数が減っているためと考えられます。ただし、展開時間はCPU、データサイズ、辞書キャッシュの状態に影響されるため、この結果だけで一般化するのは避けます。
辞書ロードの影響を見るため、同じ圧縮データに対して展開時の辞書解決を変えた測定も行いました。
| 辞書の扱い |
展開中央値(200件) |
説明 |
| メモリ上の辞書を再利用 |
2.9ms |
アプリケーション側で辞書キャッシュが効いている状態 |
| ローカルファイルから1回ロード |
5.9ms |
200件の展開前に辞書を1回読み込む状態 |
| レコードごとに辞書をロード |
38.7ms |
キャッシュが効かず、各レコードで辞書解決が発生する状態 |
ローカルファイルからのロードは、OSのファイルキャッシュが効いた可能性があります。そのため、永続ストレージの物理I/Oを代表する値ではなく、同一環境での参考値として扱います。
ネットワークから辞書を取得するケースは、今回のローカル測定では実測していません。概算する場合は、次のように分解します。
ネットワーク辞書取得時の読み込み時間
~= 圧縮データ取得時間 + ネットワーク往復遅延 + 辞書転送時間 + 展開時間
辞書転送時間
~= 共有辞書サイズ / 実効スループット
大きな共有辞書を採用すると、アーカイブ本体サイズ比は改善しやすくなります。一方で、辞書キャッシュが効かない読み込みでは、辞書サイズがそのまま追加取得量になります。したがって、保存量の評価では辞書込み実効サイズ比を見ますが、性能評価ではキャッシュヒット率、辞書ロード時間、辞書切り替え頻度を別に測る必要があります。
共有辞書のチューニング
ここからは、標準的なサイト辞書からさらに保存効率を改善するために試したチューニング手法です。zstd の共有辞書そのものの一般論ではなく、クローラーの保存データに合わせて辞書を設計するための候補探索です。
今回のチューニングでは、辞書サイズ、学習サンプルの作り方、辞書学習パラメータ、候補選定の安定性を調整しました。各要素の役割は次のとおりです。
| 手法 |
変えるもの |
期待する効果 |
増えるコスト |
| Large Dictionary |
共有辞書サイズ |
長い定型構造やページ種別ごとの差分を辞書に入れやすくする |
辞書保存量、ロード時間、メモリ使用量 |
| 学習サンプル設計 |
辞書に渡すレコード構成 |
URL構造、本文長、圧縮しにくいレコードの偏りを抑える |
学習前処理、サンプル管理 |
| 学習パラメータ調整 |
サンプル数やtrainer条件 |
保存形式を変えずに辞書の中身を調整する |
再現性管理、build時間 |
| 安定性診断 |
要求辞書サイズと実辞書サイズのズレ、クラスタ数 |
圧縮率だけが良い不安定な候補を避ける |
診断値の記録と判定ロジック |
Large Dictionary は、辞書ファイルを大きくするだけでは効果が安定しにくい場合があります。大きくした容量に保存量へ効くパターンを入れるため、学習サンプル設計と組み合わせて評価します。
学習サンプル設計では、URL 構造、本文長、訓練データ上での圧縮しにくさを使って、辞書に渡すレコードの代表性を整えます。これは読み込み時に多数の辞書を切り替えるためではありません。単一または少数の共有辞書を維持したまま、辞書に入る素材の偏りを抑えるための設計です。
チューニング手法の比較
チューニング候補は、共有辞書をどの程度の保存量で償却できるかによって採用しやすさが変わります。小さな保存量に対して大きな辞書を使うと、アーカイブ本体サイズ比が良くても、辞書込み実効サイズ比では不利になる場合があります。
次の表は、償却対象データ量ごとに、サイト辞書とチューニング候補の辞書込み実効サイズ比を比較したものです。
| ケース |
償却対象データ量 |
サイト辞書 実効サイズ比 |
チューニング候補 実効サイズ比 |
チューニング候補 共有辞書サイズ |
| Site A |
1GiB |
9.112% |
8.467% |
6.25 MiB |
| Site A |
8GiB |
9.026% |
7.819% |
22.99 MiB |
| Site A |
16GiB |
9.020% |
7.689% |
22.99 MiB |
| Site B |
1GiB |
15.181% |
14.251% |
6.25 MiB |
| Site B |
8GiB |
15.095% |
13.310% |
20.96 MiB |
| Site B |
16GiB |
15.089% |
13.191% |
20.96 MiB |
| Site C |
1GiB |
10.659% |
9.952% |
6.25 MiB |
| Site C |
8GiB |
10.446% |
9.540% |
30.43 MiB |
| Site C |
16GiB |
10.430% |
9.317% |
80.00 MiB |
チューニング候補のアーカイブ本体サイズ比と共有辞書サイズは次のとおりです。
| ケース |
償却対象データ量 |
アーカイブ本体サイズ比 |
辞書込み実効サイズ比 |
共有辞書サイズ |
| Site A |
1GiB |
7.618% |
8.467% |
6.25 MiB |
| Site A |
8GiB |
7.394% |
7.819% |
22.99 MiB |
| Site A |
16GiB |
7.394% |
7.689% |
22.99 MiB |
| Site B |
1GiB |
13.665% |
14.251% |
6.25 MiB |
| Site B |
8GiB |
12.908% |
13.310% |
20.96 MiB |
| Site B |
16GiB |
12.908% |
13.191% |
20.96 MiB |
| Site C |
1GiB |
9.803% |
9.952% |
6.25 MiB |
| Site C |
8GiB |
9.327% |
9.540% |
30.43 MiB |
| Site C |
16GiB |
8.963% |
9.317% |
80.00 MiB |
同じアーカイブ本体サイズ比の行で、辞書込み実効サイズ比だけが変わる場合があります。これは同じ候補を選んでいても、共有辞書を負担する保存量が大きくなることで、評価データあたりの辞書コストが小さくなるためです。一方で、Site C の 16GiB では、より大きな辞書を使う候補のほうが実効保存量を下げています。
自動辞書トレーニングの診断とガードレール
辞書は、学習データを渡せば常に意図どおりのサイズと性質で得られるわけではありません。今回の検証では、要求した辞書サイズと実際に生成された辞書サイズのズレを診断値として扱いました。
大きめの辞書サイズを要求しても、trainer が返す実際の辞書が大きく下回る場合があります。この状態を単純に失敗とみなす必要はありませんが、圧縮率が伸びず、かつ実際の辞書サイズが要求サイズに対して極端に小さい場合は、不安定な候補として扱います。
診断では、次の値を記録します。
| 診断値 |
目的 |
| 要求辞書サイズ |
trainer に要求した辞書サイズ |
| 実辞書サイズ |
実際に生成された辞書サイズ |
| 実辞書サイズ / 要求辞書サイズ |
要求に対して辞書がどの程度作られたか |
| 学習サンプル数 |
辞書学習に使ったレコード数 |
| 学習データの総バイト数 |
辞書学習に使ったデータ量 |
| 縮退フラグ |
辞書が不安定な状態に見える候補を識別するためのフラグ |
この検証では、実辞書サイズが要求辞書サイズの 25% 未満で、性能も良くなく、学習データ量に対して辞書が十分に形成されていない候補を、不安定な点として扱う方針にしました。不安定な点は即座に失格にするのではなく、同等性能の安定した候補があれば選ばない、という扱いです。
また、クラスタ型の候補では、学習されたクラスタ数や代替扱いになったクラスタ数も見ます。候補の圧縮率が良く見えても、解釈可能なクラスタ構造がほとんど残っていない場合は、運用に載せる候補としては扱いにくくなります。
候補選定では、圧縮後サイズだけでなく、次の観点も合わせて見ます。
| 観点 |
採用方針 |
| 圧縮後サイズ |
最小値から一定範囲内なら同等候補として扱う |
| build 時間 |
同等候補では短いものを優先する |
| 辞書や補助成果物のサイズ |
同等候補では小さいものを優先する |
| 実辞書サイズの安定性 |
実辞書サイズが極端に崩れていないものを優先する |
| 構造の解釈可能性 |
クラスタ数などが極端に潰れていないものを優先する |
この方針は、保存量の最小化だけを追うよりも保守的です。ただし、長期保存のアーカイブでは、わずかな圧縮率差より、再現性、展開可能性、運用時の説明しやすさが重要になる場面があります。
Appendix: チューニング手法の具体例
ここからは、チューニング手法を疑似コードで整理します。実行可能な Python コードではなく、方式ごとに何を変えているかを説明するためのものです。実際の保存形式では、圧縮データに加えて、復元に必要な辞書 ID や辞書世代もメタデータとして保存します。
normalize_route、sample_quota、train_dictionary_with_options などは抽象化した補助関数です。実装では、サイトの URL 体系、本文長の分布、保存対象の読み込みパターンに合わせて定義します。
標準方式は、各レコードをそのまま zstd で圧縮します。
import zstandard as zstd
LEVEL = 22
KiB = 1024
MiB = 1024 * KiB
SITE_DICTIONARY_SIZE = 256 * KiB
LARGE_DICTIONARY_SIZE = 2 * MiB
def compress_standard(records):
compressor = zstd.ZstdCompressor(level=LEVEL)
archive = []
for record in records:
payload = compressor.compress(record.body)
archive.append({
"record_id": record.id,
"dictionary_id": None,
"payload": payload,
})
return archive
上記の辞書サイズは説明用の例です。実運用では、保存量、辞書の共有コスト、読み込み時のメモリ使用量を見ながら決めます。
サイト単位辞書方式では、学習データからサイトごとの辞書を作り、保存対象レコードにはその辞書を適用します。
def train_site_dictionary(train_records):
samples = [record.body for record in train_records]
return zstd.train_dictionary(SITE_DICTIONARY_SIZE, samples)
def compress_with_site_dictionary(site_id, train_records, records):
dictionary = train_site_dictionary(train_records)
compressor = zstd.ZstdCompressor(level=LEVEL, dict_data=dictionary)
dictionary_id = f"{site_id}:site:v1"
archive = []
for record in records:
payload = compressor.compress(record.body)
archive.append({
"record_id": record.id,
"dictionary_id": dictionary_id,
"payload": payload,
})
return archive
学習サンプル設計では、辞書に渡すレコードをそのまま全件並べるのではなく、URL 構造、本文長、訓練データ上での圧縮しにくさを使って代表性を整えます。bucket は辞書を分けるためではなく、学習サンプルを偏らせないために使います。
def select_dictionary_samples(train_records):
buckets = {}
for record in train_records:
route = normalize_route(record.url)
length = length_bucket(len(record.body))
hardness = standard_compression_ratio(record.body)
key = (route, length, hardness_bucket(hardness))
add_record_to_bucket(buckets, key, record)
samples = []
for key, records in buckets.items():
quota = sample_quota(
bucket_key=key,
record_count=len(records),
raw_bytes=sum(len(record.body) for record in records),
)
samples.extend(sample_records(records, quota))
samples = rebalance_by_raw_bytes(samples)
samples = deduplicate_near_identical_samples(samples)
return samples
辞書学習パラメータ調整では、同じ保存形式のまま、辞書サイズ、学習に使うサンプル数、trainer 条件の組み合わせを変えます。辞書は学習データから作り、候補選定用のサンプルでアーカイブ本体サイズ比を比較します。
def train_dictionary_candidates(samples):
training_plans = [
{
"name": "site-sized",
"dictionary_size": SITE_DICTIONARY_SIZE,
"sample_limit": 2_000,
"trainer_options": {"search": "standard"},
},
{
"name": "large-balanced",
"dictionary_size": LARGE_DICTIONARY_SIZE,
"sample_limit": 8_000,
"trainer_options": {"search": "wide"},
},
]
candidates = []
for plan in training_plans:
selected = limit_samples(samples, plan["sample_limit"])
dictionary = train_dictionary_with_options(
dictionary_size=plan["dictionary_size"],
samples=[record.body for record in selected],
trainer_options=plan["trainer_options"],
)
candidates.append({
"name": plan["name"],
"dictionary": dictionary,
"dictionary_size": plan["dictionary_size"],
"trainer_options": plan["trainer_options"],
})
return candidates
Large Dictionary は、基本構造はサイト単位辞書方式と同じですが、辞書容量を大きく取り、上記のサンプル設計と学習条件の調整を組み合わせます。圧縮時に使う辞書は、サイト単位または少数の大きな辞書のままです。
def train_shared_dictionary(train_records, selection_records):
samples = select_dictionary_samples(train_records)
candidates = train_dictionary_candidates(samples)
best = choose_smallest_stable_candidate(
candidates,
selection_records,
)
return best["dictionary"]
def compress_with_shared_dictionary(site_id, dictionary, records):
compressor = zstd.ZstdCompressor(level=LEVEL, dict_data=dictionary)
dictionary_id = f"{site_id}:large:v1"
archive = []
for record in records:
payload = compressor.compress(record.body)
archive.append({
"record_id": record.id,
"dictionary_id": dictionary_id,
"payload": payload,
})
return archive
候補選定では、圧縮後サイズだけでなく、辞書の安定性も見ます。
def choose_smallest_stable_candidate(candidates, selection_records):
measurements = []
for candidate in candidates:
compressed_bytes = replay_on_selection_sample(
candidate["dictionary"],
selection_records,
)
diagnostics = inspect_dictionary_training(candidate)
measurements.append({
"candidate": candidate,
"compressed_bytes": compressed_bytes,
"actual_dictionary_size": diagnostics.actual_dictionary_size,
"requested_dictionary_size": diagnostics.requested_dictionary_size,
"build_seconds": diagnostics.build_seconds,
"artifact_bytes": diagnostics.artifact_bytes,
"is_unstable": diagnostics.is_unstable,
})
best_bytes = min(row["compressed_bytes"] for row in measurements)
tie_threshold = max(32, best_bytes * 0.005)
comparable = [
row
for row in measurements
if row["compressed_bytes"] <= best_bytes + tie_threshold
]
stable = [row for row in comparable if not row["is_unstable"]]
pool = stable if stable else comparable
return min(
pool,
key=lambda row: (
row["artifact_bytes"],
row["build_seconds"],
row["actual_dictionary_size"],
),
)["candidate"]
この疑似コードの要点は、最小の圧縮後サイズを出した候補を常に採用するわけではないことです。差が小さい場合は、辞書サイズ、build 時間、実辞書サイズの安定性を見て、長期運用しやすい候補を選びます。
まとめ
今回のデータでは、zstd の共有辞書によって保存量の低下が確認できました。元データを 100% とすると、標準方式のアーカイブ本体サイズ比は 14.011〜32.554%、サイト辞書のアーカイブ本体サイズ比は 9.014〜15.083% でした。
辞書込み実効サイズ比で見ると、サイト辞書は 1GiB で 9.112〜15.181%、8GiB で 9.026〜15.095%、16GiB で 9.020〜15.089% でした。さらに共有辞書をチューニングすると、1GiB で 8.467〜14.251%、8GiB で 7.819〜13.310%、16GiB で 7.689〜13.191% になりました。
共有辞書方式は、保存量を下げる余地がある一方で、辞書 ID、辞書世代、辞書キャッシュ、再学習、欠損時の復元手順を設計に含める必要があります。また、自動辞書トレーニングでは、要求辞書サイズと実辞書サイズのズレや、学習結果の縮退を診断し、同等性能なら安定した候補を選ぶほうが運用しやすくなります。
圧縮率の改善、辞書込みの実効保存量、読み込みワークロードへの影響を同じ判断材料として扱うことが、クローラーの長期保存形式では重要です。