Skip to content

perf(r2rml): vectorized fused-aggregate fold + late-rendered decode seam (Gap-4 P1) - #1582

Draft
aaj3f wants to merge 2 commits into
mainfrom
perf/fused-vectorized-fold
Draft

perf(r2rml): vectorized fused-aggregate fold + late-rendered decode seam (Gap-4 P1)#1582
aaj3f wants to merge 2 commits into
mainfrom
perf/fused-vectorized-fold

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What & why

Gap-4 program, P1 — the fused-tail fix (the first gated PR family from the adjudicated slate). The external-engine A/B measured the fused R2RML/Iceberg aggregate family 6–52× behind DuckDB at scale, and a co-resident CPU profile spike (P0) settled where that time goes. On the genuinely-fused pairs (cq014, cq027) RDF term-minting is 0.0% of on-CPU wall — the fused fold never mints — refuting "late materialization is the cure." The tail is two costs, co-primary:

pair (fused) term-mint fold mechanics decode-copy arrow/IO
cq014 0.0% 39.6% 32.9% 13.7%
cq027 (1M-row) 0.0% 45.9% 46.2% 4.5%

(For contrast, the shapes that decline the fuse — cq038, crt_highcard — are 58–83% minting; that is an admission/coverage problem, out of scope here.)

This PR attacks exactly those two costs, corridor-only (r2rml/iceberg), each behind a default-on kill switch whose OFF path is byte-identical to today.

Mechanisms

N1 — vectorized fused-aggregate fold (fluree-db-query/src/r2rml/fused_aggregate.rs). The GROUP BY fold allocated a fresh Vec<GKey> and cloned each string key every row, then hashed a Vec<GKey> through SipHash into a HashMap<Vec<GKey>, Vec<Acc>> — on cq027 that is 2M String clones + 1M SipHashes to map 1M rows onto 18 groups (GroupCol::key_at 14.4% + SipHash 7.5%, P0). New path: build the composite key borrowed (GKeyRef, no per-row String clone / Vec alloc) into a reused scratch, hash with FxHasher, and intern it to a dense id in a hashbrown::HashTable<(Vec<GKey>, u32)>; the owned key is materialized only when a new group is inserted. Typed accumulators live in group_accs[id].
HashTable (not HashMap + insert_hashed_nocheck) is deliberate: the borrowed probe and the resize-rehash go through the SAME hash function (hash_key_refs / gkeys_hash, kept in lockstep), so a table grow can never re-bucket a key away from its probe. (An earlier HashMap+insert_hashed_nocheck draft did split groups on resize — the live A/B differential caught it before commit; the grouping test is now hardened to force grows.)
Preserved exactly: the exact-arithmetic domain (Dec{i128,scale}BigDecimal on overflow, unchanged accumulate_*/Acc), every fold semantic, the memory-budget group-growth checkpoint, and the whole decline/fallback surface (admission untouched). f64 SUM stays bit-identical because each group's rows fold in the same row order under both paths.

N2 — direct Arrow→Column decode (fluree-db-iceberg/src/io/{arrow_reader,parquet}.rs). Decode was a two-hop copy: Arrow array → Vec<Option<ColumnValue>> (arrow_column_to_values) → Column's Vec<Option<T>> (build_columns_from_values) — a full intermediate allocation per column, and two String allocations per string cell. New arrow_column_to_column builds the Column directly from the Arrow array (one hop; one string alloc). Its fast arms cover only the case where the Arrow physical type matches the field type — each provably value-identical to the old composition — and every other (cross-type coercion, timestamp unit-scaling, unhandled type, schema-evolution-absent column) falls back to the exact old mapping via the extracted column_from_values. So behavior is unchanged everywhere; only the hot common path is shortened.

Switch inventory (both default ON; OFF = byte-identical old path)

  • FLUREE_FUSED_VECTOR_FOLD — N1. Off restores the owned-key HashMap<Vec<GKey>, Vec<Acc>> fold.
  • FLUREE_ARROW_DIRECT_DECODE — N2. Off restores the two-hop arrow_column_to_values + build_columns_from_values path.

Gates

  • Value-identical differentials (hermetic):
    • direct_decode_matches_two_hop — asserts arrow_column_to_column == the two-hop path (Debug-equal) across every fast type + nulls + NaN + cross-type fallbacks.
    • key_ref_at_matches_key_at — the borrowed key read equals the owned read for every Column kind, null, out-of-bounds, wrong-type, and the NUMBER(n,0) physical-Decimal integer coercion (NoneGKey::Null).
    • vector_fold_grouping_matches_owned_map — the dense dict partitions + counts a key sequence identically to the owned-key map.
  • fmt diff-clean (fluree-db-query, fluree-db-iceberg). clippy --all-targets --no-deps diff-clean on both crates (no new warnings introduced by this change).
  • Crate tests: fluree-db-query, fluree-db-iceberg --all-features, fluree-db-api (featureless + iceberg) — all green.

Live yardstick (Substrate B, SF0.1, warm; same binary, old switches OFF vs new ON)

Median of 7 warm reps, fresh process/rep, local MinIO+REST fixture (macOS, co-resident host — absolute ms are noisier than the EC2 Wave-C rows and not host-comparable to them; the OFF→ON ratio on one binary is the acceptance evidence). DuckDB column is the EC2 Wave-C SF0.1 number for context only.

pair old (OFF) new (ON) speedup result DuckDB (EC2 WaveC)
cq014 (fused single-table COUNT+SUM) 26.0 ms 14.1 ms 1.84× IDENTICAL 56 ms
cq027 (fused 1M-row grouped COUNT) 199.1 ms 117.9 ms 1.69× IDENTICAL 77 ms
cq008 (fused fact→dim→dim rollup) 203.4 ms 208.5 ms 0.98× IDENTICAL 128 ms
crt_minmax (MIN/MAX — separate fast path) 126.1 ms 126.5 ms 1.00× IDENTICAL 59 ms
crt_highcard (declines → generic path) 1440 ms 1470 ms 0.98× IDENTICAL 238 ms

Results are byte-identical (sorted) between OFF and ON on every pair — kill-criterion-1 satisfied. The movers are the single-table fused GROUP BYs the P0 profile fingered (cq014, cq027). cq008 is join-resolution-dominated (its fold/decode are a small share, so ~flat); crt_minmax uses a different fast path; crt_highcard declines to the generic path (no movement by design — see below). An earlier draft that split groups on table resize was caught by this very differential and fixed before commit.

Why crt_highcard does NOT move here (expected, by design)

crt_highcard (GROUP BY ?c where ?c = edw:customer) declines the fuse — its group key is a RefObjectMap/IRI, and scalar_column_for_var admits only ObjectMap::Column — so it runs the generic per-row materialize_batch path. The P0 profile confirms it is a minting shape (term-mint 58.2%, via expand_template_from_batch + regex), not a fold/decode shape, so P1 correctly shows ~no movement on it (its small decode-seam share, 3.6%, is all N2 can touch). It becomes a mover only after admission widening (P2 / N3): admit a RefObjectMap group key by folding on the FK column value and minting the group-key IRI once per group at output — i.e. this PR's dict + late-materialize-only-on-emit substrate extended to IRI keys. That is the designed follow-up; it is deliberately not built here.

Scope / non-goals

Corridor-only: the engine-wide hash_join.rs / group_aggregate.rs (native, Binding-based) are untouched, so native legs are byte-identical for the mechanical reason they never enter this path. Minting on the un-fused shapes (cq038/crt_highcard) is the next program step (admission widening, N3), not this PR. MIN/MAX is a separate fast path, not this fold.

aaj3f added 2 commits July 31, 2026 12:11
…id dict

The GROUP BY fold allocated a fresh Vec<GKey> and cloned each string key every
row, then SipHashed a Vec<GKey> into HashMap<Vec<GKey>, Vec<Acc>>. On a 1M-row
scan grouping to 18 groups that is ~2M String clones + 1M SipHashes (Gap-4 P0
profile: GroupCol::key_at 14.4% + SipHash 7.5% of on-CPU wall).

Build the composite key BORROWED (GKeyRef — no per-row String clone / Vec alloc)
into a reused scratch, FxHash it, and intern to a dense id in a
hashbrown::HashTable; the owned key is materialized only when a new group is
inserted, and typed accumulators live in group_accs[id]. A HashTable (not a
HashMap + insert_hashed_nocheck) is used so the borrowed probe and the
resize-rehash share one hash function (hash_key_refs / gkeys_hash) — otherwise a
table grow re-buckets a key away from its probe and splits its group.

Preserves exactly: the exact-arithmetic domain (Dec{i128,scale} -> BigDecimal on
overflow, unchanged accumulate_*/Acc), every fold semantic, the memory-budget
group-growth checkpoint, and the whole decline/fallback surface (admission
untouched). f64 SUM stays bit-identical (each group folds its rows in the same
row order under both paths).

Gated by FLUREE_FUSED_VECTOR_FOLD (default on; off = byte-identical owned-key
fold). Tests: key_ref_at_matches_key_at (borrowed read == owned read for every
Column kind/null/coercion), key_hash_borrowed_matches_owned (resize-safety
invariant), and a resize-forcing vector_fold_grouping_matches_owned_map.
…umnValue intermediate

The Arrow decode built a Vec<Option<ColumnValue>> per column
(arrow_column_to_values) and then a second Column Vec<Option<T>>
(build_columns_from_values) — a two-hop copy, and two String allocations per
string cell. The Gap-4 P0 profile measured this decode seam at ~33-46% of on-CPU
wall on the fused GROUP BY family (46% on the 1M-row cq027).

Add arrow_column_to_column, which builds the Column directly from the Arrow array
in one hop. Its fast arms cover the cases where the Arrow physical type matches
the target field type (each value-identical to the old composition); every other
pair — cross-type coercions, timestamp unit-scaling, unhandled types, and
schema-evolution-absent columns — falls back to the exact old mapping via the
extracted column_from_values, so behavior is unchanged there.

Gated by FLUREE_ARROW_DIRECT_DECODE (default on; off = byte-identical two-hop
path). Differential test direct_decode_matches_two_hop asserts the two paths
produce identical Columns across every fast type + nulls + NaN + the cross-type
fallbacks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant