プッシュ型 vs プル型実行モデルの比較
同じ演算子の木でも、駆動の向きが違うだけで生成コードの質と分岐の素直さが変わる理由が分かります。pull と push の制御フローを原理から対比し、コード生成適性まで押さえられます。
- 1.pull 型は上位演算子が next() で下位から1タプルずつ引き出す向き。制御の主体が上にあり、LIMIT や Merge Join のように「片側だけ進めたい」需要駆動の制御を素直に書ける。
- 2.push 型は下位が produce したタプルを上位の consume へ押し込む向き。パイプライン中の演算子境界が関数呼び出しでなくコード片の連結になり、融合ループへコンパイルしやすい。
- 3.両者は計算結果は同じで、変わるのは制御フローと生成コードの形。pull は需要駆動と早期停止に、push はパイプライン融合とコード生成に向く、というトレードオフ。
同じ木でも「駆動の向き」が二つある
オプティマイザが確定した物理プランは演算子の木です。ところが、その木を実際に動かすときの制御フローの向きには二つの選択肢があります。上位が下位へタプルを「要求する」pull 型と、下位が上位へタプルを「押し込む」push 型です。どちらも計算結果は同じで、変わるのは関数呼び出しの向き・パイプラインの組み方・生成されるコードの形です。本稿はこの軸を、パイプライン化・分岐予測・コード生成適性の観点で対比します。実行を1行ずつ回すかバッチで回すかという軸(Volcano/ベクトル化/コンパイル)とは直交した、別の設計次元である点に注意してください。
pull 型:上位が need で引き出す
pull 型では各演算子が open() / next() / close() を持つイテレータで、上位演算子が next() を呼ぶと下位が1タプルを返します。古典的な Volcano モデルがこれです。要求が木の上から下へ連鎖し、最下段の Scan が1行を生んで上へ返ります。
# pull 型:上位が next() を呼ぶ=下位へ「要求」が降りる
Filter.next():
while (row = child.next()) != EOF: # 下位へ要求
if predicate(row): return row # 通過した1行だけ呼び出し元へ返す
制御の主体が上にある(需要駆動 / demand-driven)ことが pull 型の本質です。これは次のような「消費側の都合で生産を止めたい」状況を素直に表現できます。
- 早期停止:
LIMIT 10は上位が10回next()を呼んだ時点で要求をやめれば、下位の走査も自然に止まります。停止条件が呼び出し元のループ内に閉じます。 - 片側選択駆動: Merge Join は「今キーが小さい方の子だけ進めたい」。pull なら進めたい子の
next()だけを呼べばよく、制御が直感的です。 - 多消費先の調停: 上位が「どの子からいつ引くか」を握るため、合流系の演算子で進行を制御しやすい。
弱点も明確です。1タプル進めるたびに木の深さぶんの next() 呼び出しが連鎖し、演算子の型が実行時に決まるため多くが**仮想関数呼び出し(間接分岐)**になります。飛び先が一定せず分岐予測が外れやすく、解釈の固定費が実データ処理を薄めます。
push 型:下位が result を押し込む
push 型は向きを反転させます。各演算子は「自分の結果を上位へ渡す」produce と「下位から来た結果を受け取る」consume の対で表現され、下位が処理したタプルを上位の consume に押し込みます。
# push 型:下位が consume() を呼ぶ=上位へ「結果」が昇る
Scan.produce():
for row in table:
parent.consume(row) # 上位へ押し込む
Filter.consume(row):
if predicate(row):
parent.consume(row) # 通過分だけさらに上へ
制御の主体が下にある(データ駆動 / data-driven)のが push 型です。重要なのは、隣り合う演算子の境界が「next() を跨ぐ関数呼び出し」ではなく、consume のコード片を続けて並べる連結になる点です。これがパイプライン融合と相性がよい理由です。
push でも、すべての境界が融合できるわけではありません。パイプライン区切り(pipeline breaker)――Hash Join の build 側や Sort、集計の確定など、全入力を読み終えるまで結果を出せない演算子――では、いったんマテリアライズが必要です。push はこの「区切りの間」を融合ループにまとめるのが得意なだけで、区切り自体を消すわけではありません。ハッシュ結合は build 側が breaker、probe 側がパイプラインになります。
なぜ push はコード生成に向くのか
pull で生成コードを作ると、演算子境界ごとに next() 関数を跨ぐため、1タプルが境界を越えるたびに関数の呼び出し・戻り・状態の保存復元が要ります。push では consume を呼び出し先へインライン展開していくと、マテリアライズ不要な一連の演算子が1本のタイトなループに畳み込まれます。
# push をコンパイルすると Scan→Filter→Aggregate が1ループへ融合
sum = 0
for row in scan(orders): # produce が外側ループを生成
if row.status == 'paid': # Filter.consume がインライン展開
sum += row.amount # Aggregate.consume がインライン展開
この形は、コンパイル実行で HyPer が示した「produce/consume で生成し、1タプルを CPU レジスタに載せたまま融合ループを流す」方式そのものです。利点が分岐予測にも効きます。
- 演算子境界の間接分岐が消える: 仮想
next()の連鎖がインライン化された直接コードになり、外れやすい間接分岐そのものが減ります。 - データ依存分岐がループ内に局在: 残る分岐は述語評価などデータ依存のものだけで、予測器が履歴を学習しやすい配置になります。
- 中間マテリアライズの削減: パイプライン内ではタプルがレジスタを通り抜けるため、メモリへの書き戻しが減ります。
逆に pull がコード生成に不利なのは、next() の「呼ばれたら1個返して中断、次にまた呼ばれたら続きから」というコルーチン的な中断・再開を生成コードで表現しづらいからです。早期停止の素直さと裏表の関係になっています。
push は「下位が勝手に押し込む」ため、消費側の都合で止める制御が pull より書きにくいのが弱点です。LIMIT による早期停止は、consume 側から produce 側へ「もう要らない」を逆伝播させる仕組み(戻り値での停止シグナルや例外的脱出)が要ります。Merge Join のような片側選択駆動も、純粋な push では表しにくく、breaker でマテリアライズしてから扱うなどの工夫を伴います。
系統と分岐:どこで pull から push へ寄ったか
制御フローの選択は実装系の世代と結びついています。年代と分岐を整理します。
- 1990年代(pull の時代): Graefe の Volcano(1990年代前半に体系化)が反復子モデルを標準化。
open/next/closeの pull で、行指向・1行ずつのインタプリタ実行が長く主流でした。実装が単純で任意の演算子を組める汎用性が評価されました。 - 2000年代(バッチ化はするが pull のまま): MonetDB/X100(2005)に代表されるベクトル化は、
next()の単位を1行からカラムバッチへ広げて固定費を償却しました。ただし駆動の向きは pull のままで、push へ寄ったわけではありません。これが「pull/push」と「行/バッチ」が直交軸である証拠です。 - 2010年代(push+コンパイル): HyPer(Neumann, 2011)が produce/consume の push でクエリ専用ネイティブコードを生成する方式を示し、push がコード生成の事実上の標準になりました。以後、コンパイル系エンジンの多くが push を採用します。
- 現在(混在・適応): 純粋にどちらか一方ではなく、breaker の前後でモデルを使い分ける、push で生成したコードの中をベクトル単位で回す、といったハイブリッドが実用化されています。
| 観点 | pull 型(demand-driven) | push 型(data-driven) |
|---|---|---|
| 駆動の向き | 上位が next() で下位から引く | 下位が consume() で上位へ押す |
| 制御の主体 | 消費側(上) | 生産側(下) |
| 早期停止 LIMIT | 呼び出し回数を止めるだけで自然に伝わる | 停止シグナルの逆伝播が要る |
| 片側選択駆動 Merge Join | 進めたい子の next() を呼ぶだけで素直 | 純 push では表しにくい |
| パイプライン融合 | 境界ごとに next() を跨ぐ | consume をインラインし1ループへ畳む |
| 分岐予測 | 間接呼び出しが外れやすい | 境界の間接分岐が消え当たりやすい |
| コード生成適性 | 中断再開が表しにくく不利 | 融合ループを生成しやすく好適 |
| 代表例 | Volcano / 古典イテレータ / 多くのベクトル化 | HyPer 系コンパイル実行 |
両者は優劣ではなくトレードオフです。pull は需要駆動・早期停止・選択的な進行制御を素直に書け、汎用性が高い。push はパイプライン融合と分岐の素直さでコード生成に最適で、OLAPの大量スキャンで CPU を埋め切れます。だからこそ、breaker を境に両モデルを併用する設計が現実解になります。
pull=上位が next() で引く需要駆動、push=下位が consume() で押すデータ駆動を即答できるように。pull が得意なのは LIMIT の早期停止と Merge Join の片側選択駆動、push が得意なのは パイプライン融合と間接分岐の除去によるコード生成。「pull/push(駆動の向き)」と「行/バッチ(処理単位)」は直交軸で、ベクトル化は pull のまま、HyPer 系コンパイルは push、という対応を押さえれば十分です。
まとめ
pull 型と push 型は、同じ演算子の木を回すときの制御フローの向きの違いです。pullは上位が next() で下位からタプルを引く需要駆動で、LIMIT の早期停止や Merge Join の片側選択駆動を素直に書ける反面、演算子境界の仮想呼び出しと中断再開が生成コードに不利です。pushは下位が consume() で上位へ押し込むデータ駆動で、隣接演算子の境界がコード片の連結になるためパイプライン融合に畳み込みやすく、間接分岐が消えて分岐予測も素直になり、コンパイル実行のコード生成に向きます。両者は計算結果が同じで変わるのは制御の形だけなので、Hash Join の build のようなパイプライン区切りを境に使い分けるのが現代エンジンの定石です。
データベース Article
プッシュ型 vs プル型実行モデルの比較を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
クエリ実行
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
push 型は下位が produce したタプルを上位の consume へ押し込む向き。パイプライン中の演算子境界が関数呼び出しでなくコード片の連結になり、融合ループへコンパイルしやすい。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「クエリ実行 / pull モデル」に近いか確認する。
- 強みである「pull 型は上位演算子が next() で下位から1タプルずつ引き出す向き。制御の主体が上にあり、LIMIT や Merge Join のように「片側だけ進めたい」需要駆動の制御を素直に書ける。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。