Skip to content

query: index-walk range semi-join for correlated range probes, plus four join-chain fixes (BSBM Explore Q5) - #1815

Merged
bplatz merged 3 commits into
mainfrom
perf/bsbm-q5-range-semijoin
Sep 10, 2026
Merged

bplatz merged 3 commits into
mainfrom
perf/bsbm-q5-range-semijoin

Conversation

@bplatz

@bplatz bplatz commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

BSBM Explore Q5 (products similar to an anchor: a shared feature and two numeric properties within ±k of the anchor's) grew ~65x from 1M to 100M products and was 56–59% of the Explore mix. The cost is per-candidate CPU in the join chain over a candidate set of ~13% of all products, not I/O.

Four generic fixes, on for every query:

  • planner: among equal-cardinality bound-subject probes, place first the one whose new variable a deferred FILTER pins (equality, IN, or a two-sided range with comparands free of the variable). Q5 evaluated its two range filters after paying for an rdfs:label lookup on every candidate. A one-sided bound does not count: BSBM Q7's ?date > now keeps half the offers, and hoisting that probe ahead of the selective vendor/country check cost 15% on Q7 at 100M.
  • where_plan: insert the WHERE-level early Distinct only where a live variable dies at that step, instead of after every step once any has.
  • distinct: normalize keys through Cow and a reused scratch vector, so a duplicate costs a hash probe and no allocation.
  • eval: try the encoded-IRI equality fast path before the bool-predicate LRU for ?v = / != <iri> shapes (the 256-entry LRU thrashed over thousands of candidates).

RangeSemiJoinOperator (fluree-db-query/src/range_semijoin.rs): a probe ?s <p> ?v . FILTER(range on ?v) whose value has no pushed-down constant bounds and is read by exactly one range filter with bounds computable from earlier-bound variables, and by nothing else, is folded behind the step that binds ?s, under the same where_dedup_safe license the early dedup uses (it is a semi-join), never in history mode, with a 256-row minimum when a driving estimate is available. Per batch it takes the union of the rows' intervals, walks the predicate's POST leaflets for that envelope (directory-key pruning, binary search of the sorted key column of predicate- and type-homogeneous leaflets, only the subject and key columns decoded) into a subject → values map, and keeps a driving row iff one of its values passes the row's own interval exactly. The walk serves numeric-bound batches, using inline range keys where possible and decoding arena-backed decimals/large integers; a batch with a date or string bound, a walk that would cover more than max(4k, 16 × driving rows) rows, or a context where raw leaflets are not authoritative (novelty overlay, time-travel, dataset scope, restricting policy) is answered by the batched subject probes the nested loop would have used, and when those probes decline, a single generic probe/filter plan serves each batch with a private row ID preserving per-row bounds. In contexts where the fast probe lane cannot serve, the existing Distinct operator removes duplicate driving rows before fallback work, under the fold's multiplicity-insensitive license. Walked and driving rows are charged fuel per batch. Each condition allows at most four walks before switching to probes, bounding repeated envelope rebuilds; rare materialized values are boxed to keep inline arena entries small. The fold is stamped range-semijoin for the routing tests and honours FLUREE_DISABLE_QUERY_FAST_PATHS.

Original measurements (before review fixes)

Result counts identical across every cell; 34 real products byte-identical at 1M and 10M.

base this PR
10M local, Q5 (Product1) 6.6 ms 1.9 ms
100M EC2 m7a.4xlarge, Q5 at c1 43.4 ms 19.1 ms
100M, Q5 at c16 50.9 ms 22.8 ms
100M, Explore mix QMpH at c1 / c16 18,848 / 251,224 25,227 / 339,313

100M is official 4.2.0 vs HEAD-from-source vs this tree, two interleaved rounds; every other Explore query within noise of base after the tie-break was narrowed. At 1M through the BSBM driver (two interleaved rounds) the Explore mix is +1–2% (Q5 0.51 → 0.43 ms from the generic fixes; the fold stays below its driving-row gate) and the BI mix is flat within 2% on all eight queries.

These historical 10M/100M runs have not been repeated after the review fixes; they do not establish which range-semi-join lane ran at those scales. See the local review-validation measurements below.

Tests

  • fluree-db-api/tests/it_range_semijoin.rs computes expected rows in Rust over a generated fixture (strict boundaries, multi-valued numerics, a non-numeric value, a missing property, duplicate candidates, a novelty-only product, a projected value that must keep the real join, a wide range that must cap the walk, a date window that must take the probe path) and asserts routing through the stamps.
  • Planner tests pin the tie-break, its one-sided exclusion, and the case where filter preference competes with the object-to-subject hash-scan preference. The precedence itself is unchanged by review fixes.
  • it_query_explain pins the Distinct placement.
  • W3C SPARQL suite and the query suites pass; clippy -D warnings and fmt clean.

Review regression coverage

  • Indexed decimal and out-of-i64 integer values, including in-range and excluded values.
  • Constant comparisons (>, >=, <, <=, =) combined with correlated ranges on indexed and novelty views.
  • Batch fallback with repeated subjects and different bounds, unbound/poisoned/missing subjects, and a non-root policy excluding a subject.
  • Deterministic growing-envelope batches: four walks maximum, then probes with the same results.
  • Outward integer key bounds for decimal ranges beyond exact f64 integer precision.
  • A correlated Q5 matrix in query_hot_bsbm, with actual runtime routing checks outside timed iterations; indexed scale plus novelty and policy fallback fixtures. This is separate from the existing constant-price Q5 benchmark.

Both wrong-result regressions were reproduced using the expanded fixture on pre-fix commit c859037bf: the constant bound admitted extra rows, and the indexed query omitted decimal_in.

Local measurements after review fixes

Same generated correlated-Q5 fixture and benchmark source in separate builds of pre-fix c859037bf and the corrected tree, using optimized dev-fast (opt-level 2). Untimed preflights check result counts against an independent calculation and record actual execution routes; timed iterations have tracing disabled. These are local diagnostics, not new production BSBM results.

Fixture Before fixes After fixes
Indexed, 10k products (two rounds) 0.326–0.423 ms 0.326–0.331 ms
Indexed, 100k products (two longer repeats) 2.72–2.79 ms 2.73–2.75 ms
Pure novelty, 1,500 products (two rounds) 29.1–32.6 ms 6.72–7.56 ms
Non-root policy, 1,500 products (two rounds) 46.3–46.6 ms 16.3–18.9 ms

Ranges are Criterion point estimates across runs, not confidence intervals. Initial 2-second 100k runs were 2.32 ms before / 2.58 ms after; the two interleaved 5-second repeats above did not reproduce that slowdown. There is no repeatable indexed regression in this local matrix, but the 10M/100M production workload still needs remeasurement for a production-scale conclusion.

At 10k, routing records four walked condition-batches and no probe condition-batches. At 100k, both versions record 13 walked and 13 probe condition-batches: one range walks, the other hits the conservative first-batch cap and uses probes. We leave that cap unchanged and make no claim that the historical 10M/100M gains came from walks. Novelty and policy cases use the generic fallback; after early input dedup they need four condition-batches rather than eight.

As a diagnostic, disabling all query shape fast paths on the corrected 10k fixture gives 0.920 ms indexed, 6.46 ms novelty, and 16.0 ms policy. This is not a fold-only comparison; it shows the corrected fallback is close to the generic pipeline rather than several times slower.

Reproduce with FLUREE_BENCH_SCALE=medium cargo bench -p fluree-db-api --bench query_hot_bsbm --profile dev-fast -- query_range_semijoin --warm-up-time 0.5 --measurement-time 2 --noplot (use large for 100k indexed products; fallback fixtures remain capped at 1,500).

The constant-bound correctness guard may cost more for those variants because it retains the enforcing join, and arena numeric support performs checks that were previously skipped incorrectly. Neither changes the eligibility of the original integer correlated-Q5 shape. We retain the conservative child cardinality estimate; range selectivity and a fresh large-scale BI-1 tie-break comparison remain follow-up work, not claims made by these fixes.

@bplatz bplatz added the area:query Query execution, planning, fast paths, overlay, result formatting label Sep 8, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 8, 2026 11:03
@bplatz
bplatz force-pushed the perf/bsbm-q5-range-semijoin branch from 2c34ab5 to 8572ea1 Compare September 8, 2026 11:40
@bplatz
bplatz force-pushed the fix/incremental-stats-flat-ndv branch 2 times, most recently from 2457d2d to a1d79e3 Compare September 8, 2026 12:59
… four generic join-chain fixes (BSBM Explore Q5)

BSBM Explore Q5 (products similar to an anchor: shared feature, two numeric
properties within +-k of the anchor's) grew ~65x from 1M to 100M products and
was 56-59% of the Explore mix. The cost is per-candidate CPU in the join
chain over a candidate set of ~13% of all products, not I/O.

Generic fixes, on for every query:

- planner: among equal-cardinality bound-subject probes, place first the one
  whose new variable a deferred FILTER pins (equality, IN, or a two-sided
  range with comparands free of the variable). Q5 evaluated its two range
  filters after paying for an rdfs:label lookup on every candidate. A
  one-sided bound does not count: BSBM Q7's `?date > now` keeps half the
  offers, and hoisting that probe ahead of the selective vendor/country
  check cost 15% on Q7 at 100M.
- where_plan: insert the WHERE-level early Distinct only where a live
  variable dies at that step, instead of after every step once any has.
- distinct: normalize keys through Cow and a reused scratch vector, so a
  duplicate costs a hash probe and no allocation.
- eval: try the encoded IRI equality fast path before the bool-predicate
  LRU for `?v = / != <iri>` shapes (the 256-entry LRU thrashed over
  thousands of candidates).

RangeSemiJoinOperator (fluree-db-query/src/range_semijoin.rs): a probe
`?s <p> ?v . FILTER(range on ?v)` whose value is read by exactly one range
filter with bounds computable from earlier-bound variables, and by nothing
else, is folded behind the step that binds `?s` under the same
where_dedup_safe license the early dedup uses (it is a semi-join), never in
history mode, and only when the planner expects at least 256 driving rows.
Per batch it takes the union of the rows' intervals, walks the predicate's
POST leaflets for that envelope (directory-key pruning, binary search of the
sorted key column of predicate- and type-homogeneous leaflets, only the
subject and key columns decoded) into a subject -> values map, and keeps a
driving row iff one of its values passes the row's own interval exactly.
The walk serves batches whose bounds are all inline numerics; a batch with a
date or string bound, a walk that would cover more than max(4k, 16 x driving
rows) rows, or a context where raw leaflets are not authoritative (novelty
overlay, time-travel, dataset scope, restricting policy) is answered by the
batched subject probes the nested loop would have used, so the lane never
costs more than the chain it replaced. Walked and driving rows are charged
fuel per batch. The fold is stamped `range-semijoin` for the routing tests
and honours FLUREE_DISABLE_QUERY_FAST_PATHS.

Measured (result counts identical across every cell; 34 real products
byte-identical at 1M and 10M):

- 10M local, Q5: 5.5 ms -> 3.5 ms with the generic fixes -> 2.0 ms with the
  semi-join (-64%); 1M unchanged (below the driving-row gate).
- 100M EC2 (m7a.4xlarge, official 4.2.0 vs HEAD-from-source vs candidate,
  two interleaved rounds): Q5 43.4 -> 19.1 ms at c1 and 50.9 -> 22.8 ms at
  c16; Explore mix throughput +34% (c1) / +35% (c16); every other Explore
  query within noise of base after the tie-break was narrowed.
- 1M local through the BSBM driver, two interleaved rounds: Explore mix
  +1-2% (Q5 0.51 -> 0.43 ms from the generic fixes; the fold stays below
  its driving-row gate), BI mix flat within 2% on all eight queries.

Tests: it_range_semijoin.rs computes expected rows in Rust over a generated
fixture (strict boundaries, multi-valued numerics, a non-numeric value, a
missing property, duplicate candidates, a novelty-only product, a projected
value that must keep the real join, a wide range that must cap the walk, a
date window that must take the probe path) and asserts routing through the
stamps; planner tests pin the tie-break and its one-sided exclusion;
it_query_explain pins the Distinct placement.

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me @bplatz and makes sense in relation to the BSBM Explore Q5 that's mentioned. I didn't personally find any issues but Claude noticed a few misses / allowances for wrong results, so I'll include that review verbatim below:


This is a really nice piece of engine work — the four generic fixes are each small, verifiable and clearly positive (the Cow distinct in particular), the fold's liveness analysis under where_dedup_safe held up against everything I threw at it, and the indexed Q5 shape on your own fixture runs 2.1× faster with only the fold toggled. I'm requesting changes for two silent-wrong-results bugs in the fold and one performance regression, all three reproduced at head against the generic pipeline: (1) the POST walk gates on OType::is_numeric(), which is "inline only", so every typed xsd:decimal value is invisible to it — an in-window decimal product is returned generically and dropped under the fold (range_semijoin.rs:625, :745); (2) a constant FILTER(?s1 > 150) on the folded variable is consumed by the pushdown and then never applied, because the folded triple never reaches build_scan_or_join — Q5 + that filter goes from 9 rows to 34 (where_plan.rs:3362); (3) wherever the probe lane declines (no binary store, non-root policy, multi-ledger, eager materialization) every driving row re-plans and re-opens a seeded operator tree, which on the pure-novelty view of your fixture is 4.2× slower than the chain it replaces (range_semijoin.rs:885) — so "never costs more than the chain it replaced" doesn't hold in exactly the contexts Solo users hit most. Fixes are small for the first two (admit NUM_BIG_OVERFLOW into the mixed-path gate; refuse or absorb pushdown bounds on the folded var) and the third wants a batch-level seeded fallback instead of a row-level one; the fixture needs a decimal product and a constant+correlated filter case so both stay fixed. A few questions in the inline notes (does the walk fire at all at 10M/100M given the 16×-first-batch cap; the rebuild-on-envelope-growth shape; tie-break precedence over the BI-1 hash-scan rule) that I'd love your read on but aren't blocking.

Adherence to repo commitments:

  • Patterns/abstractions: ⚠️ Extends the existing lanes (subject_probe_lane_plan, batched_subject_probe_binary, prepare_leaf_for_scan, PreparedBoolExpression, the fast_path_outcome stamps, the where_dedup_safe license) rather than inventing parallel ones — but the declined-batch fallback re-plans per row where every sibling lane degrades to a per-row scan.
  • Performance (speed first, memory second): ✖ CRITICAL — 4.2× regression where the probe lane declines (measured, fold-only toggle); indexed path 2.1× faster on the fixture; arena bounded by 16 × driving rows at ~150 B/row; no bench covers the fold.
  • Testing: ⚠️ The new integration test, the explain test and the planner tests run and each goes red under a targeted mutation — but the fixture has no decimal value and no constant+correlated filter, the two shapes that are wrong; no CI ran (stacked PR); W3C suite not runnable locally (submodule absent).
  • Conventions: ✔ cargo fmt clean; clippy --all-targets -D warnings clean on fluree-db-query and fluree-db-api (native); thorough multi-line commit body; self-describing title.

Verified locally at branch HEAD (7554ec85f, no CI on this stacked PR): fmt clean; clippy -D warnings --all-targets --features native clean on both touched crates; it_range_semijoin 1/1, it_query_explain 14/14, planner unit tests 109/109 (new ones seen by name); three mutations each turned the intended test red; a 15-shape differential probe against set_fast_paths_disabled(true) found the two divergences above and nothing else.

Just be sure to get the decimal gate and the pushdown interaction in (with fixture coverage) and the fallback down to batch granularity before this merges — and since this is stacked on #1814, it needs a retarget to main once that lands so CI actually runs on it. Happy to talk through any of these.


Notes with no line in this diff to anchor to:

Benches — 🔵 optional. No bench exercises the fold: query_hot_bsbm's "Q5" is the constant price-range shape (FILTER(?price >= 5000 && ?price <= 25000), consumed by the pushdown) and query_overlay_matrix's star is a constant range too, so a walk regression would not show up nightly. A query_hot_bsbm Explore-Q5 shape (correlated ±k bounds under SELECT DISTINCT, Medium/Large scale) is a genuine scope addition, so a follow-up is defensible here — but you're the one who knows the shape, and after merge it tends not to happen.

Comment thread fluree-db-query/src/range_semijoin.rs Outdated
let key_range = match (entry.p_const, entry.o_type_const) {
(Some(_), Some(ot)) => {
let o_type = OType::from_u16(ot);
if !o_type.is_numeric() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/range_semijoin.rs:625 — 🔴 blocking. The POST walk silently drops every xsd:decimal (and out-of-i64 integer) value, so the fold returns fewer rows than the chain it replaces.

Both walk passes gate on OType::is_numeric() — here for a homogeneous leaflet and again at :745 for the row-by-row mixed path — and that predicate is documented as "integer or float, inline only" (o_type.rs:329–333). NUM_BIG_OVERFLOW (0x800B) is outside both ranges, and the indexer routes every typed xsd:decimal to that arena (o_type.rs:93–96, "no stored row carries this o_type"), along with BigInt. So a leaflet or row holding a decimal is continued before it can reach the decode-and-compare path that key_bounds explicitly leaves for it ("None: the kind is not range-keyed (arena decimals), so rows are compared by decoded value instead"). The probe path does handle them — WalkValue::from_binding yields Other(binding) and value_passes re-evaluates the original filter — so the walk and the probe lane disagree with each other and with the generic pipeline.

Claude caught this with a differential probe against set_fast_paths_disabled(true): adding one product ex:DEC with ex:n1 "150.5"^^xsd:decimal (inside the ±120 window) to the PR's fixture, the Q5 shape returns 35 rows generically and 34 under the fold — ["ex:DEC","dec_in"] missing — with every batch stamped proceed. Same loss under a double-valued anchor and under SELECT ?product (COUNT(DISTINCT ?label) AS ?c) … GROUP BY ?product. Financial data is exactly where correlated range windows show up, and it is exactly where values are typed decimal.

Fix: admit the arena in both gates so those leaflets take the mixed path (key_bounds already returns None for NumBigArena, decode_set = mixed already carries OType/PId, and the mixed loop already does decode_value_v3 → bounds.matches → WalkValue::Other(materialized_object_binding(...))):

let walkable = |ot: OType| ot.is_numeric() || ot == OType::NUM_BIG_OVERFLOW;
// :625  if !walkable(o_type) { continue; }
// :745  if !walkable(OType::from_u16(ot)) { continue; }

And please add a decimal-valued product (in-window and out-of-window) to it_range_semijoin.rs's products() — the fixture's only non-integer value is the string "abc", which is why this passed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e400a8c

Both gates now admit NUM_BIG_OVERFLOW. Added in/out-of-window decimal and large-integer coverage. Also widened integer walk keys outward for decimal bounds beyond f64 integer precision, with a regression test.

continue;
}
let mut readers = pending_filters.iter().filter(|f| {
!pushdown_consumed.contains(&f.original_idx) && f.expr.referenced_vars().contains(v)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/execute/where_plan.rs:3362 — 🔴 blocking. A constant FILTER on the folded value variable is dropped whenever the pushdown consumed it, so the fold returns rows the filter should have removed.

extract_bounds_from_filters (pushdown.rs:18–47) turns FILTER(?s1 > 150) into pushdown.object_bounds[?s1] and records the filter in consumed_indices; it does this independently of the correlated filter on the same variable. collect_range_semijoin_folds then counts readers of ?v excluding consumed filters, so the correlated range is the sole reader, the probe folds, and the two places the constant bound could still be applied are both bypassed: the folded triple never reaches build_scan_or_join(…, &pushdown.object_bounds, …) (:1628), and apply_deferred_patterns skips consumed filters by design. The RangeSemiJoinCondition carries only the correlated bounds.

Claude caught this: the PR's Q5 shape plus FILTER (?s1 > 150) returns 9 rows generically and 34 under the fold (25 extra — ex:A at 150, ex:G at 120, ex:K at 110/130, ex:N19, the double anchor, nineteen bulk products), on the walked path and on the probe path (pure-novelty view) alike, because the loss is at plan time.

Fix — the minimal one is to refuse the fold when the pushdown already owns bounds on ?v; the fuller one intersects those bounds into the condition (lower/upper as constant expressions with the pushdown's strictness) so the shape still folds:

// in collect_range_semijoin_folds, alongside the read_elsewhere check
if pushdown_bounds.contains_key(v) {
    continue;
}

(pushdown_consumed is already threaded in; passing &pushdown.object_bounds next to it is a one-line signature change.) And a FILTER (?s1 > 150) variant belongs in the fixture — it is the most natural way a user tightens a Q5-shaped query.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e400a8c

Applied the minimal guard: a pushed-down constant bound keeps its enforcing join. Added >, >=, <, <=, and = variants on indexed and novelty views; reproduced the extra-row failure on the pre-fix commit.

Comment thread fluree-db-query/src/range_semijoin.rs Outdated
/// Exact evaluation of the original probe + filter seeded with one row,
/// for rows no batched path can answer (an unbound or unresolvable
/// subject).
async fn row_passes_exactly(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/range_semijoin.rs:885 — 🔴 CRITICAL (performance). Wherever the batched probe lane declines, the fold re-plans and re-opens an operator tree per driving row, and measures 4.2× slower than the chain it replaced.

probe_batch computes subject_probe_lane_plan and, on Decline (:803, :807), leaves probed = None; every kept row then takes the _ => arm at :870–872 into row_passes_exactly, which calls build_where_operators_seeded (full block collection, reorder, pushdown extraction, operator construction) and open per row. The index path does the same for unbound subjects at :1137. The lane declines for: no binary store at all (an un-indexed or novelty-only ledger — and the planner deliberately folds there, since the ≥256 driving-row gate needs stats), a non-root policy (fast_path_common.rs:2601 declines before anything else), multi-ledger, and eager_materialization (reasoning, federated). None of those are knowable at plan time, so the fold happens and the fallback pays per row. The nested loop degrades to a per-row scan in the same contexts, not a per-row plan.

Claude measured it with the fold alone toggled (all other fast paths on): the PR's fixture Q5 shape on the pure-novelty view, 5 runs each, twice: generic 177 ms / 180 ms, fold 757 ms / 761 ms; the range semijoin exhausted event reads probed=1521 fallback=1772 walked=0 probe_batches=6. Indexed, the same shape is 2.1× faster (19–20 ms vs 42 ms), so the operator is doing its job where the lane serves — the regression is confined to the declined contexts, but "restricting policy" is every non-root Solo user, and the body's "so the lane never costs more than the chain it replaced" does not hold there.

Fix: make the declined fallback batch-level rather than row-level — seed one chain per batch (SeedOperator::from_batch(&batch) over the probe + filter, projecting the subject), drain it once, and mark kept subjects — which is the generic, overlay- and policy-correct pipeline planned once per batch. Independently, refusing the fold when ctx.stats.is_none() would remove the un-indexed case outright (no stats ⇒ no index ⇒ no walk possible anyway), though that changes the "pure novelty" assertion in the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e400a8c

The generic probe/filter is now planned once per fallback batch. A private row ID preserves separate bounds for repeated subjects and handles initially unbound subjects. In contexts where the fast probe lane cannot serve, the existing Distinct removes duplicate driving rows under the fold’s multiplicity-insensitive license.

Local 1,500-product correlated-Q5 fixtures improved from 29.1–32.6 ms to 6.72–7.56 ms on novelty and from 46.3–46.6 ms to 16.3–18.9 ms with non-root policy. Tests cover differing bounds, unbound/poisoned/missing subjects, and subject-denying policy. The PR body includes methodology and the generic-pipeline diagnostic.

let mixed = narrow
.union(ColumnSet::single(ColumnId::OType))
.union(ColumnSet::single(ColumnId::PId));
let cap = walk_row_floor().max(WALK_ROWS_PER_DRIVING_ROW * self.probed_rows);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/range_semijoin.rs:602 — 🟡 question. This is more of a question than a suggestion: does the walk actually fire on Q5 at 10M/100M? The cap is max(4k, 16 × cumulative driving rows), so on the first batch it is 16× one batch, and BSBM's ±120 window over productPropertyNumeric1 (uniform 1..2000) covers ~12% of all products — ~1.2M rows at 10M, ~12M at 100M. Unless the first batch carries ≥75k rows, the first walk caps, walk_off latches, and the whole query runs on probe_batch. If that is what happened in the 100M runs, the measured gain is the probe lane's (which is still real: deduped subject probes, no row materialization, no re-dedup) and the ~600 lines of walk are exercised only at the fixture's scale. The range semijoin index built by POST walk debug event would settle it; if the walk doesn't fire at scale we may want the cap to lean on the planner's driving estimate (known at fold time) rather than rows seen so far.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added actual runtime routing checks to the correlated benchmark in e400a8c. At 10k generated products both conditions walk (four condition-batches); at 100k one walks and one probes (13 each), before and after these fixes.

Your cap concern is valid. I have not rerun the production 10M/100M datasets and cannot attribute those historical gains to POST walks. I left the cap conservative, and updated the PR body to distinguish the historical results from the new local routing and timing evidence. Using the planner estimate to authorize larger walks should be a separate measured change.

.as_ref()
.is_some_and(|index| index.envelope.covers(&batch_envelope));
if !covered && !self.walk_off[c] {
let target = match &self.indexes[c] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/range_semijoin.rs:1073 — 🟡 question. I don't think this bites Q5 (one anchor ⇒ one envelope ⇒ one walk), but a batch whose envelope isn't covered rebuilds the index from scratch over index.envelope ∪ batch_envelope, with a cap that grows as 16 × cumulative rows. A driving stream whose bounds drift monotonically (a variable anchor whose value arrives in scan order) would re-walk the cumulative envelope every batch — roughly Σ k·w over batches, quadratic in batch count — and charge fuel for each rebuild. I tried to provoke it with a 1,200-product self-join and got a single walk because the hash join scrambled the order, so this is unverified. Walking only the delta (the part of the new envelope the old one doesn't cover) or latching walk_off after a handful of rebuilds would be cheap insurance if you agree it's a real shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e400a8c

Each condition now allows at most four walk attempts, then latches over to probes. A deterministic ten-batch expanding-envelope test verifies four successful walks and exact results from the later probe batches.

} else {
let subject_var = folds[0].subject_var;
let conditions = folds.into_iter().map(|f| f.condition).collect();
Box::new(RangeSemiJoinOperator::new(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/execute/where_plan.rs:1643 — ⚪ nit / question. Folded triples never reach hash_planner.before_step, and RangeSemiJoinOperator::estimated_rows returns the child's estimate, so the label probe behind the fold is costed against the pre-semi-join stream. Not wrong, and I don't think it changes Q5's plan, but the fold exists to exploit a selectivity the planner then can't see; folding step_est × (range width / value range) in would be cheap if stats have what's needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would defer this one. Keeping the child estimate is conservative and does not affect correctness; deriving useful correlated-range selectivity needs more than assuming a uniform value distribution. These fixes leave that estimate unchanged, and the PR body calls out the limitation.

}

#[tokio::test]
async fn correlated_range_probes_fold_into_a_semijoin_with_exact_rows() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-api/tests/it_range_semijoin.rs:396 — ⚪ nit. The test sets FLUREE_RANGE_SEMIJOIN_WALK_FLOOR at the top and notes it is the only test in the binary, because walk_row_floor() is a process-wide OnceLock. That is fine today; a one-line comment at the [[test]] entry in Cargo.toml saying "keep this binary single-test" would stop the next contributor from adding a second test and getting a flaky floor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e400a8c

let mut columns: Vec<Vec<Binding>> = (0..num_cols).map(|_| Vec::new()).collect();
// Reused across rows: a duplicate row costs a hash and a probe, no
// allocation; only rows that survive are copied into an owned key.
let mut scratch: Vec<Cow<'_, Binding>> = Vec::with_capacity(num_cols);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/distinct.rs:180 — praise. The Cow scratch key is exactly right: cleared per row, hashed as the stored Vec<Binding> hashes (Cow<B> hashes as B; both Vecs length-prefix), compared element-wise, and only materialized for a vacant entry. A duplicate costs a hash and a probe and nothing else. Verified by reading against hashbrown's rehash path.

row: &R,
ctx: Option<&ExecutionContext<'_>>,
) -> Result<bool> {
if let (Some(op), Expression::Call { args, .. }) = (self.iri_eq_op, &self.expr) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/eval/helpers.rs:196 — praise. Trying fast_eq_ne_for_iri_bindings before the LRU is a pure reorder: it is the same function the uncached path already calls first (compare.rs:384), and the LRU only ever cached that path's answer, so =/!= against unbound, literal-vs-IRI and blank nodes are unchanged.

/// `?v` (so the semi-join's keep/drop equals probe+filter+early-dedup under
/// `where_dedup_safe`). Every fold shares one subject variable.
#[allow(clippy::too_many_arguments)]
fn collect_range_semijoin_folds(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fluree-db-query/src/execute/where_plan.rs:3298 — praise. The liveness analysis behind the fold is careful and I could not break the license: COUNT(*) and bag projections refuse it; a projected, ordered, OPTIONAL-read, twice-filtered, re-joined or COUNT(DISTINCT ?s1)-read value keeps that probe a real join while the still-dead sibling folds; UNION and MINUS bodies come out identical to the generic pipeline. Double-vs-integer bounds at strict boundaries, an infinite upper bound and one-sided correlated ranges all matched too.

Base automatically changed from fix/incremental-stats-flat-ndv to main September 10, 2026 13:52
@bplatz
bplatz merged commit 2a211cf into main Sep 10, 2026
16 checks passed
@bplatz
bplatz deleted the perf/bsbm-q5-range-semijoin branch September 10, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:query Query execution, planning, fast paths, overlay, result formatting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants