perf(r2rml): vectorized fused-aggregate fold + late-rendered decode seam (Gap-4 P1) - #1582
Draft
aaj3f wants to merge 2 commits into
Draft
perf(r2rml): vectorized fused-aggregate fold + late-rendered decode seam (Gap-4 P1)#1582aaj3f wants to merge 2 commits into
aaj3f wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
(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 freshVec<GKey>and cloned each string key every row, then hashed aVec<GKey>through SipHash into aHashMap<Vec<GKey>, Vec<Acc>>— on cq027 that is 2MStringclones + 1M SipHashes to map 1M rows onto 18 groups (GroupCol::key_at14.4% + SipHash 7.5%, P0). New path: build the composite key borrowed (GKeyRef, no per-rowStringclone /Vecalloc) into a reused scratch, hash withFxHasher, and intern it to a dense id in ahashbrown::HashTable<(Vec<GKey>, u32)>; the owned key is materialized only when a new group is inserted. Typed accumulators live ingroup_accs[id].HashTable(notHashMap+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 earlierHashMap+insert_hashed_nocheckdraft 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}→BigDecimalon overflow, unchangedaccumulate_*/Acc), every fold semantic, the memory-budget group-growth checkpoint, and the whole decline/fallback surface (admission untouched). f64SUMstays 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'sVec<Option<T>>(build_columns_from_values) — a full intermediate allocation per column, and twoStringallocations per string cell. Newarrow_column_to_columnbuilds theColumndirectly 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 extractedcolumn_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-keyHashMap<Vec<GKey>, Vec<Acc>>fold.FLUREE_ARROW_DIRECT_DECODE— N2. Off restores the two-hoparrow_column_to_values+build_columns_from_valuespath.Gates
direct_decode_matches_two_hop— assertsarrow_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 (None⟺GKey::Null).vector_fold_grouping_matches_owned_map— the dense dict partitions + counts a key sequence identically to the owned-key map.fluree-db-query,fluree-db-iceberg). clippy--all-targets --no-depsdiff-clean on both crates (no new warnings introduced by this change).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.
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 ?cwhere?c = edw:customer) declines the fuse — its group key is aRefObjectMap/IRI, andscalar_column_for_varadmits onlyObjectMap::Column— so it runs the generic per-rowmaterialize_batchpath. The P0 profile confirms it is a minting shape (term-mint 58.2%, viaexpand_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 aRefObjectMapgroup 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.