Skip to content

perf: query read scaling, object→subject hash join, planner fixes, and EXPLAIN physical-plan tooling#1293

Merged
bplatz merged 17 commits into
mainfrom
perf/snapshot-arc-scaling
Jun 5, 2026
Merged

perf: query read scaling, object→subject hash join, planner fixes, and EXPLAIN physical-plan tooling#1293
bplatz merged 17 commits into
mainfrom
perf/snapshot-arc-scaling

Conversation

@bplatz

@bplatz bplatz commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This PR improves query scaling and planner diagnostics for large read/query workloads.

  • Replaces per-query deep snapshot clones with Arc<LedgerSnapshot> and switches cached ledger state to RwLock, allowing concurrent read snapshots to proceed in parallel while preserving exclusive write access.
  • Adds a cost-based object-to-subject HashJoinOperator for large path joins, including planner tie-breaks that choose the driving side most likely to unlock a large hashable predicate scan.
  • Extends /fluree/explain with compound-aware logical plans and planned physical operator trees, including fast-path labels, scan details, hash-join decisions, and expanded subquery bodies.
  • Fixes planner estimates for anchored property paths so bounded closures drive joins instead of being treated like full-world scans.

Closes: #1287

Performance Notes

  • Concurrent read benchmark improved from roughly flat scaling after 2 cores to about 8.05x at 16 cores, with peak QPS reported around 107k.
  • BSBM-BI object-to-subject joins avoid repeated scattered OPST seeks by scanning the large predicate partition once and probing a hash table.
  • Bowtie join ordering now prefers the side that unlocks the larger hash join, with the commit notes reporting a 110s -> 2.38s improvement for the BI F2 shape.
  • Anchored property paths such as <concept/912> skos:broader+ ?b are estimated as bounded closures rather than million-row scans.

Explain Changes

plan.logical now preserves compound query structure while reflecting the same planner order used by execution.

plan.physical renders the planned operator tree without executing the query, including:

  • HashJoinOperator vs NestedLoopJoinOperator selection
  • hash-join cost inputs and rejection reasons
  • fast-path operator labels and fallback edges
  • dataset scan details such as predicate and planned index hints
  • nested subquery bodies under SubqueryBody

bplatz added 15 commits June 4, 2026 16:05
Concurrent read throughput against a single ledger barely scaled with
cores (BSBM-Explore-class workloads): an in-process bench showed ~1.96x at
2 cores, then a collapse to ~1.3x that stayed flat through 16 cores. Every
read query took an EXCLUSIVE tokio::sync::Mutex in LedgerHandle::snapshot()
and, while holding it, deep-cloned the whole LedgerSnapshot (namespace
maps, stats, schema, graph registry, watermarks). Reads on one ledger
therefore serialized on that mutex, and each paid a deep clone.

Two changes, both required:
- LedgerState.snapshot / LedgerView.snapshot become Arc<LedgerSnapshot>,
  propagated through GraphDb so deriving a query view is a refcount bump
  rather than a deep copy (eliminates two per-query deep clones). Snapshot
  mutations (commit / load / reindex / reload / staging / rebase / push)
  use Arc::make_mut (copy-on-write); writers are rare and already
  serialized, so the CoW clone is off the read hot path.
- LedgerHandle's state and binary_store guards become RwLock, so
  concurrent reads take a shared read() (cloning the now-cheap Arc
  snapshot) instead of serializing on an exclusive Mutex; transactions
  take write().

The Arc change is the necessary complement to the RwLock: under a shared
read lock, N readers clone the snapshot concurrently, and Arc makes that a
refcount bump instead of N contending deep copies.

In-process bench (single ledger, trivial lookup queries): flat ~1.3x /
~16k QPS peak before -> 3.75x @4c, 6.54x @8c, 8.05x @16c, ~107k QPS peak;
+12% single-core. Adds it_scaling_bench.rs (#[ignore]) as the harness.
Adds HashJoinOperator for the BSBM-BI "small + large" object->subject path
join (e.g. `?review rev:reviewer ?reviewer . ?reviewer bsbm:country <US>`).
The planner correctly drives from the selective bound-object side, but the
NestedLoop then resolves the large predicate via per-driving-object OPST seeks
into the scattered object-major index, which degrades superlinearly (~47s at
100M). The hash join builds a table from the small driving side and scans the
large predicate's contiguous partition once + probes, turning N scattered seeks
into one sequential scan.

Selection is cost-based in build_scan_or_join: the shape must match AND the
probe predicate must be large enough (probe_count >= 250k) but not too large
relative to the driving set (probe_count <= driving_est * 64). driving_est is a
running per-triple cardinality estimate threaded through build_triple_operators
and build_sequential_join_block. FLUREE_HASH_JOIN is a force-override only
(On/Off/unset=Auto).

Correctness: unbound/poisoned build-side join vars are kept as wildcard rows
that match every probe row (the join var takes the probe value), matching the
nested-loop "take the right side" semantics rather than dropping rows. On a true
multi-graph dataset the operator falls back to a NestedLoopJoinOperator at open()
rather than erroring, since the ledger-local key normalisation is single-graph.

Verified at 10M: auto-selects (~1.45x faster), correct results; VALUES UNDEF
gives byte-identical results vs the nested loop; 205 query tests pass with the
hash join forced on. Follow-up: fold the shared planning state into a
HashJoinPlanner.
Fold the hash-join selection state — StatsView, the FLUREE_HASH_JOIN force
mode, and the running driving-chain cardinality — into a single HashJoinPlanner
threaded through a WHERE join block. build_triple_operators and
build_sequential_join_block now share one planner via before_step() instead of
each maintaining its own driving_est running-product loop, and build_scan_or_join
takes &HashJoinPlanner in place of the (stats, driving_est) parameter pair.

Move all of the selection logic (force enum, cost constants, cost model, shape
eligibility, planner) from where_plan.rs into hash_join.rs, co-locating "when to
use the hash join" with the operator itself and trimming the where_plan god-file.
Behavior-preserving: the cost decision is byte-identical to before.
The object→subject hash join's cost gate rejected the very query it exists for —
BSBM BI-1's `?review rev:reviewer ?reviewer . ?reviewer bsbm:country <US>`
COUNT(*) stayed ~48s. The driving estimate for a bound-object pattern is the
average object size (count/ndv ≈ 28K for country), but US is skewed-popular
(actual ~61.8K), so the estimated probe/driving ratio (2.85M/28K = 101.7×)
exceeded the 64× cap even though the actual ratio (46×) sits under it.

The scattered-OPST alternative costs ~760 µs/seek vs ~26 ns per sequential probe
row, putting the true break-even ratio near 29,000×, so 64× was far too tight.
Raise it to 1024× — comfortably absorbs the average-vs-skewed estimate error
while still guarding the pathological tiny-driving × huge-probe case.
The eligibility check required `tp.p.is_sid()` and the cost lookup used
`tp.p.as_sid()`, but only reasoning queries are pre-encoded to SIDs (runner.rs);
a plain SPARBM/JSON-LD query reaches planning with `Ref::Iri` predicates. So the
hash join was silently ineligible for the exact BSBM BI-1 join it targets —
`?review rev:reviewer ?reviewer . ?reviewer bsbm:country <US>` — regardless of
the cost gate, which is why raising the scan-ratio cap alone changed nothing.

Accept any fixed predicate (SID or IRI) for the probe, and look probe cardinality
up by IRI via `get_property_by_iri` when it isn't a SID — mirroring the planner's
`property_stats` fallback so the cost model and reorder agree. The cap raise +
this together are what let the hash join replace the scattered-OPST nested loop.
…ogical plan

Explain previously re-derived join order with a hand-rolled greedy reorder
(reorder_for_explain) that diverged from the executor's planner::reorder_patterns
— so the "optimized" order shown to users could differ from what actually runs.
Route explain_patterns through reorder_patterns (wrap triples as Pattern::Triple,
reorder, unwrap) and delete the duplicate, including its all-fallback/all-equal
short-circuits.

Add a compound-aware `plan.logical` view that preserves OPTIONAL/UNION/MINUS/
EXISTS/subquery structure and renders each node with kind + category + estimate
in the planner's chosen order, with user-facing IRIs. It is present even without
statistics (heuristic estimates). The flat original/optimized arrays are
unchanged for back-compat.

Reconcile docs/query/explain.md with the emitted JSON (the old text-format
example was produced by no endpoint) and lock the logical/execution-hints
surfaces with tests.
Add an Operator::describe() introspection method (provided; default leaf via
op_name/plan_children/plan_details) so the real operator tree can be rendered
without executing. The hot path is untouched — describe() is only ever called
by explain.

Explain now builds the real tree via build_operator_tree (build-only: no
open(), no I/O — so no scans/joins run) and walks it into a new plan.physical
field. Because fast-path / count-planner / fold selection happens at build
time, plan.physical shows the chosen physical operators (PropertyJoin vs
HashJoin vs NestedLoop, count fast paths, etc.) that the pattern-level views
cannot. Build errors are surfaced in-band rather than failing the explain.

Edges carry a relationship kind (child / fallback / conditional) so a
fast-path operator's correctness fallback never reads as a co-executing child.
plan_children is implemented on the modifier chain, grouping, the three joins,
compound operators, and count_rows; remaining fast-path operators render as
named leaves (the "which fast path fired" signal is still shown).

Document plan.physical and the planned-vs-actual boundary; add tests asserting
the tree is connected and operators resolve to concrete names.
…more operators

Cover the rest of the operator tree in plan.physical:

- FastPathOperator (the wrapper used by the whole count/metadata fast-path
  family, including the generic count planner) now renders as
  "FastPath:<label>" so explain names WHICH fast path fired, with the generic
  pipeline attached on a `fallback` edge (not a child — only one runs).
- fast_group_count_firsts operators and UnionStarCountAll expose their fallback
  edge likewise.
- Graph/Service/Values/GeoSearch/S2Search/PropertyPath connect their child so
  the tree no longer truncates at them.
- DatasetOperator exposes scan details (predicate + planned index hint when the
  planner set one) via a new DatasetBuilder::plan_details hook.

Add a test asserting a COUNT(*) explain surfaces a fast-path operator.
Add HashJoinPlanner::explain_object_hash_join, which computes the annotated
decision (join var, probe_count, driving_est, scan_ratio, chosen, and a
HashJoinReason: forced-on/off, cost-wins, probe-too-small, scan-ratio-too-high,
no-probe-stats). build_scan_or_join now computes the decision once, applies
`chosen` to pick HashJoin vs NestedLoop, and stashes it on the chosen operator
(plan-only — never read on the hot path). The prior choose_object_hash_join is
replaced (its single caller switched over).

plan.physical now renders hash-join-chosen / hash-join-reason and the cost
inputs on join nodes, so a NestedLoop that lost the hash join shows why — the
diagnostic the object-drive perf work needs. Also fill in the last bare leaves:
PropertyJoinOperator lists its fused predicates; count-plan already rendered as
a labeled FastPathOperator.

Add object->subject join tests: NestedLoop with the rejected reason by default,
HashJoin under FLUREE_HASH_JOIN=1.
…h join

In a "bowtie" join — two equally-selective bound-object filters connected through
a chain (BSBM-BI: producer→country=DE and reviewer→country=US) — reorder tied the
two filters on estimate and broke the tie by original query index. Since the BI
queries are written producer/DE-first, that drove from the DE side, which leaves
the 2.85M-row rev:reviewer join as a subject-driven forward join over a large
intermediate (NestedLoop, ~110s on BI-1's F2 at 100M).

Add a tie-break before original index: among equally-selective starts, prefer the
one whose chain turns the LARGER predicate into an object→subject hash join (one
contiguous probe scan) instead of a forward join. Driving from US makes the 2.85M
rev:reviewer join a hash join — F2 110s -> 2.38s (46x), identical result. The
signal is 0 without stats, so it only ever breaks exact row-count ties.
A forward join — the right pattern's subject is bound from the left and its object
is new — is not an object→subject hash candidate, so the decision helper returned
None and EXPLAIN left the NestedLoop opaque (no reason, no driving size). This is
exactly the BSBM-BI F2 case: the reviewer joins land subject-driven and the plan
gave no hint why they weren't hash joins.

Classify the join shape into eligible / subject-driven / not-a-candidate, and for
the subject-driven case return a decision carrying reason="subject-driven-forward-
join", the driving-set estimate, and the probe predicate count. The NestedLoop
already renders a stashed decision, so these now show up — making "reorder to drive
the other end" legible from the plan alone. join_var becomes Option (a rejected or
forward join has none).
The SubqueryOperator builds its subplan lazily (it holds the SubqueryPattern +
stats + planning, not an operator field), so the default plan_children walk
truncates at the subquery node — leaving the inner joins, where BSBM-BI time
actually goes, opaque in plan.physical.

Override describe() to rebuild the inner tree on demand for explain: build-only
(no open()/exec), via the same build_where_operators_seeded +
apply_solution_modifiers path the runtime uses, seeded with a schema-only
SeedOperator carrying the correlation vars so the inner reorder sees the same
initial bound set. The inner plan renders under a SubqueryBody node (the first
child remains the outer correlated input); node details report join-mode and
correlation-vars. Add a sub-SELECT test asserting the inner aggregation is
visible under SubqueryBody.
build_inner_plan_for_explain always seeded the rebuilt inner plan with
SeedOperator(correlation_vars), but execution seeds two ways: join-mode evaluates
the body once with an EmptyOperator (materialize), while a genuine per-row
subquery seeds the correlation vars. The seed's schema is the inner
reorder_patterns' initial bound set AND the child every nested subquery sees, and
a SeedOperator reports estimated_rows()=Some(1) — so nested subqueries' cardinality
guard evaluated 1 >= 8 as false and EXPLAIN rendered them join-mode=false even when
they actually materialize, misreporting both the inner join order and nested
join-mode (e.g. the BSBM BI-8 plan).

Seed EmptyOperator for join-mode / uncorrelated subqueries and only
SeedOperator(correlation_vars) for the per-row path, so plan.physical reflects the
plan that runs.
…orld scan

estimate_pattern scored every property path at DEFAULT_PROPERTY_SCAN_SELECTIVITY
(1,000,000) regardless of whether an endpoint was bound. A transitive path with a
constant (or already-bound) subject/object — e.g. `<concept/912> skos:broader+ ?b`
— enumerates the reachable set from a fixed node, typically a handful of rows, but
the 1M estimate made reorder rank it above a joined high-cardinality predicate. So
`<c912> skos:broader+ ?b . ?b skos:prefLabel ?lbl` drove a full scan of all 150k
prefLabel triples instead of probing prefLabel by the ~5 nodes the path produces:
88s vs ~1ms for the VALUES-materialised equivalent (issue #1287).

Estimate a path anchored at a bound endpoint as a small bounded closure so the
planner drives it first and the joined predicate becomes a per-subject probe. An
unanchored path (both endpoints free) is genuinely unbounded and keeps the large
estimate.
@bplatz bplatz requested review from aaj3f and zonotope June 5, 2026 02:54
… test env

Address three review findings on the hash-join/explain work.

The object→subject hash join collapsed both Unbound and Poisoned join keys to a
single wildcard that matched every probe row. That is correct for Unbound, but a
Poisoned binding (a failed OPTIONAL) must BLOCK matching — the nested loop skips
such rows — so a failed OPTIONAL feeding the hash join could fan out to every probe
row instead of producing no match. join_key now returns a 3-way class: Unbound is a
wildcard, Poisoned is dropped, everything else is keyed. Adds an operator-level
test that a Poisoned build row produces no fan-out, plus a classification test.

plan.logical re-estimated every rendered node with an empty bound-var set, so later
nodes showed full-scan estimates as if no earlier variable were bound, diverging
from the planner's context-aware estimates. The ordered logical plan now threads
the evolving bound set (and inner compound pattern lists thread their own), with a
regression test that the second triple of a chain estimates bound-subject rather
than a full predicate scan.

The explain test mutated FLUREE_HASH_JOIN process-wide without isolation; since
HashJoinPlanner reads it at plan time, a parallel plan-building test could go flaky.
A shared mutex + RAII guard now serializes the file's plan-building tests and
restores the prior value on drop (including on panic).
Base automatically changed from perf/iri-compactor-namespace-arc to main June 5, 2026 19:33

@zonotope zonotope 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.

🎼

@bplatz bplatz merged commit d637f72 into main Jun 5, 2026
14 checks passed
@bplatz bplatz deleted the perf/snapshot-arc-scaling branch June 5, 2026 20:25
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.

Query planner: joining a property-path-produced variable with skos:prefLabel triggers a full predicate scan (88 s vs 1 ms)

2 participants