2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonのZstd辞書は小さいJSONをどこまで縮めるか

2
Posted at

PythonのZstd辞書は小さいJSONをどこまで縮めるか

小さいJSONを1件ずつZstandardで圧縮しても、圧縮レベルを上げれば素直に小さくなるとは限りません。入力が短いと、1件の中だけでは共通するバイト列を十分に利用できないからです。

Python 3.14ではcompression.zstdが標準ライブラリに入り、train_dict()とZstdDictも使えます。似たJSONを大量に保存するなら、圧縮レベルを詰める前に、データ群で辞書を共有する設計を測る価値があります。

ただし判断材料は圧縮後のサイズだけではありません。辞書そのもののサイズ、圧縮・展開時間、辞書を配布・管理するコストまで含めないと、実運用では逆転します。

小さいJSONを1件ずつ圧縮してみる

Zstandardは過去に現れたバイト列との一致を利用して圧縮します。大きな入力なら同じ入力の中から繰り返しを見つけやすい一方、数百バイト程度のJSONを独立したフレームとして圧縮すると、参照できる材料そのものが少なくなります。

Python 3.14で追加されたcompression.zstdには、1回で圧縮するcompress()のほか、インクリメンタル圧縮用のZstdCompressorがあります。また、公式ドキュメントはZstandard辞書について、小さいデータの圧縮効率を改善する用途を明示しています。

まず、同じスキーマで値だけが違うJSONを作ります。

実行環境はPython 3.14以降です。追加パッケージは使いません。

import json
import random

def make_sample(i: int) -> bytes:
    obj = {
        "id": i,
        "category": random.choice(["book", "food", "tool"]),
        "status": random.choice(["new", "active", "closed"]),
        "region": random.choice(["tokyo", "osaka", "nagoya"]),
        "enabled": i % 2 == 0,
    }
    return json.dumps(
        obj,
        separators=(",", ":"),
        sort_keys=True,
    ).encode()

samples = [make_sample(i) for i in range(5000)]

print(len(samples))
print(len(samples[0]))

ここで大事なのは、5000件を連結して一つの巨大な入力として圧縮するのではなく、各JSONを独立して保存する条件を再現することです。

連結してから圧縮すると、前のJSONに現れた"category"や"status"を後続データから参照できます。それはログをまとめてアーカイブする用途なら妥当ですが、1件ずつ読み書きするKVストアやキャッシュとは条件が違います。

辞書が効くか確認したいなら、保存単位も実運用に合わせる必要があります。

辞書はJSONごとではなく似たデータ群から作る

PythonのZstd辞書は小さいJSONをどこまで縮めるか

Zstandard辞書は、圧縮対象ごとに作るものではありません。

train_dict(samples, dict_size)は複数のサンプルから辞書を学習します。たとえば「同じ種類の商品JSON」「同じイベント形式のログ」のように、構造や語彙が似ているデータ群で一つの辞書を共有するイメージです。

Python 3.14のAPIでは次のように書けます。

from compression import zstd

train_samples = samples[:4000]
test_samples = samples[4000:]

dictionary = zstd.train_dict(
    train_samples,
    dict_size=4096,
)

plain = [
    zstd.compress(data)
    for data in test_samples
]

with_dict = [
    zstd.compress(data, zstd_dict=dictionary)
    for data in test_samples
]

print("raw:", sum(map(len, test_samples)))
print("zstd:", sum(map(len, plain)))
print("dict:", sum(map(len, with_dict)))
print("dictionary:", len(dictionary.dict_content))

ここでは4000件で辞書を作り、残り1000件を評価しています。

5000件すべてから辞書を作って、その5000件を圧縮して「これだけ縮んだ」と評価するのは避けます。辞書が評価対象のデータをすでに見ているからです。

機械学習ほど複雑な話ではありませんが、考え方は同じです。運用開始後に入ってくる未知のJSONへ辞書を適用したときの性能を知りたいので、学習用と評価用を分離します。

さらに、分割は単なるランダム分割だけでなく、実データの変化も意識したほうがいいです。たとえばJSONのスキーマが月単位で変化するなら、古い期間で辞書を作り、新しい期間を評価側に置くほうが本番条件に近くなります。

圧縮率だけでなく時間と辞書代も測る

辞書を使った結果だけを見て採用を決めると、一つ大きな項目が抜けます。

辞書自身にもサイズがあります。

1000件の圧縮結果が合計で数KB小さくなっても、そのために数KBの辞書を一緒に保存するなら、データ量によっては得をしません。一方、同じ辞書を数百万件で共有できるなら、1件あたりの辞書コストはかなり小さくできます。

そこで、圧縮サイズと時間を同時に測ります。

import time
from compression import zstd

def measure(items, dictionary=None):
    start = time.perf_counter()

    compressed = [
        zstd.compress(data, zstd_dict=dictionary)
        for data in items
    ]

    compress_sec = time.perf_counter() - start

    start = time.perf_counter()

    restored = [
        zstd.decompress(data, zstd_dict=dictionary)
        for data in compressed
    ]

    decompress_sec = time.perf_counter() - start

    assert restored == items

    return (
        sum(map(len, compressed)),
        compress_sec,
        decompress_sec,
    )

normal = measure(test_samples)
dict_used = measure(test_samples, dictionary)

print("normal:", normal)
print("dictionary:", dict_used)
print("dict bytes:", len(dictionary.dict_content))

ここでは具体的な計測値を載せません。CPU、Pythonのビルド、データの長さや偏りによって結果が変わるためです。

見るべきなのは少なくとも、通常のzstdと辞書付きzstdについて、圧縮後の総バイト数、圧縮時間、展開時間です。

さらにストレージ量を比較するなら、

辞書方式の実効サイズ
= 圧縮済みデータの総サイズ
+ 配布・保存が必要な辞書サイズ

として考えます。

辞書を共有する件数が少ないほど、辞書サイズの比率は大きくなります。

逆に、似たJSONが大量にあり、同じ辞書を長く共有できるなら辞書の固定費を薄められます。つまり「JSONが小さいから辞書を使う」ではなく、どれだけ似たデータへ同じ辞書を再利用できるかが採用判断になります。

時間についても注意があります。公式ドキュメントでは、大きい辞書ほど一般に圧縮率が良くなる一方、圧縮が遅くなり、小さい辞書ではその逆になると説明されています。また、ZstdDictには辞書ロード時の処理を減らすためのdigested dictionaryがあります。

大量の短い入力を処理するなら、辞書を毎回準備するコストまでベンチマークに混ぜるのか、準備済みの辞書を再利用する定常状態を測るのかも分けたほうがいいです。

辞書サイズを固定値にしない

dict_size=4096は説明用の値で、4096バイトが適切という意味ではありません。

公式ドキュメントでは辞書サイズについて、Zstandard側の推奨として絶対的な上限を100KB以下としつつ、データによってはもっと小さくできると説明しています。大きくすれば常に得になるわけではありません。

実際に試すなら、たとえば複数の候補を同じ評価データで比較します。

from compression import zstd

for size in (1024, 2048, 4096, 8192):
    dictionary = zstd.train_dict(
        train_samples,
        dict_size=size,
    )

    total, c_sec, d_sec = measure(
        test_samples,
        dictionary,
    )

    print(
        size,
        len(dictionary.dict_content),
        total,
        c_sec,
        d_sec,
    )

この比較で見たいのは「最小の圧縮サイズを出した辞書」だけではありません。

辞書をクライアントへ配布するならバージョン管理が必要です。古い圧縮データを残すなら、そのデータを展開できる古い辞書も保持する必要があります。Pythonのdecompress()でも、辞書付きで圧縮されたデータには圧縮時と同じ辞書を渡す必要があります。

そのため私は、辞書方式を圧縮アルゴリズムの設定値というより、データフォーマットの依存関係として扱うほうが安全だと考えています。

dictionary_idのような識別情報を圧縮データと対応付け、「どの辞書なら展開できるか」を失わない設計が必要です。圧縮率が少し改善するだけなら、この運用コストのほうが大きいケースもあります。

反対に、同一スキーマの小さいJSONが大量にあり、辞書の更新頻度が低く、圧縮データと辞書の対応を管理できるなら試す価値があります。最初に圧縮レベルだけを上げるのではなく、学習データと未知の評価データを分け、辞書サイズを含む総バイト数と圧縮・展開時間を測る。その結果で、辞書を共有する単位を決めるのがよさそうです。

参考

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?