🌳

Parquetの圧縮方式による性能比較

2024/12/21に公開

おはようございます。ログラスの龍島(@hryushm)です。最近Parquetの発音について考える毎日を過ごしています。パーケット、パーキー、パーケ、パルケ......。

Parquetは複数の圧縮方式をサポートしています。データ分析処理を最適化するために何を選ぶべきか、簡単な実験を通じて考えてみます。
Parquetファイルの生成やクエリにはPolarsを利用します。

Parquetで利用可能な圧縮方式は公式のドキュメントに記載されています。Polarsのドキュメントでは下記のように紹介されています。

Choose “zstd” for good compression performance. Choose “lz4” for fast compression/decompression. Choose “snappy” for more backwards compatibility guarantees when you deal with older parquet readers.
(訳) 圧縮性能を重視する場合は“zstd”を選択してください。圧縮・解凍速度を重視する場合は“lz4”を選択してください。古いバージョンのParquetリーダーとの互換性を優先する場合は“snappy”を選択してください。

圧縮率を高めたければZstandard、速度重視ならLZ4、互換性が必要ならSnappyが適しているようです。
実際に比較してみましょう。実行環境はMacBookPro M1 Pro、Polarsのバージョンは1.17.1 です

書き込み性能

まずは書き込みからです。

検証スクリプト
import polars as pl
import numpy as np
import time
from datetime import datetime, timedelta

# ランダムデータの生成
n_rows = 100_000_000

# 日付データの生成
# 2024年1月1日からn秒後の日付
start_date = datetime(2024, 1, 1)
dates = [start_date + timedelta(seconds=x) for x in range(n_rows)]

# データフレームの作成
df = pl.DataFrame({
    # 連番のID
    "id": range(n_rows),
    # タイムスタンプ
    "date": dates,
    # 0から1000の一様分布からのランダム値
    "value": np.random.uniform(0, 1000, n_rows),
    # A,B,C,Dからランダムに選択されたカテゴリ
    "category": np.random.choice(["A", "B", "C", "D"], n_rows),
    # TrueとFalseからランダムに選択されたフラグ
    "flag": np.random.choice([True, False], n_rows)
})

# 圧縮形式を分けて保存
# 各圧縮形式での書き込み時間を計測
results = {c: [] for c in ["zstd", "lz4", "snappy"]}

for compression in ["zstd", "lz4", "snappy"]:
    print(f"\n{compression}での書き込み:")
    for i in range(5):
        start = time.time()
        df.write_parquet(
            f"test_data_{compression}.parquet",
            compression=compression,
        )
        end = time.time()
        elapsed = end - start
        results[compression].append(elapsed)
        print(f"  実行 {i+1}: {elapsed:.2f}秒")
    
    avg_time = sum(results[compression]) / len(results[compression])
    print(f"{compression}の平均書き込み時間: {avg_time:.2f}秒")

print("データセットを生成しました。行数:", n_rows)
結果

zstdでの書き込み:
  実行 1: 6.73秒
  実行 2: 6.49秒
  実行 3: 6.49秒
  実行 4: 6.50秒
  実行 5: 6.47秒
zstdの平均書き込み時間: 6.54秒

lz4での書き込み:
  実行 1: 4.47秒
  実行 2: 4.46秒
  実行 3: 4.49秒
  実行 4: 4.29秒
  実行 5: 4.77秒
lz4の平均書き込み時間: 4.49秒

snappyでの書き込み:
  実行 1: 4.24秒
  実行 2: 4.01秒
  実行 3: 3.98秒
  実行 4: 4.66秒
  実行 5: 4.14秒
snappyの平均書き込み時間: 4.21秒
データセットを生成しました。行数: 100000000
ファイルサイズ確認
$ ls -lh | awk '{print $5, $9}' 
1.7G test_data_lz4.parquet
1.7G test_data_snappy.parquet
1.1G test_data_zstd.parquet

まとめると下記のようになります。

圧縮形式 平均書き込み時間 ファイルサイズ
Zstandard 6.54秒 1.1GB
LZ4 4.49秒 1.7GB
Snappy 4.21秒 1.7GB


圧縮方式別Parquetファイル書き込み時間[1]

ドキュメント通りZstandardが時間がかかるが高圧縮、LZ4とSnappyは時間、サイズ共に同程度となり、それぞれ実行時間のばらつきもほぼありません。

余談ですが、Parquetのドキュメントの記載によると、LZ4とつくものにはLZ4とLZ4_RAWの2種類があり、LZ4は非推奨となっています。Polarsで lz4 を指定して作成すると非推奨ではないLZ4_RAW形式となっているようでした。

圧縮方式の確認
 $ parquet footer test_data_lz4.parquet  | grep codec | uniq 
        "codec" : "LZ4_RAW",

読み込み性能

続いて読み込みです。純粋な読み込み性能を比較するため、集計処理などは行わなずに読み込みのみ行います。

検証スクリプト
import polars as pl
import time

# 各圧縮形式のファイルに対して読み込みを実行
compression_types = ["zstd", "lz4", "snappy"]
results = {c: [] for c in compression_types}

for comp in compression_types:
    filename = f"test_data_{comp}.parquet"
    
    print(f"\n{comp}での読み込み:")
    
    for i in range(5):
        start = time.time()
        
        # データ読み込み
        df = pl.read_parquet(filename)
        
        end = time.time()
        elapsed = end - start
        results[comp].append(elapsed)
        print(f"  実行 {i+1}: {elapsed:.2f}秒")
    
    # 全実行の平均時間
    avg_time = sum(results[comp]) / len(results[comp])
    print(f"{comp}の平均実行時間: {avg_time:.2f}秒")
    
    # 初回を除いた平均時間
    avg_time_without_first = sum(results[comp][1:]) / len(results[comp][1:])
    print(f"{comp}の初回を除いた平均実行時間: {avg_time_without_first:.2f}秒")
結果
zstdでの読み込み:
  実行 1: 0.75秒
  実行 2: 0.52秒
  実行 3: 0.45秒
  実行 4: 0.42秒
  実行 5: 0.40秒
zstdの平均実行時間: 0.51秒
zstdの初回を除いた平均実行時間: 0.45秒

lz4での読み込み:
  実行 1: 1.21秒
  実行 2: 0.19秒
  実行 3: 0.18秒
  実行 4: 0.21秒
  実行 5: 0.19秒
lz4の平均実行時間: 0.40秒
lz4の初回を除いた平均実行時間: 0.19秒

snappyでの読み込み:
  実行 1: 2.21秒
  実行 2: 0.35秒
  実行 3: 0.27秒
  実行 4: 0.25秒
  実行 5: 0.26秒
snappyの平均実行時間: 0.67秒
snappyの初回を除いた平均実行時間: 0.28秒


圧縮方式別Parquetファイル読み込み時間

読み込みは少し異なる結果になりました。いずれの圧縮方式も初回が一番実行時間が長く、2回目以降が抑えられています。これは2回目以降はOSレベルでのページキャッシュが効いているからだと考えられます。そこで2回目以降はI/Oがほぼ発生していないという仮定を置き、ZstandardとLZ4を比較すると、I/OとCPU(解凍処理)にかかっている時間は図のように考えられます。

ファイルサイズの小さいZstandardはI/Oに時間がかからず、CPUを使った解凍処理に時間がかかる。ファイルサイズが大きいLZ4はI/Oに時間がかかるものの解凍処理は短時間で終わるといった具合です。
つまりI/Oがボトルネックになり、CPUリソースに余裕がある環境であればZstandardのような比較的圧縮効率の高い方式を選択するとよいですし、CPUがボトルネックでI/Oに余裕があるような環境であれば解凍処理が高速なLZ4やSnappyなどを選択すると良いことになります。
また今回の実験のように同じファイルに高頻度でアクセスするような処理が行われる場合はキャッシュを有効活用でき、LZ4のような方式が有利になる可能性があります。

まとめ

圧縮効率を高めればCPUリソースが増え、I/Oが減る。圧縮効率を下げればCPUリソースが減りI/Oが増えるということは一般に知られていることだと思いますが、ParquetとPolarsの組み合わせで改めて確認することができました。
総じてはZstandardがバランスが取れていると感じ、PolarsのデフォルトがZstandardになっていることにも頷けましたが、CPUやI/Oに極端な制限のある環境であれば違う選択肢も取れそうです。
今回は試していませんが、Zstandardは圧縮レベルを指定することもできるので、より柔軟にCPUとI/Oのリソースバランスに合わせたチューニングが可能そうです。

脚注
  1. ChatGPTになかなか日本語でグラフ化してもらえなかったので突然の英語です ↩︎

株式会社ログラス テックブログ

Discussion