ベクトル化クエリ実行
分析クエリが同じCPUで数倍速くなる理由を原理から掴みたい人へ。行を1件ずつ処理するVolcanoモデルの限界、列指向の一括処理・SIMD・キャッシュ効率・コード生成が効くメカニズムを、分析基盤の観点で解きほぐします。
- 1.分析エンジン(ClickHouse/DuckDB/Photon/Velox等)が速いのは、行を1件ずつ関数呼び出しで回すVolcanoモデルをやめ、同じ列の値を数千件まとめて処理するベクトル化にあるためだ。仮想関数呼び出しと分岐予測ミスのオーバーヘッドが件数で割り勘になり、1行あたりのCPUコストが桁で下がる。
- 2.列を連続配列で持つとタイトなループになり、コンパイラの自動ベクトル化やSIMD命令で1命令が複数要素を同時に処理できる。データがCPUキャッシュに乗り、分岐のない述語評価(selection vector)や辞書エンコードとの相性でメモリ帯域も節約できる。
- 3.ベクトル化とコード生成(JITでクエリ専用機械語を吐く)は競合ではなく相補的な高速化軸で、前者はメモリ実体化のコスト、後者はコンパイル時間とデバッグ性のトレードオフを持つ。多くの現代エンジンは両者を組み合わせる。
なぜ行単位処理は分析で遅いのか:Volcanoモデルの限界
分析クエリ(数億行を集約・フィルタ・結合する OLAP)を速くしたい。まず立ちはだかるのが、伝統的なクエリ実行の骨格である Volcanoモデル(反復子モデル) です。各演算子(スキャン、フィルタ、集約、結合)が next() を持ち、上位が呼ぶたびに 1行(タプル)を1件返す 。これは美しく汎用的で、任意の演算子を組み合わせられます。しかし分析ワークロードでは原理的に遅い。
理由は、返す価値のある「実データの処理」に対して、制御のオーバーヘッドが1行ごとに丸ごとかかることにあります。
- 仮想関数呼び出しのコスト。
next()は演算子ごとに実装が違う仮想呼び出しで、1行につき演算子の段数ぶん発生する。呼び出し自体のコスト(間接ジャンプ、レジスタ退避)が実処理に匹敵する。 - 分岐予測ミス。演算子ごとの型判定や
NULL判定が1行ごとに走り、CPUのパイプラインが乱れる。 - 命令キャッシュの汚染。1行のために多数の演算子コードを行き来するので、命令が局所化されず、CPUが同じコードを繰り返し実行する利点を失う。
10億行なら、この固定費が10億回。「1行あたりの実処理は数ナノ秒なのに、その周りの制御に十数ナノ秒かかる」 という逆転が起きます。OLTPのように数行を触るだけなら誤差ですが、全行を舐める分析では致命的です。
Volcanoモデルの重さは、足し算や比較といった実計算ではなく、それを起動するための毎回の段取りにあります。行を1件ずつ受け渡す限り、仮想呼び出し・分岐・キャッシュミスという固定費が件数に比例して積み上がる。ベクトル化の発想は単純で、この固定費を「1行ごと」から「1バッチ(数千行)ごと」に払い替えることです。段取りの回数が件数分の1になり、1行あたりのCPUコストが桁で下がります。
ベクトル化:一括処理で固定費を割り勘にする
ベクトル化実行(vectorized execution) は、next() が1行ではなく 列の値を数千件まとめた「バッチ(ベクトル)」を返す ように変えます。MonetDB/X100 が示した定石で、典型的なバッチは 1024〜数千要素。演算子は1呼び出しの中で、タイトなループでバッチ全要素を処理します。
# 行単位(Volcano):1行ごとに next() と型判定
for 各行:
row = child.next() # 仮想呼び出し(1行ぶん)
if row.price > 100: emit(row) # 分岐(1行ぶん)
# ベクトル化:1バッチ = 1回の呼び出しで N 件をループ
batch = child.next_batch() # 仮想呼び出しは N 件で1回
prices = batch.column("price") # 連続した配列
for i in 0..N: # 単純ループ(コンパイラが最適化しやすい)
result[i] = prices[i] > 100
効くポイントは3つあります。第一に、仮想関数呼び出しがバッチ単位になり、1行あたりの呼び出しコストが N 分の1に薄まる。第二に、ループ本体が単純で型が固定なので、分岐が減り、CPUの分岐予測が当たりやすい。第三に、同じ演算子コードを N 回連続実行するので命令キャッシュが効き、CPUが投機的実行・パイプライン化をフルに使えます。処理は行の集合ではなく 列ごとの配列 に対して進むため、列指向ストレージ(/database/ の列ストア)と自然に噛み合います。
SIMDとキャッシュ効率:ハードウェアを使い切る
ベクトル化の真価は、列を連続配列で持つとハードウェアの並列性を引き出せる点にあります。
CPUには SIMD(Single Instruction, Multiple Data) 命令があり、1命令で複数のデータ要素を同時に処理します。AVX-512 なら512ビット幅、32ビット整数を 一度に16個 足せます。列の値が隙間なく並んでいれば、コンパイラの 自動ベクトル化 がループを SIMD 命令に置き換えたり、エンジンが手書き SIMD で述語評価・集約・ハッシュ計算を一括化できます。行指向でフィールドが構造体に散っていると、この一括ロードができません。
もう一つが キャッシュ効率 です。CPUはメモリを キャッシュライン(例 64バイト)単位 で読みます。列を連続配置すれば、1ラインのフェッチで必要な値だけが密に載り、無駄がありません。
| 観点 | 行指向を1行ずつ | 列指向をバッチで(ベクトル化) |
|---|---|---|
| メモリアクセス | 行全体を読むため不要な列も載る | 使う列だけを連続で読み、帯域を節約 |
| SIMD適用 | 困難(値が構造体に散在) | 容易(同型の値が連続配列) |
| キャッシュライン利用率 | 低い(隣接に無関係な列が同居) | 高い(必要な値が密に詰まる) |
| 分岐予測 | 行ごとの型/NULL判定で外れやすい | 単純ループで当たりやすい |
| 関数呼び出し | 1行ごとに演算子段数ぶん | 1バッチで1回に薄まる |
述語評価も分岐を避ける形に変わります。price > 100 を各行で if するのではなく、全要素を比較して真偽を配列(マスク)に書き出し、通過した行の位置だけを 選択ベクトル(selection vector) に集めて後続へ渡す。分岐の代わりに算術と添字操作で進むので、パイプラインが乱れません。さらに列が 辞書エンコード(値を整数IDに置換)されていれば、比較や集約を整数IDのまま実行でき、メモリ帯域と計算量をさらに削れます。ここは圧縮された列を展開せずに処理するという分析基盤特有の最適化で、ビッグデータ処理でこそ効きます。
バッチを大きくするほど固定費は薄まりますが、大きすぎると中間結果がCPUキャッシュ(L1/L2)からあふれ、メモリ往復が増えて逆効果になります。X100 が 1024 前後を選んだのは、複数演算子の中間ベクトルを合わせてもキャッシュに収まる大きさだからです。「1行ずつ(Volcano)」でも「全行を一度に(フル実体化)」でもなく、キャッシュに載る塊で回すのがベクトル化の勘所です。
コード生成:もう一つの高速化軸
固定費を潰すアプローチはベクトル化だけではありません。コード生成(code generation / query compilation) は、クエリごとに 専用の機械語を実行時に生成(JITコンパイル) します。HyPer が広めた データ中心(data-centric)/プッシュ型 モデルが代表で、演算子の抽象を消し去り、そのクエリだけのループへ融合します。
Volcanoの「1行ごとに仮想 next()」も、ベクトル化の「バッチを跨ぐたびに中間ベクトルをメモリへ実体化」も無くし、1行をレジスタに載せたまま、フィルタ→計算→集約を一気通貫で処理する。これを パイプライン融合(operator fusion) と呼びます。
| 軸 | ベクトル化(vectorized) | コード生成(compiled) |
|---|---|---|
| 1回の処理単位 | 列のバッチ(数千行) | 1行を融合パイプラインで貫通 |
| オーバーヘッド削減 | 仮想呼び出しをバッチで割り勘 | 演算子抽象を消しレジスタ内で完結 |
| 主なコスト | 中間ベクトルのメモリ実体化 | 実行前のコンパイル時間 |
| SIMD活用 | 得意(配列に効かせやすい) | 可能だが手当てが要る |
| 起動レイテンシ | 小さい(即実行) | コンパイル分の初期遅延 |
| デバッグ性・移植性 | 高い(通常コードで追える) | 低い(生成コードは追いにくい) |
両者は競合ではなく 相補的 です。ベクトル化は実装・デバッグが容易で、SIMD を効かせやすく、起動が速い。一方でバッチ境界ごとに中間結果をメモリへ書き戻す実体化コストがあります。コード生成はその実体化を消せますが、クエリごとのコンパイルに時間がかかり、短いクエリでは割に合わず、生成コードのデバッグも難しい。
研究(Kersten らの比較)では、両者を突き詰めると性能はおおむね拮抗し、勝敗はクエリ形状に依存すると報告されています。SIMD が効く単純スキャン・集約はベクトル化が有利になりやすく、複雑な述語や関数を長いパイプラインで貫くケースはコード生成の融合が有利になりやすい。ゆえに現代エンジンの多くは二者択一にせず両取りします。Photon や Velox はベクトル化を土台に据えつつコンパイル的最適化を取り込み、一部エンジンはバッチ内のループ本体だけをJITする折衷を採ります。「ベクトル化かコンパイルか」ではなく「どこに実体化コストを、どこにコンパイルコストを払うか」の設計判断です。
分析基盤での位置づけ:なぜ今これが標準なのか
ベクトル化は単体エンジンの小技ではなく、現代の分析・レイクハウス基盤の共通土台になっています。クラウドの列指向ウェアハウスや、Spark を高速化する Photon、Presto/Spark 系の実行を差し替える Velox、組み込み分析の DuckDB、カラム型 OLAP の ClickHouse は、いずれもベクトル化実行を中核に持ちます。
その背景には分散データ処理特有の事情があります。第一に、分析基盤のデータは Parquet / ORC など列指向フォーマットで貯まっており、列を連続で読む実行モデルと最初から相性が良い。第二に、S3 などのオブジェクトストレージからの読み出しは帯域が貴重なので、使う列だけ・圧縮のまま処理する列指向+ベクトル化が I/O 削減に直結する。第三に、1ノードのCPUを使い切る垂直効率を上げることは、同じ処理をより少ないノードでこなせることを意味し、分散クラスタのコストを直接下げます。
押さえるべき要点。(1) Volcanoモデルが分析で遅いのは計算ではなく1行ごとの仮想 next()・分岐・キャッシュミスという固定費が件数分積み上がるため。(2) ベクトル化は処理単位を列のバッチ(数千行)にして固定費を割り勘にし、連続配列ゆえSIMD・キャッシュ効率が効く。(3) バッチはキャッシュに収まる大きさが最適で、大きすぎるとあふれて逆効果。(4) 述語は分岐でなくマスク+選択ベクトルで評価し、辞書エンコードなら整数IDのまま処理して帯域を節約。(5) コード生成は演算子抽象を消しパイプライン融合で実体化を無くす別軸で、ベクトル化と相補的。両者は突き詰めると拮抗し、多くのエンジンは組み合わせる。「ベクトル化=SIMDだけの話」と矮小化しないこと。
まとめ
- 分析クエリでVolcanoモデル(1行ずつ
next())が遅いのは、仮想関数呼び出し・分岐予測ミス・命令キャッシュ汚染という固定費が行数に比例して積み上がるため。実計算より段取りが重い。 - ベクトル化は処理単位を**列のバッチ(数千行)**に変え、固定費をN分の1に割り勘にする。ループが単純化し分岐予測が当たり、命令キャッシュも効く。
- 列を連続配列で持つと SIMD(1命令で複数要素)とキャッシュライン利用率が最大化し、述語はマスク+選択ベクトル、圧縮列は辞書エンコードのまま処理して帯域を節約できる。バッチはキャッシュに収まる大きさが最適。
- コード生成(JIT)は演算子抽象を消してパイプライン融合し、中間結果の実体化を無くす別の高速化軸。ベクトル化とは相補的で、実体化コストとコンパイルコストのどちらを払うかの選択。
- 両者は突き詰めると性能が拮抗し、Photon・Velox・DuckDB・ClickHouse など現代の分析基盤はベクトル化を土台に両取りする。列指向フォーマット・オブジェクトストレージ・分散コストの事情がこれを標準にした。
データ工学 Article
ベクトル化クエリ実行を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ベクトル化
比較で見る軸
難易度: advanced / カテゴリ: データ工学 / タグ数: 6
導入後に効く点
列を連続配列で持つとタイトなループになり、コンパイラの自動ベクトル化やSIMD命令で1命令が複数要素を同時に処理できる。データがCPUキャッシュに乗り、分岐のない述語評価(selection vector)や辞書エンコードとの相性でメモリ帯域も節約できる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データ工学
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ベクトル化 / SIMD」に近いか確認する。
- 強みである「分析エンジン(ClickHouse/DuckDB/Photon/Velox等)が速いのは、行を1件ずつ関数呼び出しで回すVolcanoモデルをやめ、同じ列の値を数千件まとめて処理するベクトル化にあるためだ。仮想関数呼び出しと分岐予測ミスのオーバーヘッドが件数で割り勘になり、1行あたりのCPUコストが桁で下がる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。