❄️

dbt マクロ実行を GNU Parallel で高速化 - Snowflake の並列実行能力を最大活用

に公開

この記事は、ナウキャスト Advent Calendar 2025の9日目の記事です。

はじめに

こんにちは、ナウキャストでデータエンジニアをしている和田です。

今回お話する内容は、dbt + Snowflake における並列実行についてです。
並列実行良いですよね。1時間かかる処理が8個あった場合、通常は8時間かかりますが、同時に8個流すことができれば1時間で終わります。
生産性8倍、一日かかる処理を朝の会議が始まる前に終わらせることができます。

そんな並列実行について、dbt + Snowflake ではどのように活用されているのか、また、並列実行に関係する困り事の解決事例についてお話したいと思います。

並列実行のお話: dbt-snowflakeでの活用

Snowflake の並列実行

Snowflake の Warehouse は、複数のクエリを並列実行できる強力な機能を持っています。デフォルトでは最大8クエリまで同時実行可能です( MAX_CONCURRENCY_LEVEL によって設定可能です)。
https://docs.snowflake.com/ja/user-guide/performance-query-warehouse-max-concurrency

また重要なポイントとして、並列化しても Snowflake のコストは変わらないという点です。Warehouse の課金は「稼働時間 × サイズ」で決まるため、稼働時間を並列実行で短縮した場合、Warehouse サイズを上げなければコストは削減されます!

この並列実行能力を最大限に活用できれば、処理時間、コストを大幅に短縮できるはずです。

dbt の並列実行

次に dbt の並列実行についてです。
dbt の dbt builddbt run では、依存関係を壊すことなく、モデルの構築を並列実行してくれます。
イメージとしてはこんな感じです。

model_a ──┐
          ├──> model_c
model_b ──┘

この場合、model_amodel_b は並列実行され、両方が完了してから model_c が実行されます。dbt は --threads オプション(デフォルトは4)で並列度を制御でき、Snowflake Warehouse の能力を効率的に使えます。

小話: dbt のデフォルト並列実行数は4ですが、Snowflake Warehouse はデフォルトで8クエリまで並列実行可能です。つまり、dbt のデフォルト設定では Snowflake のリソースを半分しか使っていないことになります。dbt_project.yml や実行時に --threads 8 を指定することで、Snowflake の並列実行能力を最大限に活用できる可能性があります(dbt の実行環境との兼ね合いもあります)。

# dbt_project.yml
threads: 8  # Snowflake の並列実行能力に合わせる

並列実行のお話: dbt run-operation は並列実行されない

dbt run-operation について

自動的に依存関係を解決しながら並列実行を制御してくれる dbt ですが、利用していく中で少し困ったケースが出てきました。
それが dbt run-operation コマンドです。このコマンドは dbt で定義したマクロを実行できますが、複数のマクロを一括で並列実行することができず、個別実行となってしまいます。そのため、マクロの数だけ待ち時間が発生して処理時間が長くSnowflake Warehouse が持つ並列実行能力も全く活かすことができません
https://docs.getdbt.com/reference/commands/run-operation

例えば、次のようなカスタムマクロを run-operation で実行する場合を考えてみます。

dbt run-operation export_table --args '{table: "table_a"}'
dbt run-operation export_table --args '{table: "table_b"}'
dbt run-operation export_table --args '{table: "table_c"}'

これらは個別実行されます。各マクロの実行に30秒かかる場合、合計で90秒必要になります。

小話: この問題はコミュニティでも何度か議論されていました(多くのユーザーが run-operation の並列化を望んでいますね)。

どちらも「大量の外部テーブルを作成する時に線形に処理時間が伸びる。dbt rundbt test では並列実行できるのに、dbt run-operation では並列実行できないのはなぜか?どうすればいいのか?」という話題で、提示された解決方法は以下のようなものでした。

  • モデルの pre-hook として ALTER external table REFRESH を追加することで並行性を得た(#109)
  • モデルの hooks を使う(モデルは並列実行可能なため)(#280)
  • カスタム materializations を使った工夫した実装(#280)

ナウキャストでの具体例と問題

私たちのチームでは、Snowflake から S3 へのデータエクスポートに COPY INTO 文を使用して、複数テーブルのエクスポートを dbt マクロで実装し、dbt run-operation を用いて実行していました。しかし、例にもれずマクロを個別実行していたためマクロを並べた数だけ待ち時間が発生して処理時間も長くSnowflake Warehouse の並列実行能力も全く活かせていませんでした

Warehouse は8クエリまで並列実行できるのに、1クエリずつしか実行されず、残り7スロットが無駄になっている状態でした。Snowflake のコストも、処理時間も、とてももったいない状態です。

解決策の検討

xargs を使った並列実行

最初に思いついたのは、& を使ったバックグラウンド実行による並列化でした。

例えば:

# & によるバックグラウンド実行
for i in {1..5}; do sleep $i & done;

このコマンドでは、1~5秒の sleep をバックグラウンドで実行し、実行した段階で次の処理に進みます。
逐次実行の場合は1~5秒の合計で15秒程度かかりますが、バックグラウンド実行の場合は並列で処理が進み、5秒程度で終了します。

また、xargs-P オプションでも並列実行が可能でしたが、xargs はそもそも map 処理をするためのツールであり、並列実行制御に使うのは本来の用途とは異なります。

seq 5 | xargs -I {} -P 5 bash -c 'sleep {}'

と書くことで今回の課題である並列実行は解決できますが、より良い方法を模索します。

GNU Parallel を使った並列実行

次に思いついたのが、並列処理に特化した強力なツールを使うことでした。
今回は GNU Parallel を使って解決を模索することにしました。

例えば、先ほどの例を GNU Parallel で書き直すと:

seq 5 | parallel -j 5 sleep {}

-j オプションを使うことで並列実行数(使用コア数)を指定できます。
これで xargs と同じ並列実行ができますが、プロセスのコントロール等がより精密で、また parallel には並列実行をより便利に使うオプションが豊富にあるため、xargs よりも便利に扱うことができます。

並列実行に適したツールであることが分かり、今回の問題解決に適していることもわかったので、チームとして parallel を使って並列実行を導入することを決定しました。

実際の実装例

我々のチームでは Makefile に記載されたコマンドで dbt の実行を制御しており、下記のように個別実行されるように実装されていました。

dbt/export_macro:
	cd dbt && poetry run dbt run-operation export_table_a --vars 'env: ${ENV}' --args "{'date': '${DATE}'}" && \
	cd dbt && poetry run dbt run-operation export_table_b --vars 'env: ${ENV}' && \
	cd dbt && poetry run dbt run-operation export_table_c --vars 'env: ${ENV}' --args "{'target': '${TARGET}'}" && \
    ...

この export_macro は毎日実行され、実行する度に2時間30分程の時間をかけて S3 に出力していました。
これを parallel を使った並列処理にするために、下記のように実装しました。

dbt/export_macro:
	@parallel --verbose --tag -j 8 "poetry run dbt run-operation {} --vars 'env: ${ENV}' --args '{date: \"$${DATE}\", target: \"$${TARGET}\"}'" ::: \
		export_table_a \
		export_table_b \
		export_table_c \
    ...

parallel を使った並列処理にすることで、1時間35分程まで時間を短縮し、50分以上の時間短縮をすることに成功しました。
また、同様の問題を抱えた他のプロジェクトでも parallel を導入し、プロジェクトによっては50%以上の時間短縮に成功したものもありました。

実装にあたってのつまずきポイント

最後に、実際に実装するにあたっていくつかつまずいたポイントがあったのでまとめておきます。

ポイント1(デバック時のログ出力について): parallel--verbose オプションは実行されるコマンドの内容を標準エラー出力に表示し、--tag オプションは各ジョブの出力にマクロ名をタグとして付加します。デバッグ時に処理が追いやすいように付けています(これが無いと複数のジョブのログが混ざり、何がいつ終わったのかわからなくなりました)。

ポイント2(エスケープについて): 引用符のエスケープに注意が必要です。空文字が入る等して何度もエラーを繰り返しました。

  • dbt の --args オプションは JSON 形式を期待します。parallel は内部的にコマンドラインを再評価する挙動をするため、JSON 文字列に対して parallel がコマンドラインの再評価を行い、$ を変数として展開しようとします。そのため、parallel は空文字として JSON 文字列に変数を展開してしまい、DATE が読み込めない挙動になります。この問題を回避するために、シェル展開を遅延させる必要があります。$${DATE} のように $$ でエスケープすることで、子シェルに ${DATE} を渡し、Make コマンドに渡された DATE を正しく変数として展開することができます。
  • --args オプションで \" (バックスラッシュ+ダブルクォート)を使っています。これは parallel コマンドが " (ダブルクォート)を使っているため、内部のコマンドでは " (ダブルクォート)をエスケープする必要があるからです(もしくは、' (シングルクォート)を使うのも手です)。

ポイント3(引数について): parallel では全マクロに同じ引数を渡すため、すべてのマクロが同じ引数形式を受け取れる必要があります。

  • export_table_a{date, target} を受け取る場合、export_table_bexport_table_c も同じ引数を受け取れるように実装しておく必要があります。引数を使わないマクロでも、引数を受け取れるように定義し内部で使用しないだけにするか、または別の parallel コマンドで実行する必要があります。

ポイント4(依存関係について): paralleldbt run のように依存関係を自動解決することはありません。開発者が依存関係を理解し、依存関係が存在する場合には別のコマンドに分けて実行する必要があります。この点については注意が必要です。

余談:GNU Parallel の便利なオプション

今回の実装では使っていませんが、parallel には便利なオプションがあるので一部ですが紹介します。
(より詳しく知りたい方は man parallel を見てみましょう!)

便利なオプション

各ジョブのログをファイルに出力する

parallel --joblog parallel.log

失敗したジョブのみ再実行

parallel --retry-failed --joblog parallel.log

進捗表示

parallel --progress -j 4 ...
# または
parallel --bar -j 4 ...

タイムアウト制御

parallel --timeout 300 -j 4 ...  # 5分でタイムアウト

リソースベースの制御

parallel --load 80% -j 4 ...  # CPU負荷が80%以下の時のみ新規ジョブ開始

エラー発生による処理の制御

parallel --halt soon,fail=3 ...  # ジョブが3つ失敗したら処理を中断する

リトライ

parallel --retries 2 ...  # 2回までリトライ

ドライラン(テスト実行)

parallel --dry-run ...

実行コア数の制御

# -j 0にするとCPUコア数に応じて自動調整
parallel -j 0 ...

# コア数の2倍
parallel -j 200% ...

組み合わせによる実行

parallel -j 4 dbt run-operation refresh --args "'{schema: \"{1}\", table: \"{2}\"}'" ::: schema_a schema_b ::: table_1 table_2  # schema*tableの4パターンでコマンドを実行する

SSH経由でリモート実行

# servers.txt
:       # ローカルマシン
server1.example.com
server2.example.com
parallel --sshloginfile servers.txt 'process {}' ::: data1 data2 data3  # ローカル、サーバー1、サーバー2で分散して実行

まとめ

GNU Parallel は学習コストも低く、すぐに導入できるため、dbt マクロの並列実行に限らず、様々な場面で活用できるツールです。
今回の事例では、Snowflake の並列実行能力を活用することでコスト削減にも寄与しており、導入することでのメリットがとても大きかったです。
ぜひこの記事をご覧のみなさまも Makefile を見直してみて、依存関係もないのに逐次実行になっている処理があれば parallel の導入を検討してみましょう!

https://www.gnu.org/software/parallel/
https://www.getdbt.com/
https://www.snowflake.com/ja/

Finatext Tech Blog

Discussion