モーセルドリブン並列実行とNUMA対応スケジューリング
多コア・複数ソケットのサーバーで分析クエリが頭打ちになる理由が分かります。仕事を小片に割ってワークスティールで均す morsel-driven 並列と、NUMA を意識した配置の原理を押さえられます。
- 1.morsel-driven は1パイプラインを固定 N 分割せず、各表を数千行程度の小片(morsel)に切り、ワーカースレッドが空いた順に morsel を取って処理する。負荷の偏りやスキューが出ても、遅いワーカーの残り仕事を空いたワーカーが盗む(ワークスティール)ことで自動的に均衡する。
- 2.並列度はクエリ起動時に固定せず、各ワーカーが次の morsel を取りに来るたびに動的に決まる。これにより、他クエリと共存する状況でもコアを過不足なく使い、エラスティックに並列度を上げ下げできる。
- 3.NUMA 機ではメモリにローカル/リモートの距離差がある。各 morsel をそのデータが載るソケットのワーカーに優先配分し、ハッシュ表もソケットローカルに置くことで、低速なリモートアクセスとインターコネクト渋滞を避ける。
多コアを「埋めきれない」問題
現代の分析サーバーは数十コア・複数ソケットを備えますが、クエリ実行モデルを速くしただけでは、この並列度を使い切れません。古典的な並列実行は、プランをコア数ぶんに静的分割し、各スレッドに均等量を割り当てます。しかしデータには偏り(スキュー)があり、ある区画だけ行数が多い・選択率が高いと、その担当スレッドだけ走り続け、他は早々に空いて遊びます。最も遅い1スレッドが全体の完了時刻を決める――この末尾の引きずりが、コアを増やしても速くならない主因です。本稿は、これを動的負荷分散で断つ morsel-driven 並列実行と、複数ソケット機で効かせる NUMA 対応スケジューリングの原理を解説します。
morsel:仕事を小片に割る
morsel-driven の核心は、並列の単位をスレッドではなくデータの小片(morsel)にすることです。各入力(テーブルやスキャン結果)を、数千行程度(典型的には1万行前後)の morsel に区切ります。ワーカースレッドは「自分の担当区画」を持たず、共通のディスパッチャから次の morsel を1つずつ取得して処理し、終えたらまた次を取りに来ます。
# ワーカーは担当を固定せず、空いたら次の morsel を取る(pull 型のタスク取得)
worker():
while (m = dispatcher.next_morsel(pipeline)) != null:
process(m) # この morsel ぶんだけ走る(数千行)
# 取れる morsel が尽きたら、このパイプラインの自分の仕事は終了
固定分割と違い、速いワーカーは多くの morsel を、遅いワーカーは少しの morsel を、結果として自然に消化します。1スレッドが重い morsel に当たっても、それは morsel 1個ぶん(数千行)の遅延に閉じ込められ、全体を引きずりません。粒度の選び方が要で、morsel が大きすぎると末尾の偏りが残り、小さすぎるとディスパッチャ取得や同期の固定費が嵩みます。CPU キャッシュに乗り、かつ1個あたりの処理時間が取得コストを十分上回る大きさが選ばれます。
morsel-driven は通常、コンパイル実行のパイプライン(マテリアライズを挟まず融合された一連の演算子)と組みます。1つの morsel は、スキャン→フィルタ→結合プローブのように、1つのパイプラインを端から端まで通り抜けてからワーカーに次が渡されます。並列はパイプライン内で morsel を多数のワーカーに散らすことで実現し、ハッシュ表ビルドのようなパイプライン境界(pipeline breaker)で全ワーカーを合流させます。
ワークスティール:遅れを盗んで均す
ディスパッチャから次々 morsel を配る方式は、それ自体が動的負荷分散ですが、より積極的に偏りを均すのが**ワークスティール(work stealing)**です。各ワーカーは自分のローカルなタスクキューを持ち、まずそこから取り出します。自分のキューが空になったワーカーは、まだ仕事が残っている他ワーカーのキューの末尾から morsel を盗み、代わりに実行します。
# 自分のキューが空なら、忙しいワーカーから盗む
worker(self):
while true:
m = self.local_queue.pop()
if m == null:
victim = pick_busy_worker() # 残務のあるワーカーを選ぶ
m = victim.queue.steal() # 末尾から1つ盗む
if m == null: break # どこにも残務がなければ終了
process(m)
これにより、ある morsel が想定外に重くても、その隣で空いたワーカーが残りを引き受け、全ワーカーがほぼ同時刻に終わるよう収束します。スティールは「キューが空になったときだけ」発生するため、平常時の同期コストはほぼゼロで、偏りが出た瞬間にだけ調整が働く――この競合の局所化がワークスティールの効きどころです。
NUMA:メモリに「距離」がある
複数ソケットのサーバーは **NUMA(Non-Uniform Memory Access)**構成です。各 CPU ソケットは自分に直結したローカルメモリを持ち、他ソケットのメモリ(リモート)へはインターコネクト(UPI など)を経由します。ローカルアクセスは速く、リモートは遅い――この非対称性が NUMA の本質です。morsel-driven のワークスティールを素朴に回すと、ワーカーがソケットをまたいで他所のデータを盗み、大量のリモートアクセスとインターコネクト渋滞を招きます。せっかくの動的均衡が、メモリ帯域のボトルネックに化けるのです。
リモートメモリは、レイテンシが数割増えるだけでなく、ソケット間インターコネクトという共有・有限な帯域を全ワーカーで奪い合います。1ワーカーのリモート参照は局所的な遅延で済んでも、多数のワーカーが同時にリモートを叩くと帯域が飽和し、全体スループットが頭打ちになります。大量集計のように帯域律速な処理では、リモート比率の削減がそのまま速度に直結します。
NUMA 対応スケジューリング:ローカルを優先する
対策は、morsel をそのデータが載っているソケットのワーカーに優先配分することです。入力データはソケットごとに分けて配置し(あるいは初回アクセス時のファーストタッチでローカルに割り付け)、ディスパッチャは各ソケットローカルな morsel キューを持ちます。ワーカーはまず自分と同じソケットの morselを取り、それが尽きて初めて他ソケットから盗みます。
- ローカル優先のディスパッチ: 各ソケットに morsel キューを分け、ワーカーは原則ローカルキューだけを消化する。大半のアクセスがローカルメモリに収まる。
- NUMA ローカルなハッシュ表: 結合のハッシュ表や集約のテーブルも、ソケットごとに分割して各ローカルメモリに置く。プローブはまず自ソケットの区画を引く。
- スティールは最後の手段: ローカルが空のときだけ他ソケットから盗む。均衡(負荷の偏り解消)とローカリティ(リモート回避)が衝突する場面で、ローカリティをやや優先して帯域を守る。
# NUMA 対応の morsel 取得:ローカル優先、空ならリモートを盗む
next_morsel(worker):
m = local_queue[worker.socket].pop() # まず自ソケット
if m != null: return m
for s in other_sockets_by_distance(): # 近いソケットから順に
m = local_queue[s].steal()
if m != null: return m # リモートだが遊ばせるよりはマシ
return null
つまり morsel-driven は、平常時はローカリティで帯域を守り、偏りが出た瞬間だけスティールで均衡を取り戻すという二段構えで、負荷分散とメモリ局所性という相反しがちな要求を両立させます。なおデータの読み出し経路もソケットを意識し、ローカルメモリへ直接読み込むことでリモート転送を初手から避けます。
静的分割との比較
| 観点 | 静的分割(従来) | morsel-driven |
|---|---|---|
| 並列の単位 | スレッド(区画を固定割当) | morsel(数千行の小片) |
| 負荷分散 | 事前の均等分割に依存 | 実行時にワークスティールで均す |
| スキュー耐性 | 弱い(重い区画が末尾を引く) | 強い(偏りは morsel 単位に閉じる) |
| 並列度 | 起動時に固定 | morsel 取得ごとに動的・弾力的 |
| NUMA 局所性 | 考慮しづらい | ローカル優先配分で帯域を守る |
| 他クエリとの共存 | コアの過不足が出やすい | 空きコアを morsel で吸収しやすい |
morsel-driven では並列度を起動時に決め打ちしません。ワーカーは「次の morsel を取りに来られたぶん」だけ働くため、実行中にワーカーを増減させても破綻しません。複数クエリが同居する環境で、空いたコアを別クエリの morsel に回す、優先度の高いクエリへワーカーを寄せる、といった動的なリソース再配分が自然に書けます。固定分割では起動後の並列度変更が難しいのと対照的です。
morsel=数千行の小片、ワーカーは担当を固定せず空いた順に morsel を取るをまず即答。静的分割が遅い理由はスキューで重い区画の担当スレッドが末尾を引きずること、morsel-driven が速い理由はワークスティールで偏りを morsel 単位に閉じ込め全ワーカーをほぼ同時に終わらせること。NUMA 対応は(1) ローカルソケットの morsel を優先配分、(2) ハッシュ表もソケットローカルに分割、(3) スティールは最後の手段でリモートアクセスとインターコネクト渋滞を避ける、と説明できれば十分です。
まとめ
morsel-driven 並列実行は、仕事をスレッド単位ではなく数千行程度のmorselに割り、ワーカーが空いた順に取って処理する方式です。区画を固定割当する静的分割と違い、スキューが出てもワークスティールで偏りを morsel 単位に閉じ込め、全ワーカーをほぼ同時刻に終わらせます。並列度は morsel 取得ごとに動的に決まり、他クエリと共存しても空きコアを吸収できます。複数ソケット機ではNUMA 対応スケジューリングが要で、morsel をデータの載るソケットのワーカーへ優先配分し、ハッシュ表もソケットローカルに置いて、低速なリモートアクセスとインターコネクト渋滞を避けます。平常時はローカリティ、偏ったときだけスティールという二段構えが、多コア・複数ソケットの分析エンジンで並列度を使い切る内部の正体です。
データベース Article
モーセルドリブン並列実行とNUMA対応スケジューリングを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
クエリ実行
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
並列度はクエリ起動時に固定せず、各ワーカーが次の morsel を取りに来るたびに動的に決まる。これにより、他クエリと共存する状況でもコアを過不足なく使い、エラスティックに並列度を上げ下げできる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「クエリ実行 / 並列処理」に近いか確認する。
- 強みである「morsel-driven は1パイプラインを固定 N 分割せず、各表を数千行程度の小片(morsel)に切り、ワーカースレッドが空いた順に morsel を取って処理する。負荷の偏りやスキューが出ても、遅いワーカーの残り仕事を空いたワーカーが盗む(ワークスティール)ことで自動的に均衡する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。