Parquetの圧縮方式による性能比較
おはようございます。ログラスの龍島(@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のリソースバランスに合わせたチューニングが可能そうです。
-
ChatGPTになかなか日本語でグラフ化してもらえなかったので突然の英語です ↩︎
Discussion