Fused Filter + GROUP BY Design (Boolean 6.4x 专项)¶
Status: complete — Phases 1-3 merged and released in v1.33.1; unchanged through v1.34.0 (2026-09-20).
Problem¶
No-cache benchmark metric Boolean Filter+GROUP+HAVING+TopK:
SELECT city, COUNT(*) AS n, AVG(score) AS av
FROM default
WHERE (city IN ('Beijing','Shanghai','Guangzhou') OR age IN (33,44,55))
AND score >= 40
GROUP BY city
HAVING COUNT(*) > 100
ORDER BY n DESC, city
LIMIT 5
ApexBase 37.2 ms vs DuckDB 5.83 ms (6.4x slower) — the only structural weakness on the 103-metric fair scoreboard.
Verified cost breakdown (1M rows, no cache)¶
Stage costs measured on the generic pipeline for this query:
| Stage | Cost | Notes |
|---|---|---|
Candidate lane (city IN scan) |
~7 ms | already dict-key level: per-row u16 index + flag test |
| OR/AND index merge | ~3 ms | O(n) two-pointer merge (fixed in v1.33.0 dev) |
| Gather (300K rows × 3 cols) | ~14 ms | extract_rows_by_indices_to_arrow; city decoded per row into a new StringArray; primitives use Vec<Option<T>> (16 B/row) |
| Morsel predicate re-eval | ~4 ms | generic In leaf does per-row string compare per value on the StringDictionary view |
| GROUP BY city (192K rows) | ~1 ms | execute_group_by dispatch, not a bottleneck |
The pipeline reads the same data 2–3 times (candidate lane, gather, re-eval) and re-evaluates the same predicate twice. That redundancy is the structural gap vs DuckDB's single fused vectorized scan.
Design principles¶
- One physical protocol. Dictionary keys (u16 row ids) + primitive
column lanes are the shared representation for candidate lanes, morsel
re-evaluation, and the fused aggregation kernel. New filter shapes are
expressed as small predicate trees over these lanes and route to the
fused path automatically when capability-gated, otherwise to the improved
generic pipeline. No per-metric
try_*branching on SQL text. - Capability-gated fusion. The fused kernel accepts a bounded predicate grammar (leaves: numeric range, numeric IN, string IN/Eq on a dictionary-encoded column; structure: AND/OR/NOT). Anything outside the grammar falls back to the generic pipeline — correctness first.
- Post-aggregate tail processing. HAVING / ORDER BY / LIMIT / OFFSET run
on the aggregated result (≤ dictionary size rows), reusing existing
apply_order_by_topk/apply_limit_offset/ HAVING evaluation.
Phase 1 — generic pipeline quick wins¶
Benefits every filtered query, not just the benchmark shape.
P1.1 Mixed-projection dictionary gather¶
read_columns_by_indices_to_arrow today: try_dict_indexed_read requires
all projected columns to be dictionary-covered, so a mixed projection
(city, age, score) falls back to full indexed extraction, which decodes
every selected city string and copies it into a fresh StringArray.
Change: per-column dispatch in the V4 indexed path:
- string column with a usable global dict cache (no nulls, ≤4096 distinct,
group_ids length matches active rows) →
DictionaryArraywith keys gathered from the cached u16 ids (2 B/row); - every other column → existing parallel indexed extraction for that column.
Result batch may mix DictionaryArray and primitive columns. Downstream
operators already accept both (ColumnView::StringDictionary,
execute_group_by dict dispatches).
P1.2 Dictionary-level IN/Eq in morsel re-evaluation¶
ResolvedPredicate::In on a StringDictionary view currently resolves each
row's string and compares against every IN value. Add a DictIn leaf:
resolve matching dictionary keys once (O(distinct)), then per row do a u32
key load + bitset test. Same for StringEq on a dictionary view
(DictEq, single key compare). Falls back to the generic leaf when the view
is not a dictionary or the value set is empty.
P1.3 Primitive gather layout¶
In extract_rows_by_indices_to_arrow, replace Vec<Option<i64>> /
Vec<Option<f64>> accumulators (16 B/row incl. tag + zero fill) with
primitive buffers + validity buffers (8 B/row), built directly into Arrow
Int64Array / Float64Array with NullBuffer.
Expected Phase-1 total: 37.2 ms → ~30 ms. The fused path (Phase 2) is the real win; Phase 1 raises the floor for all fallback shapes.
Phase 2 — fused predicate-lane aggregation kernel¶
Generalize the existing fused path
(try_fast_numeric_filter_group_by +
OnDemandStorage::execute_between_group_agg_cached(_mmap)) instead of adding
a parallel mechanism:
Kernel¶
Single streaming pass over row groups (existing RCIX plumbing):
- group column: dictionary-encoded (global dict cache provides per-row u16 ids + distinct strings; ≤4096 distinct);
- up to 2 additional numeric lanes (Int64/UInt*/Float64, plain or bitpack / float-dict encodings as already decoded by the kernel);
- predicate tree over lanes:
- leaf
num_range(col, lo, hi)with the existing epsilon/ceil/floor strict-bound semantics; - leaf
num_in(col, sorted_values)(binary search, ≤16 values); - leaf
dict_in(group_col, keys)/dict_eq(group_col, key)(bitset over u16 ids, computed once from distinct strings); - structure: AND / OR (NOT only around a leaf).
- per row:
pass = eval(tree)→counts[key] += pass,sums[key] += pass*agg[row].
Aggregates: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col) over one numeric column (same set the kernel already supports; MIN/MAX added via per-group running min/max when enabled by gate).
Gate (in try_fast_numeric_filter_group_by, generalized)¶
Accept when:
- single dictionary-encoded group column, ≤4096 distinct, no nulls in group/filter/aggregate columns;
- WHERE is a tree of the supported leaves over ≤2 non-group numeric columns plus the group column itself (string IN/Eq);
- SELECT = group column + COUNT(*) / SUM / AVG / MIN / MAX over ≤1 numeric column (existing column-shape check);
- no joins, no DISTINCT, no windows, no delta/pending state;
- HAVING: optional, references only the aggregates in SELECT (post-filter);
- ORDER BY / LIMIT / OFFSET: applied post-aggregation (existing helpers).
Anything unparseable returns None → existing generic pipeline.
Expected result¶
1M-row single pass: ~18 MB sequential column reads (u16 ids + 2×8 B lanes) + predicate eval + L1-resident accumulation into ≤10 group slots. Target 8–12 ms (≤2x DuckDB), i.e. a 3–4x improvement over the 37.2 ms generic pipeline.
Phase 3 — LUT truth table + fast lane decode¶
Phase 2's per-row predicate-tree evaluation is still the dominant cost once the data path is lean. Phase 3 removes both remaining costs: per-row tree evaluation (truth table) and per-row-group lane materialization (in-place decode).
P3.1 LUT truth-table fast path¶
When the predicate has ≤3 lane leaves (dictionary leaves are folded to
group-id masks at compile time), the whole AND/OR/NOT tree is precomputed
into a truth table over (comparison bits, group id):
lut_prog = try_build_lut_program(predicate, num_groups): for each of the ≤3 lane leaves a comparison bit is defined (range or IN membership), and the compiled LUT maps(bits << stride_shift) | group_id → 0/1, wherestride = next_power_of_two(num_groups).- Size bound:
2^k × stride ≤ 8 × 4096 = 32 KB(k ≤ 3, groups ≤ 4096) — L1/L2 resident, one lookup per row. - Per row: k straight-line comparisons (no branches over the tree), one LUT byte load, one scatter into the group accumulator.
- The row loop is monomorphized per per-leaf mode tuple via const-generic
lut_loop_{0,1,2,3}(84 instantiations); IN comparisons with N ≤ 8 are unrolled (first four direct, remainder a tiny loop) because a runtime-length vectorized loop emits heavy reduction code. all_none(no row can match) returns without scanning.- Predicates with >3 lane leaves fall back to the Phase 2 generic per-slot plan — same kernel, same buffers, unchanged behavior.
P3.2 In-place lane views (no per-RG materialization)¶
decode_fused_lane returns a view instead of a copied buffer:
- PLAIN, 8-byte aligned → zero-copy
&[i64]/&[f64]over the mmap body (existing behavior). - PLAIN, misaligned → raw in-place byte view (
FusedLaneView::Raw*); the LUT loop reads 8-byte slots withptr::read_unaligned, which is a native unaligned load on ARM64. No per-RG memcpy, no allocation. The generic (fallback) path materializes such lanes into scratch buffers. - BITPACK int → two-word shift-merge decode (
bitpack_fillinmod.rs; each value spans at most two u64 words, short per-bit tail for the final partial word) written directly into the caller buffer — the generic decode path no longer allocates an intermediate per-RG Vec. The sharedbitpack_decode_i64uses the same core, so all bitpack consumers benefit.
Measured (1M rows, no cache, probe query)¶
| Stage | Before P3 | After P3 |
|---|---|---|
| Row loop (k=2, count+avg) | 6.1 ms | 4.2 ms |
| Lane decode (age BITPACK + score PLAIN) | 4.5 ms | ~1.3 ms |
Total Boolean Filter+GROUP+HAVING+TopK (probe, no cache) |
10.8 ms | 5.5 ms |
Public benchmark (no cache, 1M rows): the metric moved from 37.2 ms (6.4x slower than DuckDB) to 5.55 ms vs DuckDB 5.76 ms — now the winner. Tabular fair scoreboard: 103/103 (was 101/103).
Current no-cache results for the same metric: 3.98 ms vs DuckDB 5.68 ms in the v1.33.1 snapshot, and 4.01 ms vs DuckDB 5.75 ms in the v1.34.0 snapshot, with 103/103 tabular wins in both. The table above records the phase-3 measurement taken during v1.33.0 development and is kept as the design record.
Testing and acceptance (per phase, per AGENTS.md)¶
- Rust unit tests: kernel vs generic pipeline on random predicate trees (fuzzed leaves/structure), null-free gates, boundary epsilon cases, HAVING/ORDER/LIMIT tails; P1.1/P1.2 mixed-batch round trips.
- Python differential tests in
test/test_query_architecture_contracts.py: random WHERE/GROUP/HAVING/ORDER/LIMIT shapes, fused result must match the generic pipeline row-for-row. - Benchmark: the existing
Boolean Filter+GROUP+HAVING+TopKmetric covers the fused gate; P1.1/P1.2 are covered by the existing filtering metrics. - Acceptance per phase:
maturin develop --release, full pytest, full cargo test, public benchmark (default +--no-result-cache) vsbenchmarks/latest_public_baseline.json, canary gate, and--mode fulllocal perf guard before release.
Risk notes¶
- Epsilon/strict-bound semantics of the numeric range leaf must be reused verbatim from the existing kernel (no re-derivation).
group_ids(u16) validity: the gate must re-validate dictionary cache length against active rows (deletes) exactly astry_dict_indexed_readdoes.- Mixed DictionaryArray batches change batch schema for some queries; the
Python layer and any code that assumes
Utf8for string columns must be audited (Arrow consumers ininto_record_batchand result materialization).