Skip to content

perf(query): accelerate joins, existence checks, and counts - #1959

Merged
bplatz merged 13 commits into
mainfrom
perf/chain-limit-startup
Sep 29, 2026
Merged

bplatz merged 13 commits into
mainfrom
perf/chain-limit-startup

Conversation

@bplatz

@bplatz bplatz commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up: #1983

Small-limit queries can buffer large joins before returning their first result, partially bound existence checks repeat correlated work, and count aggregates construct joined rows that they immediately discard. This change reduces those costs and MINUS build overhead while retaining existing equality, policy, and visibility paths.

  • Give simple unordered join blocks an advisory startup goal, including through DISTINCT and FILTER, with growing probe windows for full drains. When statistics estimate that a DISTINCT projection cannot reach LIMIT + OFFSET, retain throughput planning; unknown estimates keep the startup hint.
  • Start eligible star joins with a subject-probe window sized from LIMIT + OFFSET. Once a window has matched rows, size the next from the observed yield: twice the subjects the remaining rows need at that rate, capped at eightfold growth, which still applies until something matches. Unbudgeted joins keep their existing schedule. The budget controls consumed and probed subjects; the source scan can still fetch a larger batch.
  • Build MINUS in a fresh scope without an unnecessary seed join, allowing ordinary scan and star planning. Store single shared encoded-reference keys in a compact subject-ID set, retain normalized equality for other terms and wildcard compatibility for composite keys, and account for retained keys in memory checks.
  • Reuse normalized partial-key projections for triple-only EXISTS / NOT EXISTS bodies when OPTIONAL leaves outer keys unbound. Cache at most four projection shapes, account for both base and projected key sets, and retain per-row evaluation for unsupported cases.
  • Remove only an immediately preceding DISTINCT when every streaming aggregate is COUNT(DISTINCT). Preserve intermediate deduplication, LIMIT boundaries, mixed aggregates, and grouped-list output.
  • Count matches directly in batched subject joins. Ungrouped COUNT() avoids output batches; grouped COUNT() counts matches per driving row and folds them into group totals when all keys are already on the driving side. Joined-row BIND, right-side group keys, and mixed aggregates retain ordinary row consumption. Both drains reuse the existing scan, filter, overlay, history, and runtime fallback paths.
  • Add a repeatable operator probe with plan output and independent result checks, and update the performance architecture and query troubleshooting documentation.

Representative native measurements use an indexed, deterministic 50k-person fixture (about 396k facts), one warmup, and 20 measured executions, excluding response serialization. Each row compares the change with its preceding implementation:

Query shape Before After
Two-hop DISTINCT LIMIT 1 20.5 ms 1.64 ms
NOT EXISTS after OPTIONAL 643 ms 82 ms
Filtered two-hop COUNT(*) about 82 ms about 52 ms
Two-hop grouped COUNT(*) 66–70 ms about 37 ms
MINUS count 6.58–6.78 ms 3.24–3.25 ms
Star LIMIT 10 0.799–0.812 ms 0.509–0.511 ms

The MINUS and star measurements are repeated alternating runs against 7def618. MINUS results agree with raw-row counts and an independent NOT EXISTS control; dense and sparse star results match full-drain prefixes. The full-row filter control remains about 13.2–13.3 ms. These changes have not yet been rerun in the container benchmark, so their effect on its remaining gaps is unverified.

The DISTINCT full-drain guard gives up the startup hint when statistics say LIMIT + OFFSET cannot be reached. Measured against the hint (an earlier commit in this PR), the longer chain improves from 128–131 ms to 104–107 ms and the shorter one goes from 19.4 ms back to 24.8 ms. Against main neither changes: both run the same plan as main, and the table below shows ratios of 1.00–1.02, so the 24.8 ms is a forgone win rather than a regression. The small DISTINCT LIMIT 1 case retains its startup improvement. The grouped-count change also improves filtered and 50,000-group cases; repeated alternating COUNT(DISTINCT) controls remain about 98–99 ms before and after.

Against main

Timed on a c7i.8xlarge with the same fixture: 20 measured executions per query, two rounds alternating main (ea287de, the commit merged into this branch) and this PR (ce18257) on one machine. Absolute times differ from the table above, which ran on a different machine.

Query shape main This PR
Two-hop LIMIT 1 38.7–39.9 ms 3.35–3.36 ms
Two-hop DISTINCT LIMIT 1 40.7–41.2 ms 3.69–3.71 ms
NOT EXISTS after OPTIONAL 789–790 ms 154–156 ms
MINUS count 17.5 ms 7.1 ms
Filtered two-hop COUNT(*) 140–144 ms 72–73 ms
Two-hop grouped COUNT(*) 138–139 ms 75–77 ms
Star LIMIT 10 1.76–1.78 ms 1.16–1.17 ms
Star, 1 match in 1,000, LIMIT 10 8.06–8.14 ms 6.54–6.57 ms
Star, 1 match in 100, LIMIT 100 43.4–44.4 ms 12.5 ms
Star, 1 match in 10,000, LIMIT 3 8.29–8.37 ms 9.42–9.47 ms
DISTINCT drain, short / long chain 60 / 240 ms 60–62 / 234–245 ms

The other probe queries are within 4% of main. Before the yield-based sizing, the 1-in-1,000 star took 40–45 ms: its windows grew 10, 80, 640, 5,120, 40,960 and read 46,810 subjects, where main's 1,024 start read 9,216. It now reads 7,150. The 1-in-10,000 star is the one shape slower than main (10,951 subjects against 9,216): a rate estimated from its first match or two is rough.

Benches

Two query_hot benches guard these lanes. Both have regression budgets and are in the nightly compare subset:

  • query_hot_negation_count: MINUS count, NOT EXISTS after OPTIONAL, and filtered and grouped COUNT(*) over a join.
  • query_hot_limit_startup:
    • chain LIMIT 1, with and without DISTINCT;
    • the DISTINCT drain the guard keeps on the throughput plan;
    • a dense and a selective star.

The selective star catches the window-size overshoot: at small scale it takes 4.40–4.54 ms without the yield-based sizing and 2.41–2.43 ms with it.

The compare gates them only once a baseline that includes them is captured (capture_baseline) and committed. Until then it lists them as new scenarios.

Validation: 2,152 tests passed (1,632 query unit tests, 404 query integration tests, and 116 policy tests; four existing tests ignored). Coverage includes duplicate and unbound inputs, composite keys, empty results, mixed aggregates, DISTINCT/LIMIT boundaries, runtime fallback, indexed and novelty-only data, overlays, historical reads, restricted policy views, cancellation, and memory budgets. Group totals are checked against independent folds over raw rows; indexed count-drain assertions verify that the final join emits no intermediate output batches. Star assertions verify consumed subject windows of 1, 10, and 15 for LIMIT 1, LIMIT 10, and OFFSET 5 LIMIT 10, and that a 1-in-100 star with LIMIT 30 stops before reading half of its 12,000 subjects (it read all of them before the yield-based sizing). With that sizing, 1,634 query unit tests and the LIMIT integration tests pass, and clippy is clean. MINUS tests cover mixed key representations, deduplication, wildcard compatibility, policy visibility, and memory-budget enforcement.

Two pre-existing MINUS count errors surfaced during additional edge-case checks and were reproduced against the committed implementation at 7def618: a VALUES/UNDEF input followed by a type pattern returns 1,202 instead of 802 in a 1,200-person fixture, and a point-in-time query after updates and reindexing returns 801 instead of 800. Both are pre-existing, and both are fixed in #1965, which is stacked on this PR.

cargo test -p fluree-db-query --lib --offline
cargo test -p fluree-db-api --test grp_query_sparql --test grp_policy --offline -- --test-threads=1
cargo clippy -p fluree-db-query -p fluree-db-api --lib --test grp_query_sparql --test grp_policy --example query_operator_probe --offline -- -D warnings
cargo fmt --all --check

All checks above and the diff whitespace check passed. Integration and policy suites ran serially after a parallel run failed an existing tracing-capture assertion.

Use a planning row goal to avoid large eager hash builds and size initial join probes through DISTINCT and FILTER. Preserve existing plans for blocking modifiers and explicit hash-join overrides.

Add work-count and correctness regressions, a repeatable operator probe, and measured results. The indexed in-memory two-hop DISTINCT LIMIT 1 case improves from 20.491 ms to 1.643 ms over 20 measured runs.

Validation: 1,614 query unit tests and 400 query integration tests passed; formatting and diff checks passed.
Rewrite docs/design/performance.md as a description of how the engine is
designed for performance rather than a record of individual changes:

- Add a design-principles section covering fallback, soundness gates,
  equivalence testing, observability, and resource accounting.
- Reorganize join operators and group row goals, count-only consumption,
  and duplicate control under one section.
- Remove dated before/after timings, baseline commits, and admission
  thresholds that belong in pull requests and module documentation.
- Document late materialization and leaflet/disk caching.
- Correct stale limits: unanchored property-path closures are computed
  rather than rejected, and eager decoding applies only under unindexed
  novelty.
- Adopt a factual tone and reduce em-dash use.

Move the optimizer kill-switch table to debugging-queries.md and drop the
stale fast-path operator count from the README.
@bplatz bplatz added area:query Query execution, planning, fast paths, overlay, result formatting performance Performance improvement (release notes: Performance) labels Sep 26, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 26, 2026 14:45
@bplatz bplatz changed the title perf(query): accelerate chain joins, existence checks, and counts perf(query): accelerate joins, existence checks, and counts Sep 26, 2026

@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 is great @bplatz and I think the design and execution holds up. My only note/question is whether we want to fold the newly improved shape-based performance metrics into the benching we do, as I don't think this PR includes shape-specific additions for those for, for example, the nightly bench etc.


I appreciate the PR body's honesty here as it made for a clear path on the review — you disclose the DISTINCT-guard tradeoff instead of burying it, you name two pre-existing MINUS bugs you found and deliberately left alone, and you say outright that the container benchmark hasn't been rerun. That's the right posture for a perf PR this size. I spent most of my time trying to find a way for the new row_goal to leak into a MINUS or semijoin inner build, where a truncated drain would mean silently wrong answers, and I couldn't: the allow-list at execute/operator_tree.rs:2285 excludes every compound pattern, with_row_goal is unconditional at each root so a subquery can't inherit, FILTER NOT EXISTS lowers to Pattern::NotExists in both front ends, and set_row_budget really does only pace flushes — FlushSchedule::advance() always grows back to cap. The semijoin projection is sound for the reason your gate says it is, and I verified that independently through choose_exists_strategy rather than taking the comment's word for it.

My notes are all about measurement rather than mechanism. The one I'd most like answered before this merges is the 19.4 ms → 24.8 ms sentence — read in parallel with the line before it, it says a shape got 28% slower, and I don't think that's what you mean. I'm fairly confident it's a forgone win rather than a regression against main (when the guard fires row_goal is None and every new path goes inert), but the one thing stopping me from asserting it is property_join.rs:1407, which swaps budgeted for ramped_from and so changes the first probe window for pre-existing budgeted property joins — including sparse-LIMIT stars that now pay ~3 extra flushes where MIN_FLUSH used to amortize them. A sparse-star wall time next to the dense one would settle both questions at once. Beyond that: semijoin.rs:145 builds a full O(|key_set|) projection on the first partially-bound row, which is a new cliff for "huge inner set, one unbound row," and none of the six shapes in your table has a bench — there's no MINUS, NOT EXISTS, or grouped-COUNT(*)-over-join bench in the repo, query_operator_probe is an example rather than a [[bench]], and ci.yml has no bench job since db#1802 moved comparison to nightly.

One calibration note for whoever reads the table later: the "OPTIONAL / NOT EXISTS 643 ms → 82 ms" row is the NOT EXISTS side. This PR touches no optional.rs at all, which #1973 (filed this morning) independently confirms in its related-work list — so the reverse-OPTIONAL shape that OOM-killed a 48 GB machine at 43 GB RSS is still open and is arguably the bigger fish in this neighborhood.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ row_goal lands in the existing PlanningContext decide-once struct, HashJoinPlanner gains a cost input rather than a second planner, and the count drains extend the existing Operator::drain_count contract. No parallel mechanism invented.
  • Performance (speed first, memory second): ⚠️ Materially faster on six measured shapes with plausible mechanisms, but three unmeasured shapes could go the other way — sparse-LIMIT property joins (property_join.rs:1407), the SmallRowGoal hash-join decline whose estimate fails open on None, and the semijoin projection cliff — and no bench guards any of it. No proven regression.
  • Testing: ✔ All five touched it_*.rs files are declared by a grp_*.rs and genuinely run; unit coverage for the budget/cancellation/overflow edges is thorough (the checked_join_count(u64::MAX, 1) assertion is a nice touch). ⚠️ The gap is benches, not tests.
  • Conventions: ✔ Self-describing subjects, clippy-clean under --all-targets, docs/design/performance.md and docs/troubleshooting/debugging-queries.md both updated with the behavior change.

Verified locally at branch HEAD cf859718a: cargo test -p fluree-db-query --lib --offline → 1632 passed / 0 failed / 1 ignored; cargo test -p fluree-db-api --test grp_query_sparql --offline -- --test-threads=1 → 404 passed / 0 failed / 3 ignored (matching your reported 404 exactly); cargo clippy -p fluree-db-query --all-targets --offline -- -D warnings → clean. CI on this head is fully green including testsuite-sparql — worth noting because #1965 and #1967 above it have no test/clippy/fmt runs at all.

Approving so you can merge when ready — but I'd really like the 19.4 → 24.8 baseline stated plainly in the body first, since under our own speed-first rule that sentence currently reads as a blocking regression and I don't think it is one.

&& !replay;
self.chunk_schedule = streamable.then(|| match self.row_budget {
Some(budget) => FlushSchedule::budgeted(budget, BATCHED_JOIN_SIZE),
// These probes visit only the chunk's subjects, so a small

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/property_join.rs:1407 — optional, but the one I'd most like a number on. This swaps FlushSchedule::budgeted(budget, BATCHED_JOIN_SIZE) for ramped_from(budget, …), and the two differ in exactly the place that matters: budgeted is budget.clamp(MIN_FLUSH.min(cap), cap), so the first window was never below 1024, while ramped_from is initial.max(1).min(cap), so it is now the raw budget.

What makes this different from the rest of the PR is that PropertyJoinOperator::row_budget is fed by the pre-existing set_row_budget LIMIT pushdown, not by the new planning.row_goal — so unlike every other change here, this one fires on queries that have no row goal at all, and it changes behavior for budgeted property joins that exist on main today.

For a LIMIT 10 star whose matches come early, that is the "0.799 → 0.509 ms" win in the table and it's a good one. For a LIMIT 10 star that has to drain because matches are sparse, the window sequence becomes 10, 80, 640, 5120, 40960, cap where it used to be 1024, 8192, 65536, cap — roughly three extra flushes, each paying the per-flush setup that MIN_FLUSH was introduced to amortize (your own rewritten doc at operator/flush.rs:11-13 is what names that tension). The body says "dense and sparse star results match full-drain prefixes," which I read as a correctness check; I don't see a sparse timing anywhere.

Could we get the sparse-star wall time alongside the dense one? If it's flat, this is just a clean win and I'd say so in the body. If it isn't, a floor — ramped_from(budget.max(SOME_MIN), cap) — would keep the LIMIT 10 win without giving up the amortization on a drain.

  • PR body, the DISTINCT-guard tradeoff paragraph — optional, needs one sentence rather than a code change. "The shorter chain changes from 19.4 ms to 24.8 ms when it returns to the ordinary throughput plan" is genuinely ambiguous about its baseline, and given how this repo treats speed I think it's worth disambiguating explicitly in the body.

Read as parallel to the sentence before it ("the longer chain improves from 128–131 ms to 104–107 ms"), it says the short chain is ~28% slower than before, which would be a blocking regression. Read as "with the startup hint it was 19.4; the guard sends it back to the throughput plan at 24.8," it's a forgone win and neutral against main.

I'm fairly confident it's the second, because when the guard fires row_goal is None and with None every new path is inert — hash_join.rs:474 requires row_goal.is_some_and(…), and the if let Some(goal) at execute/where_plan.rs:3355 isn't taken — so the plan should be byte-identical to main. The one thing that keeps me from asserting it is the property_join.rs change above, which fires independently of row_goal. One sentence ("24.8 ms matches main; the 19.4 ms was the hint we're deliberately giving up here") would close it.

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.

Measured both on a c7i.8xlarge (50k fixture, 20 runs per query, alternating main and the branch on one machine).

Sparse star: you were right, it was a regression. star_sparse_limit (1 match in 1,000, LIMIT 10) went from 8.1 ms on main to 40–45 ms. The per-flush setup wasn't the cost; the overshoot was. Windows of 10, 80, 640, 5,120 and then 40,960 read 46,810 subjects, where main's 1,024 start read 9,216. A floor didn't fix it: 64 gave 32 ms and 256 gave 16 ms, and both gave back part of the dense win.

Fixed in ce18257 instead. Once a window has matched rows, the next is sized from the observed yield: twice the subjects the remaining rows need, capped at the ×8 step. That star now reads 7,150 subjects in 6.6 ms, and the dense LIMIT 10 star stays at 1.17 ms (main: 1.77 ms). One shape is still slightly slower than main: 1 match in 10,000 with LIMIT 3, 9.4 ms against 8.3 ms, because a rate estimated from the first match or two is rough. A new test in it_limit_stops_work checks that a 1-in-100 star with LIMIT 30 stops before reading half of its 12,000 subjects; without the fix it read all of them.

19.4 → 24.8 ms: confirmed a forgone win. The plan is identical to main's and the timing ratio is 0.97–1.02 across runs; the longer chain is neutral against main too. The PR body now says so, and has a table measured against main.

if !self.partial_key_sets.contains_key(positions.as_slice()) {
if self.partial_key_sets.len() >= MAX_PARTIAL_KEY_SETS {
return Ok(None);
}

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/semijoin.rs:145 — optional. This is more of a question than a suggestion, and it's about the miss case rather than the hit case. The first outer row that arrives with an unbound key var builds the whole projection: for (i, key) in self.key_set.iter().enumerate() with a CompositeGroupKey clone per entry, so O(|key_set|) work and allocation before that one row gets its answer. The path it replaces was a single seeded any_solution, which for an indexed inner is a cheap lookup.

So the shape that loses is "very large inner key set, very few partially-bound outer rows" — e.g. a NOT EXISTS over a high-cardinality predicate sitting above an OPTIONAL that almost always matches, where maybe one row in a batch has the key unbound. We pay a second full pass over a set we already materialized in open(), to serve one row. I don't think this is common, and the memory is properly charged (record_alloc + checkpoint every 1024, which is nicely done), but it is a new cliff that didn't exist before.

Would it be worth deferring the build until some small number of rows have actually needed the same mask — a counter per positions key, build on the second or fourth hit — so a one-off unbound row keeps the old cheap path? It's minor and non-blocking, but if you agree it's right I'd rather see it folded in here than lost in the backlog.

  • fluree-db-api/examples/query_operator_probe.rs (commenting here because the real subject is the absence of a bench, which has no line in this diff) — optional. The probe harness is a genuinely good artifact and I'm glad it exists. My worry is that it's the only thing standing behind six performance claims, and nothing will run it again.

Concretely: it's an examples/ binary, so it isn't a [[bench]] in fluree-db-api/Cargo.toml, it has no entry in regression-budget.json, and it isn't referenced by ci.yml or bench.yml. Meanwhile there's no bench anywhere for the shapes this PR accelerates — I grepped both bench directories for MINUS, NOT EXISTS/NotExists, CountAll and COUNT(*); the only COUNT(*) hit is query_hot_whole_graph_agg.rs, which is the whole-graph aggregate lane rather than the join-count lane you added, and query_hot_optional.rs's four lanes are all OPTIONAL, none a negation. And since db#1802 moved comparison to nightly, ci.yml on this branch has no bench job at all.

Net: every number in the table is true today and unguarded tomorrow, and so is the disclosed tradeoff. Even one bench — a query_hot_negation_count.rs with a MINUS lane, a NOT EXISTS lane and a grouped-COUNT(*)-over-join lane, plus its regression-budget.json entry — would turn six one-time measurements into something nightly can defend. I recognize that's the largest ask here; I'd still rather see it in this PR than filed, because after merge the shapes are fast and nobody has a reason to come back.

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.

Projection build: leaving it as is. The cost is real, but bounded: open() has already paid one pass over the key set to build it, and each projection is one more pass, at most once per mask and for at most MAX_PARTIAL_KEY_SETS (4) masks. So the worst case, a huge key set with a single partially-bound row, pays one extra pass, charged to the memory budget as you noted. Deferring until a mask repeats would keep the per-row path alive for the first rows of every mask, which is the path the 5x NOT EXISTS win replaces, to save at most that one pass.

Bench: added in 8649fb2.

  • query_hot_negation_count covers MINUS, NOT EXISTS after OPTIONAL, and filtered and grouped COUNT(*) over a join.
  • query_hot_limit_startup covers the chain LIMIT 1 cases, the DISTINCT drain the guard protects, and a dense and a selective star.
  • The selective star would have caught the property_join.rs overshoot. At small scale, built with and without that fix, it runs 4.40–4.54 ms before and 2.41–2.43 ms after.

Both benches have budgets and pass the reconcile check and the tiny --test smoke run.

Registering a bench only gets it smoke-run, though. The nightly compare runs a named subset, so e7d54ec adds both to that subset and to the CI-class capture. They gate once a baseline that includes them is captured (capture_baseline) and committed. Until then, compare lists them as new scenarios; the rest of the subset is already advisory because of the host-class mismatch.

&& !select_needs_grouped_vars;

if use_streaming {
// COUNT(DISTINCT) already deduplicates its input within each group.

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/operator_tree.rs:3598 — nit. The DISTINCT elision is the one commit with no row in the perf table, and it adds a trait method (Operator::take_distinct_input) whose contract lives entirely in a doc comment: "The caller must prove that its consumer cannot observe input duplicates." I convinced myself it's sound — COUNT(DISTINCT ?x) dedups ?x regardless of input multiplicity, and the use_streaming && !select_needs_grouped_vars gate above is what protects grouped-list output, which distinct_count_with_grouped_list_keeps_terminal_distinct pins nicely. But with no measured win attached, I'm curious what it bought. If the answer is "not much yet, it's groundwork," a line in the body saying so would help the next reader decide whether the trait surface is earning its keep.

  • fluree-db-query/src/property_path.rs:18-35 — nit, and not something this PR introduced. The doc rewrite is honest and I'd rather have it than the old text. But it's worth registering what it retracts: the previous comment claimed the both-unbound case was "intentionally an error to prevent accidental full-closure enumeration which can be extremely expensive," and the new text says it "materializes the whole closure and is not optimized." So a stated safety property turns out never to have been enforced. Given OPTIONAL with a left-bound variable in object position: quadratic CPU and ~170 KB of memory per required row (OOM) #1973 landed this morning with a 43 GB OOM on an adjacent unoptimized shape, I think that's worth more than a doc edit — even just a tracing::warn! or a record_alloc on the closure path so it shows up in a budget rather than in the kernel's OOM killer. Genuinely separable from this PR; flagging it so it doesn't disappear with the old comment.

/// Estimate the size of a DISTINCT projection from mandatory triple domains.
/// Their NDVs are approximate: use this only for costing, never to cap results.
/// Multiplying the per-variable domains avoids assuming independence/selectivity
/// will reduce the projection. Missing domains and computed bindings stay unknown.

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/planner.rs:139 — estimate_projected_distinct_rows multiplies per-variable NDVs, which is an upper bound on the projection, and operator_tree.rs:2308 uses it for "even the upper bound can't reach LIMIT + OFFSET, so drop the hint." Using an upper bound for a can't-possibly-reach test is the correct direction — the failure mode is keeping a hint you should have dropped, never dropping one you needed. The doc comment saying so ("use this only for costing, never to cap results") is exactly right, and projected_distinct_estimate_keeps_unknown_and_computed_domains_unknown pins all five None cases.


/// Add one batch of MINUS rows to the hash set and wildcard list.
fn index_minus_batch(&mut self, batch: &Batch) {
if let [var] = self.shared_vars.as_slice() {

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/minus.rs:157 — I went looking for a semantic change in the single-shared-variable fast path, specifically the early return that skips unbound right rows. It matches the pre-existing rule: input_row_eliminated_all_unbound_no_match already asserted that a right row with no bound shared domain eliminates nothing, so this is a faster encoding of the same semantics rather than a behavior change. single_key_index_matches_compatibility_for_ids_and_other_terms cross-checking the compact index against rows_match for every binding kind — including EncodedSid with a t/op, a Long(7) literal, and an EncodedPid with the same numeric value — is the right instrument for that, and it's what let me stop worrying about the s_id-only key.

planning: PlanningContext,
) -> Self {
let schema: Arc<[VarId]> = Arc::from(child.schema().to_vec().into_boxed_slice());
let partial_keys_safe = !inner_patterns.is_empty()

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/semijoin.rs:87 — the partial_keys_safe gate is the part that makes the projection sound, and it's drawn in the right place. Tracing key_vars back through choose_exists_strategy, key_vars is exactly outer_schema ∩ inner_produced_vars and outer_only_consumed already rejects any inner referencing an outer var it doesn't produce — so for a conjunction of triples every key var is bound in every inner solution, an unbound outer key really is free, and projection is equivalent to substitution. Routing Poisoned to Ok(None) rather than treating it as free is the detail that would have been easy to get wrong.

Starting a LIMIT star's first chunk at the row budget instead of 1,024
subjects left later chunks growing eightfold. On a selective star the last
chunk overshot what the LIMIT still needed: a LIMIT 10 star matching one
subject in 1,000 read 46,810 subjects where the 1,024 start read 9,216,
and ran 5x slower.

Once a chunk has matched rows, the next covers the rows still wanted at
the observed rate twice over, capped at the geometric step, which still
applies until something matches. The same star now reads 7,150 subjects.
The chunk is sized when it is read rather than after the previous probe,
because only then has the previous chunk's yield been emitted; unbudgeted
joins keep the same sequence of sizes.
The shapes #1959 speeds up had no bench: nothing covered MINUS, NOT
EXISTS, COUNT(*) over a join, or LIMIT stopping a chain or star early.
Two query_hot benches add them, one scenario per lane, with the selective
star sized so a fixed eightfold probe step would overshoot what LIMIT
still needs. Budgets copy the sibling query_hot numbers until a nightly
baseline exists.
Main now rejects path-style ledger ids ("test/main") at storage seams in
debug builds (#1958). This branch's new query unit tests spelled their
snapshots that way; they now use "test:main", as #1958 did for the older
tests. The bench-list row in benches.md keeps main's longer
construct_formats entry and adds the two new benches.
Registering a bench only smoke-runs it at tiny scale; the nightly compare
and the CI-class capture run a named subset. Add query_hot_negation_count
and query_hot_limit_startup to both. Until a baseline including them is
captured, compare lists their scenarios as new rather than failing.
@bplatz
bplatz merged commit 1c81d32 into main Sep 29, 2026
16 checks passed
@bplatz
bplatz deleted the perf/chain-limit-startup branch September 29, 2026 11:04
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 performance Performance improvement (release notes: Performance)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants