PostgreSQLの並列処理とJIT
――実行計画からひも解く処理特性と導入効果

Software Design 2026年4月号の第1特集「PostgreSQL 18に学ぶデータベース高速化機能」から第5章「並列処理とJIT」を公開します。本特集のほかの章では、この記事の前提知識にあたる「SQL文の解析・実行の流れ」「実行計画の見方」について解説しています。⁠バッファ(キャッシュ⁠⁠MVCC」⁠クエリ最適化」⁠インデックス」など他の高速化技術も取り上げています。ぜひ本誌にてご確認ください。

はじめに

本稿では、PostgreSQLがサポートする高速化処理について紹介します。これから紹介するパラレルクエリやJITの効果を見るため、高速化処理を無効にしたパターンのクエリと実行計画を図1に提示します。1,000万レコードの最大値を取るサンプルクエリは全体のコストが288,935かかり、全体の実行時間が2427.187ミリ秒かかっています。

図1 直列処理時の実行計画
test=> SET jit=off;     ← JITを無効化 
SET
test=> SET max_parallel_workers_per_gather=0;    ← パラレルクエリを無効化 
SET
test=> EXPLAIN (ANALYZE ON) SELECT MAX(bid) FROM pgbench_accounts;
QUERY PLAN
--------------------------------------------------------------------------------------------
 Aggregate  (cost=288935.00..288935.01 rows=1 width=4) (actual time=2427.136..2427.138ュ
 rows=1.00 loops=1)
   Buffers: shared hit=163935
   ->  Seq Scan on pgbench_accounts  (cost=0.00..263935.00 rows=10000000 width=4) (actualュ
 time=0.028..1024.361 rows=10000000.00 loops=1)
         Buffers: shared hit=163935
 Planning Time: 0.212 ms
 Execution Time: 2427.187 ms
(6 rows)

test=>

パラレルクエリ

パラレルクエリとは

パラレルクエリは1つのクエリをPostgreSQL内部で並列に処理して高速化するしくみです。リーダープロセス(バックエンドプロセス)がワーカープロセス(子プロセス)を生成し、並列処理を実行します。その後、リーダープロセスが処理結果をひとまとめにして後続処理へ連携するという流れになります(図2)

図2 パラレルクエリの概念図

パラレルクエリの動作条件

パラレルクエリは実行計画でクエリ内の特定箇所を並列処理にすることで高速化が見込まれる場合に動作します。ワーカープロセスの生成数は、次の3つのパラメータに基づき決定されます。

  • max_worker_processes:バックグラウンドプロセスの最大値
  • max_parallel_workers_per_gather:1つのクエリで実行できるワーカープロセスの最大値
  • max_parallel_workers:クラスタ全体で同時に実行できるワーカープロセスの最大値

パラレル化の対象となるのは、⁠シーケンススキャン」⁠ビットマップヒープスキャン」⁠インデックススキャン」⁠ネステッドループ結合」⁠ハッシュ結合」⁠マージ結合」です。パラレルクエリ自体は全体が並列で動くわけではありません。実行計画の一部分のみが対象となり、並列処理後の結果は「集約」「アペンド」という処理をもって1つにして後続処理に渡されます。そのため集約・アペンド処理のオーバーヘッドがかかることに留意してください。仕様により、一時テーブルや共通テーブル式(CTE⁠⁠、外部テーブル、相関サブクエリなどを使用している場合はパラレルクエリとして動作しない点に注意しましょう。

パラレルクエリの実行計画

パラレルクエリが動作しているかは実行計画を見ることで判断できます。図3は全体のコストが196,185かかり、全体の実行時間が665.080ミリ秒かかっています。直列時よりコストが約32%削減され、実行時間は約3.6倍高速化されていることがわかります。

図3 パラレルクエリ処理時の実行計画
test=> SET jit=off;    ← JITを無効化 
SET
test=> SET max_parallel_workers_per_gather=4;    ← パラレルクエリを4多重で有効化 
SET
test=> EXPLAIN (ANALYZE ON) SELECT MAX(bid) FROM pgbench_accounts;
QUERY PLAN
--------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=196185.42..196185.43 rows=1 width=4) (actual time=636.842..664.ュ
943 rows=1.00 loops=1)
   Buffers: shared hit=163935
   ->  Gather  (cost=196185.00..196185.41 rows=4 width=4) (actual time=636.620..664.927ュ
 rows=5.00 loops=1)
         Workers Planned: 4
         Workers Launched: 4    ← 4多重でプロセスが動作している 
         Buffers: shared hit=163935
         ->  Partial Aggregate  (cost=195185.00..195185.01 rows=1 width=4) (actualュ
 time=625.573..625.574 rows=1.00 loops=5)
               Buffers: shared hit=163935
               ->  Parallel Seq Scan on pgbench_accounts  (cost=0.00..188935.00ュ rows=2500000 width=4) (actual time=0.034..270.140 rows=2000000.00 loops=5)
               ↑ 1プロセスは200万レコード処理している 
                     Buffers: shared hit=163935
 Planning Time: 0.233 ms
 Execution Time: 665.080 ms
(12 rows)

test=>

パラレルクエリの実行計画で登場するノードは表1を参照してください。Gather以下が並列処理部分です。図3では「Parallel Seq Scan」「Partial Aggregate」となっているため、シーケンススキャン部分と集約処理が並列処理されています。

表1 パラレルクエリのノード一覧
ノード 実行計画上の表記 説明
パラレルシーケンシャルスキャン Parallel Seq Scan テーブルの処理するブロック範囲を設定し、各プロセスでそれぞれ逐次処理する。
パラレルビットマップヒープスキャン Parallel Bitmap Heap Scan リーダープロセスで処理対象テーブルのインデックスをスキャンし、アクセスするブロックを示すビットマップを作成する。各プロセスが作成したビットマップを参照してテーブルを処理する。インデックススキャン部分は直列処理。
パラレルインデックススキャン Parallel Index Scan インデックスの処理するブロック範囲を設定し、各プロセスでそれぞれ逐次処理する。インデックスのみで処理が完結する場合はParallel Index Only Scanとなる。
ネステッドループ結合 Nested Loop 外側のテーブルをループで読み出し、内側のテーブルもループで該当行を探索する結合。内側のテーブルにインデックスがあると高速化される。
マージ結合 Merge Join それぞれのテーブルをソートした後に結合キーを探索する結合。各プロセスで同一処理のソートを行うため、計算効率的に非効率な結合となる。
ハッシュ結合 Hash Join 一方のテーブルに対するハッシュテーブルを作成し、もう片方のテーブルデータをハッシュテーブルに参照して結合する。各プロセス共通のハッシュテーブルを作成するが、ハッシュテーブルが大きい場合、メモリ不足時に一時ファイルを使用するため遅くなる可能性がある。ハッシュテーブル作成も並列処理する場合はParallel Hash Joinとなる。
集約 Partial Aggregate 各プロセスで処理された最終結果をまとめる処理。この処理結果がリーダープロセスへ転送される。
転送 Gather 各プロセスで処理した結果をリーダープロセスに転送する。ソート済みデータを転送するGather Mergeも存在する。
集約確定 Finalize Aggregate Gatherで転送された処理結果を再集約して結果を確定させる。AggregateはGroupAggregate やHashAggregateが存在する。
パラレルアペンド Parallel Append UNIONやパーティションテーブルを並列処理する場合に使用される。各プロセスは別々のテーブルを並列で処理し、処理結果をまとめる。

また、⁠Workers Planned: 4」で実行を計画し、⁠Workers Launched: 4」で計画どおりに4プロセスのワーカープロセスが生成されて実行されていることが読み取れます。ワーカープロセスに基づいた見積もりは1プロセスあたり250万レコードを処理する予定でしたが、実行時はリーダープロセスも処理を行いますので、実際は200万レコードの処理となっています。

JIT(実行時)コンパイル

JITとは

PostgreSQLはLLVMと呼ばれるコンパイル基盤を用いて、クエリの一部分を直接CPUで動作する形に変換(コンパイル)します。通常のクエリを実行する流れは、クエリをParserで解析して抽象構文木(AST)に変換し、そのあとAnalyzerでシステムカタログを用いて型情報を付与してAnalyzed ASTを生成します。Analyzed ASTとコスト情報を用いて、Rewriterがクエリの書き換え(ビューに対する書き換え)を、Optimizer(Planner)が実行計画を最適化します。そして、Executorが実行計画をもとにクエリを実行します。クエリの実行は各行の式をリアルタイム(インタプリタ)で解釈して実行されます。そのため、実行計画以外は最適化が行われません。

JITが有効な場合はOptimizerが実行計画を最適化する際、推定コスト閾値しきいちを超える場合にJITコンパイルを検討します。JITを使用することになると該当式をネイティブマシンコードにコンパイルし、直接CPUで実行します[1]

インタプリタ実行では最新のCPUアーキテクチャを十分活用できない可能性がありますが、コンパイルされたネイティブマシンコードはCPUアーキテクチャを十分に活用できる形に最適化されます。Executorはコンパイルしたネイティブマシンコードの関数をロードし、実行計画をもとに処理を行います(図4)

図4 JITの概念図

JITの動作条件

前述のとおり、JITはPlannerでJITを使用し、高速化が見込まれた場合に動作します。高速化の対象は、WHERE句やターゲットリスト、集約関数などといった式の評価や、ディスク上のタプル(レコード)を効率良くメモリに展開するためのタプル変形、関数のインライン化(関数の実コードを置き換える)があります。

JITの使用可否はパラメータjitです。jitの値をoffにすることでJITは動作しなくなります。デフォルトでJITは有効化されています。

JITが動作する場合、クエリの実行とは別にコンパイルを実行する時間が別に追加されることに留意してください。クエリの実行は高速化されてもコンパイル時間をプラスすると、ほとんど変わらなかったり逆に時間がかかったりする場合があります。そのため、コストの判定として次の3つのパラメータを設定します。

  • jit_above_cost:JITコンパイルを使用するコストの閾値
  • jit_inline_above_cost:関数のインライン化を使用するコストの閾値
  • jit_optimize_above_cost:積極的な最適化を使用するコストの閾値

JITの実行計画

図5は実行計画内に「JIT」の行が出ているため、JITが動作していると判断できます。全体のコストが288,935かかり、全体の実行時間が2040.375ミリ秒かかっています。コストは変更なしですが、実行時間は直列時より約387ミリ秒高速化されていることがわかります。

図5 JIT処理時の実行計画
test=> SET jit=on;    ← JITを有効化 
SET
test=> SET max_parallel_workers_per_gather=0;   ← パラレルクエリを無効化 
SET
test=> EXPLAIN (ANALYZE ON) SELECT MAX(bid) FROM pgbench_accounts;
QUERY PLAN
--------------------------------------------------------------------------------------------
 Aggregate  (cost=288935.00..288935.01 rows=1 width=4) (actual time=1959.029..1959.031ュ
 rows=1.00 loops=1)
   Buffers: shared hit=163935
   ->  Seq Scan on pgbench_accounts  (cost=0.00..263935.00 rows=10000000 width=4) (actualュ
 time=0.033..838.801 rows=10000000.00 loops=1)
         Buffers: shared hit=163935
 Planning Time: 0.213 ms
 JIT:  ← 以降のインデントが下がっている行はJITの詳細情報 
   Functions: 3
   Options: Inlining false, Optimization false, Expressions true, Deforming true
   Timing: Generation 0.599 ms (Deform 0.194 ms), Inlining 0.000 ms, Optimization 0.727 ms,ュ
 Emission 16.291 ms, Total 17.618 ms
 Execution Time: 2040.375 ms
(10 rows)

test=>

「Functions」行は、JITコンパイルによって処理された関数の数です。⁠Options」行は実行されたJIT処理をBool型で表示します。各項目の説明は表2を参照してください。⁠Timing」行は、一連の処理で要した時間を表示します。各項目の説明は表3を参照してください。

表2 JIT処理一覧
項目 説明
Inlining インライン化
Optimization 最適化
Expressions 式の評価
Deforming タプル変形
表3 JIT処理の所要時間
項目 説明
Generation JITコンパイルの所要時間
Inlining インライン化所要時間
Optimization 最適化所要時間
Emission JITコード出力所要時間

パラレルクエリとJITの使い分け

パラレルクエリは処理を行うプロセスを増やして高速化を行い、JITはクエリ自体を最適化して高速化するというように、それぞれ異なる高速化技術となります。パラレルクエリとJITは共存でき、パラレルクエリ内でJITが動くという複合処理も可能です。

複合処理時の実行時間は623.507ミリ秒となりました(図6)。パラレルクエリの実行時間は665.080ミリ秒だったため、約42ミリ秒だけ高速化されていますが、JITコンパイル処理しだいで逆に遅くなることもあります。この場合はJITの恩恵を受けることができていないため、JIT機能を一時的に無効化することを検討してください。

図6 複合処理時の実行計画
test=> SET jit=on;    ← JITを有効化 
SET
test=> SET max_parallel_workers_per_gather=4;    ← パラレルクエリを4多重で有効化 
SET
test=> EXPLAIN (ANALYZE ON, COSTS OFF) SELECT MAX(bid) FROM pgbench_accounts;
QUERY PLAN
--------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=196185.42..196185.43 rows=1 width=4) (actual time=605.071..622ュ
.974 rows=1.00 loops=1)
   Buffers: shared hit=163935
   ->  Gather  (cost=196185.00..196185.41 rows=4 width=4) (actual time=604.855..622.946ュ
 rows=5.00 loops=1)
         Workers Planned: 4
         Workers Launched: 4
         Buffers: shared hit=163935
         ->  Partial Aggregate  (cost=195185.00..195185.01 rows=1 width=4) (actual time=ュ
582.718..582.720 rows=1.00 loops=5)
               Buffers: shared hit=163935
               ->  Parallel Seq Scan on pgbench_accounts  (cost=0.00..188935.00 rows=ュ
2500000 width=4) (actual time=0.024..254.786 rows=2000000.00 loops=5)
                     Buffers: shared hit=163935
 Planning Time: 0.142 ms
 JIT:
   Functions: 17
   Options: Inlining false, Optimization false, Expressions true, Deforming true
   Timing: Generation 1.851 ms (Deform 0.403 ms), Inlining 0.000 ms, Optimization 1.636ュ
 ms, Emission 24.366 ms, Total 27.853 ms
 Execution Time: 623.507 ms
(16 rows)

test=>

パラレルクエリとJITのメリット/デメリットを表4に提示します。

表4 パラレルクエリとJITのメリット/デメリット
高速化技術 メリット デメリット
パラレルクエリ 大規模テーブルアクセスなどを並列処理にて高速化できる。 プロセス生成・並列処理結果の連携によるオーバーヘッドあり。
JIT 式の評価などをCPUで動作させることで高速化できる。 JITコンパイルによるオーバーヘッドあり。

パラレルVACUUM

VACUUMとは

PostgreSQLが採用しているアーキテクチャは「追記型アーキテクチャ」と呼ばれます。更新(UPDATE)や削除(DELETE)を行うと、既存のレコードに対して処理を行わずに新しいレコードをテーブル末尾に追加します。古いレコードはどのトランザクションからもアクセスされなくなると「dead tuple」として残り続け、テーブル肥大化の要因となります。インデックスも同様です。VACUUMはテーブルをスキャンして、このdead tupleを再利用可能な状態に戻し、ディスク容量の圧迫やパフォーマンス低下を防ぐ役割を担います。

パラレルVACUUM

パラレルVACUUMはテーブルに設定された複数のインデックスに対して、並列処理を実行します。並列処理でないVACUUM処理の場合は複数のインデックスも直列で実行するため時間がかかりますが、パラレルVACUUMは同時に複数のインデックスを処理するため高速に処理が完了します。パラレルVACUUMはインデックスのみに対して動作し、通常レコードは並列処理されないことに留意してください。

おすすめ記事

記事・ニュース一覧