EPYC単機でELTを安定運用するために整理したメモリ循環設計
512GBメモリプールをステップごとに循環利用し、In-Memory/Arrow IPC/Parquetを切り替えるELT実行設計の整理。
はじめに
ローカルの Linux サーバー上で Dagster、Trino、DataFusion、PostgreSQL を組み合わせた ELT 基盤を組むと、ボトルネックは単純な CPU 性能ではなく、どのステップにどれだけメモリを使わせ、いつ解放させるかに集約されることが多い。今回のメモでは、AMD EPYC 9175F 16c/32t、768GB DDR5-6400、RTX 6000 Pro Max-Q、NVMe 3.84TB という前提の上で、512GB を ELT 専用プールとして切り出し、それをどう循環利用するかを整理した。
この種のデータ基盤を 48h Prototype や データ基盤整備 の提供形に接続する構想もあり、単なるチューニング断片ではなく、Airbyte -> Iceberg / Trino -> 可視化 のような一連の流れをローカル・オフライン重視で回すための運転ルールとして位置付けている。
背景・動機
最初に決めたかったのは、「大きなメモリを積んでいるのだから、とにかく全部メモリで押し切ればよい」という雑な設計を避けることだった。768GB 積んでいても、Trino の heavy query、DataFusion の大きな変換、PostgreSQL のロード、Dagster のワーカー並列が同時に走れば、簡単にピークがぶつかる。しかも一度ピークが重なると、スワップや OOM の問題だけではなく、復旧しにくい中途半端な失敗になりやすい。
そこで中心方針を次の一文にまとめた。
512GBをステップごとに循環利用し、軽いときはIn-Memoryで最速、重いときはArrow IPCやParquetへ安全に逃がす。
この方針にすると、速さだけでなく、障害時の再開性や運用ルールまで一緒に設計できる。
基本リソース前提
前提リソースは次の通り。
- サーバー:
AMD EPYC 9175F 16c/32t (5GHz) + 768GB DDR5-6400 + RTX 6000 Pro Max-Q + NVMe 3.84TB - ELT プール:
512GBをワークフロー専用に確保 - 帯域: メモリ帯域
~600GB/s、NVMe I/O 数GB/s、ネットワーク10GbE
ここで重要なのは、512GB を「システム全体で自由に食ってよい上限」として扱わず、「その時点で実行中の ELT ステップに貸し出す運転資源」として扱うことだ。データ基盤や可視化、MLOps の構成を広げるほど、個々のサービスを常時フル稼働前提で置くより、ワークロードごとに明確な枠を切った方が保守しやすい。
メモリ運用ポリシー
原則
メモリ運用の原則はかなり明確だ。
512GBプールをステップごとに循環利用する- 同一プロセスなら
In-Memory直渡しを優先する - 別プロセスやコンテナに分ける場合は、合算で
512GB上限に収める
Dagster でワークフローを組むと、つい op を細かく並べて並列で回したくなるが、重い分析系ではそれが逆効果になりやすい。むしろ、1 本ずつ太く流し、終わったら確実に解放し、次のステップが同じメモリを再利用する方が安定する。
切替基準
中間サイズごとの切替基準も定義している。
| 中間サイズ | 推奨方式 | 備考 |
|---|---|---|
<= 40-50% (200-250GB) | In-Memory(同一プロセス + InMemoryIOManager) | 帯域 600GB/s を直接使えるので最速 |
50-80% | Arrow IPC ストリーム | NVMe 退避でもオーバーヘッドを抑えやすい |
>80% または複数 op 同時 | Parquet 一時出力 | 失敗時再開や安全性を優先 |
この境界を明文化しておくと、「今回はメモリに乗せるべきか」「最初から Parquet に落とすべきか」をその場の感覚ではなくルールで判断できる。
各コンポーネントの最適化
DataFusion (Rust)
DataFusion 側では、巨大な中間データを不用意に collect しないことを最優先にしている。
MemoryPool上限を op 単位でS/M/Lプロファイルとして設定DiskManagerは NVMe の/opt/tmpを指定し、必要なら強制スピル可能にするStreaming APIのexecute_stream()を優先する- パーティション数は実コア数である
16cに合わせる
ここで大事なのは、メモリをたくさん積んでいても「全部集める」設計にしないことだ。ストリームで流し切れる処理は流し切る方が、結果的にピークを低く抑えられる。
Trino
Trino は heavy query を走らせると簡単にノード全体のメモリ圧迫要因になるので、設定だけでなく実行ルールもセットで必要だ。
JVM: -Xmx192g
max-spill-per-node=512GB
query.max-memory-per-node=128GB
query.max-total-memory-per-node=160GB
この前提では 256GB コンテナに収め、spill は NVMe に逃がす。さらに heavy query は Dagster 側の concurrency 制御で同時 1 本に絞る。クエリエンジン単体の設定だけではなく、オーケストレータ側で「重い仕事を同時に投げない」ことが効く。
PostgreSQL
PostgreSQL は ELT 全体の中では「最後の永続化」と「大量ロード」の役割が大きいので、派手なチューニングより確実なロード手順が重要だ。
shared_buffers=8-16GB
work_mem=128MB
maintenance_work_mem=2-4GB
wal_compression=lz4
checkpoint_timeout=20-30min
max_wal_size=12-16GB
大量ロード時は次のどちらかを基本形にする。
UNLOGGED TABLE -> COPY -> インデックス作成 -> LOGGEDCTAS (CREATE TABLE AS SELECT)
必要に応じて、取り込み中だけ synchronous_commit=off を使う選択肢も残す。常時無効化ではなく、取り込みフェーズ限定の判断に留めるのが現実的だ。
Dagster
Dagster では executor を仕事の重さに応じて切り替えるのが中核だ。
- 軽量:
in_process_executor + InMemoryIOManager - 重量:
multiprocess_executor + opごとに Podman コンテナ (--memory/--cpuset-cpus)
さらにタグで concurrency を制御する。
- heavy 系 (
Trino/DataFusion大 op):max_concurrent=1 - 軽い op(メタ処理、
dbt run、ログ): 並列可
これを決めておくと、ワークフローを書いた人ごとに実行ポリシーがぶれるのを防げる。
プロセス / コンテナ設計
In-Memory(最速)
一番速いのは、同一ジョブを in-process executor で実行し、中間を pyarrow.Table や RecordBatch の shallow copy で渡す形だ。条件は、中間サイズが <=200GB 程度、つまり ELT プールの 50% 以下に収まること。
この条件を外れるのに無理にメモリ直渡しを続けると、後続 op の起動時に一気に危なくなる。
コンテナ分離(安定)
安定性重視なら、各 op を podman run --memory=... --cpuset-cpus=... で分離して実行する。終了すればそのプロセス空間ごと解放されるので、512GB を循環利用するという方針に最も素直だ。
Podman を主軸にした運用や、ローカル環境での再現性確保が強く意識されているので、この方式は全体方針とも相性が良い。
subprocess 起動時の注意
Python 親プロセスから Rust 子プロセスを起動する場合は、親が巨大バッファを持ったままだと合算ピークで OOM を起こしやすくなる。対策としては次の通り。
- 子起動前に親のバッファを
del - スコープを抜ける
gc.collect()を呼ぶ- Python は
forkよりspawnを推奨する
fork の Copy-on-Write が破れると、実メモリが思った以上に増えるので、この注意点は地味だが重要だ。
I/O 最適化
I/O は単に「速いディスクを使う」では足りず、中間データの表現と置き場所を決める必要がある。
Arrow IPC: 最速退避。ゼロコピー寄りParquet: 安全性とリトライ性重視。圧縮はzstd(1-3)、行グループは128-256MBNVMe: スピルディレクトリを/opt/tmpに集約/dev/shm: Arrow Plasma / IPC なら--shm-size 2-4GB- クリーンアップ: ジョブ終了時に一時ファイルを削除し、失敗時もハンドルする
Arrow IPC は速さが欲しい時の逃がし先、Parquet は確実さが欲しい時の逃がし先、という役割分担にしておくと迷わない。
運転ルール
最終的な運転ルールは次の 5 点に収束した。
- 重いクエリは
1本ずつ太く回す - op ごとに終了させて解放し、
512GBを循環利用する - 中間サイズに応じて
In-Mem -> Arrow IPC -> Parquetを切り替える - 障害許容性が必要な処理は
Parquetを選ぶ - 速度最優先の処理は
In-Memoryで shallow copy / ポインタ渡しを使う
このあたりは個別最適に見えて、実際にはチーム運用のためのルールでもある。どの job でも同じ判断軸で設計できるようにするのが狙いだ。
メリット
この設計のメリットは大きく 4 つある。
- 速さ:
In-Memoryでメモリ帯域600GB/sを最大限に使える - 安定性:
512GB循環利用と cgroup 制御、spill で落ちにくい - 柔軟性: 中間サイズに応じて
Arrow IPC/Parquetを切り替えられる - シンプル性:
Dagsterの executor 切替で運転モードを変えられる
単機サーバー運用では、「何でも並列化する」より「どこまでを同時に許すか」を明示した方が結果的に速く、壊れにくくなる。
まとめ
512GB をステップごとに循環利用し、軽いときは In-Memory で高速化し、重いときは Arrow / Parquet に退避し、op はコンテナ分離で確実に解放する。
今後の展開
この方針をそのまま podman-compose.yml のメモリ / CPU プロファイルと Dagster executor 設定に落とし込めば、実運用向けの雛形にできる。Airbyte OSS、Great Expectations、OpenMetadata、MLflow なども含めたローカル重視のデータ基盤像が整理されていたので、次はこの ELT 実行ルールを周辺サービスまで含めたテンプレートにするのが自然だと思っている。
