Skip to content

perf(query): F17 — UNION/BIND row-budget forwarding + budget-met branch skip (q029 125s→2.6s)#1507

Open
aaj3f wants to merge 6 commits into
test/exploration-corpus-familyfrom
perf/r2rml-f17-union-budget
Open

perf(query): F17 — UNION/BIND row-budget forwarding + budget-met branch skip (q029 125s→2.6s)#1507
aaj3f wants to merge 6 commits into
test/exploration-corpus-familyfrom
perf/r2rml-f17-union-budget

Conversation

@aaj3f

@aaj3f aaj3f commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

q029 ({ WebEvent, eventType="purchase" } UNION { WebEvent, eventType="add_to_cart" } LIMIT 100) re-scanned both FACT_WEB_EVENT branches ~253× — 1.94 M file decodes for a 100-row answer — because the LIMIT 100 budget never reached the branch scans. It died at two absorb points: UnionOperator and BindOperator (q029's branch root is BIND(const AS ?et)), both inheriting the trait's no-op set_row_budget; and once branch-1 filled the budget the union still drained branch-2.

Two changes, both gated by FLUREE_R2RML_UNION_BUDGET (default on):

  1. Forward the budget through UnionOperator (the full k to each branch — a single branch may supply all k) and BindOperator (to its child; BIND is categorically 1:1/order-preserving, reclassified from absorb to forward). The R2RML scan then caps its materialize window at k.
  2. Budget-met branch/input skip (secondary lever): the union stops building further branches / pulling further input once pending_output_rows >= row_budget. Sound on two counts — a budget reaches the union only under row/order-preserving operators up to the LIMIT (Sort/Distinct absorb ⇒ arbitrary-k-subset / rows_only), and the check fires only when the budget is actually MET (an under-filled branch-1 still runs branch-2).

Result

q029: 125 s (pre-slate) → 2.61 s in-protocol (3-rep per-head baseline, F17 + the #1503/artifact cache stack) — under the ≤3 s bar; burndown row closes. Deterministic, network-invariant evidence (live gate): scan_table 253 → 2, parquet decodes 1,940,510 → 7,703 (2 sweeps → 1), OFF byte-preserved, oracle-parity 0-mismatch. Full-corpus baseline: 59 records, 0 hash mismatches.

Cold isolated first-ask is ~57 s-class and is reported, not gated: the residual is one full FACT_WEB_EVENT sweep, S3-fetch-bound (decode trivial). Wall model: ~7 s fixed (loadTable+oauth+scan_plan) + N_sweeps × one-sweep-fetch; the union is sequential, so the lever removes one whole sweep. This is the shared PR-2a floor (q016/q031/q056) — fetch-rate-sensitive, not decode.

Honest notes

  • NOT byte-identical (a sketch prediction, refuted + corrected in-tree): eventType is a post-decode residual and the scan window is geometric-growth, so the budget materializes a different valid first-100 → rows_only (same class as q055/q057). Oracle parity holds.
  • Blast radius = q029 only (q042 is UNION without LIMIT; q029 is the sole BIND-under-LIMIT query in the corpus).

Perf violations at this head — documented, not blessed away

The 3-rep baseline flagged 5 pre-existing perf violations (q002/q004/q024/q030/q043, 2.5–8× over blessed). Attributed and NOT caused by F17:

q blessed observed ratio load_table (GET)
q002 1136 ms 2827 ms 2.49× 1.77 s
q004 415 ms 3320 ms 8.00× 1.52 s
q024 510 ms 2828 ms 5.55× 1.22 s
q030 1221 ms 3047 ms 2.50× 0.96 s
q043 2125 ms 12879 ms 6.06× 11.17 s (6 GETs)
  • F17 verified inert for all five — none has a UNION or a BIND, so F17's switch-gated code never executes for them.
  • fetch_bytes ≈ 0 for every one (files disk-served; artifact cache 90 MB, 5.7× under the 512 MiB cap) — disproves any cache-eviction / regression story.
  • The wall is the loadTable catalog GET: ~1.5 s now vs ~0.4 s when the baselines were blessed — a network-path / environmental slowdown of the catalog endpoint. q043 is worst because it does 6 GETs = the edw:name name-dim fan-out (F20-family, pre-existing).
  • Not re-blessed (that would lower the bar on a catalog-slow day) — tracked as register F21; re-check catalog latency in a later window.

#1503 scope note (comment for the reviewer): attributing F21 showed the #1503 loadTable-pointer cache is TTL-window-bounded — TTL default 300 s and get_metadata_location prunes on an expired read, so the pointer spares the GET only within ~5 min of a table's last touch (a first-ask after a quiet gap still pays it). Only one .pointer.json (FACT_WEB_EVENT) survived on disk. That is an honest narrowing of #1503's "credential-free first-ask" claim to an intra-window/intra-session win; whether it's purely TTL or also a per-table gap, and how to widen it, is F21's design question (separate sketch + PR). Dated note added to 21-loadtable-metadata-cache.md.

Tests

  • Hermetic (fluree-db-query, 13 union tests): BIND forwards; Sort/Distinct absorb (decline proofs); union stores; Limit→Union correctness; 3 build-count lever tests (branch-1 fills k ⇒ branch-2 not built; under-fills ⇒ built; no budget ⇒ both run).
  • Full -p fluree-db-query suite: 1256 passed, 0 failed.
  • Scan-level effects (early-stop, ON vs OFF counters) are live-gated — only the R2RML scan honors row_budget and union branches are pattern-built, so there is no hermetic mock-scan seam (a #[cfg(test)] injection hook was considered and declined to keep the binary scaffolding-free).

Harness

The live-gate script's OFF arm ceiling was raised 180 s → 300 s (its ~167 s baseline is fragile at 180 — same headroom ratio as q056 168/180). Manifest timeout_s stays 180 (shipped F17-ON q029 is ~2.6 s in-protocol, far under it).

Stacked on #1506 (test/exploration-corpus-family). Commits: sketch → forwarding → doc-correction → lever → baseline-verdict/F21-docs.

@aaj3f
aaj3f force-pushed the test/exploration-corpus-family branch from 2ae2f99 to fd06533 Compare July 15, 2026 17:42
aaj3f added 6 commits July 15, 2026 13:42
Design for q029 (UNION under LIMIT re-scans both FACT_WEB_EVENT branches
fully because the LIMIT budget dies at the UnionOperator AND BindOperator,
both of which inherit the no-op set_row_budget). Fix = forward the budget
through both (UNION to each branch, full k; BIND to its child), switch-gated
FLUREE_R2RML_UNION_BUDGET. Every decline case (ORDER BY/DISTINCT/in-branch
filter) is handled by the existing absorb boundary. Lead-approved.
q029 (`{WebEvent eventType=purchase} UNION {...add_to_cart} LIMIT 100`)
re-scanned both FACT_WEB_EVENT branches in full (~1.94M file reads for a
100-row answer) because the LIMIT budget died at TWO absorb points: the
UnionOperator and the BindOperator (q029's branch root is BIND(const AS
?et)), both inheriting the no-op set_row_budget.

Forward the budget through both, gated by FLUREE_R2RML_UNION_BUDGET
(default on):
- UnionOperator stores the budget and re-applies the FULL k to each branch
  operator tree as it is built (a single branch may supply all k rows, so
  it is not split across branches).
- BindOperator forwards to its child — reclassified from absorb to forward,
  categorically sound because BIND is 1:1 and order-preserving.

The R2RML scan then caps its materialize window at k and stops after k
matching rows (the eventType equality is a fused scan constraint, so the
scan's `emitted` counts matches). Exact-LIMIT-preserving: the budget only
removes rows the LIMIT already discards, so switch ON == OFF.

Every decline case is handled by the EXISTING absorb boundary, no new
logic: ORDER BY (Sort absorbs), DISTINCT (Distinct absorbs), an in-branch
FILTER/reorder (absorb). Hermetic tests cover Bind-forwards, Sort/Distinct
absorb, the union store, and Limit->Union correctness; the per-branch scan
early-stop + q029 ON==OFF are the live gate.

Corpus blast radius = q029 only (q042 is UNION without LIMIT; q029 is the
sole BIND under LIMIT).
… outcome

Live gate refuted two sketch predictions: (1) ON!=OFF hash — eventType is
a post-decode residual + geometric window growth, so the budget picks a
different valid first-100 (rows_only, not byte-identical) and each branch
still sweeps the full table; the win is re-drive elimination (166.7->45.9s,
decodes 1.94M->15.4K/126x), residual = PR-2a per-file floor. (2) the
secondary lever DOES help q029 (branch-2 skip, ~46->~23s) — the earlier
'no corpus query needs it' was wrong. Dated corrections in sketch + register.
The union runs every branch for an input row before checking the budget,
so once branch-1 fills the LIMIT budget, branch-2 still scans fully — for
q029 that is a whole extra FACT_WEB_EVENT sweep (the 2nd of the two sweeps
in the measured 45.9s). Break the union's branch/input loop once the
buffered output meets the row budget (new `budget_met` flag, same
FLUREE_R2RML_UNION_BUDGET switch).

Sound on two counts: a budget reaches the union only when every operator
up to the LIMIT is row/order-preserving (Sort/Distinct absorb), so the
consumer wants an arbitrary k-subset (rows_only); AND the check fires only
when the budget is actually MET, so an under-filled branch-1 still runs
branch-2 and contributes its rows.

Build-count hermetic tests: branch-1 fills k -> branch-2 not built;
branch-1 under-fills -> branch-2 built; no budget -> both run.

Reverses the earlier in-sketch deferral — its "no corpus query needs it"
premise was refuted by the ON trace (q029's branch-2 sweeps fully after
branch-1 supplies 100). Expected q029 -> ~1 sweep (~23s-class); wall
re-gate pending the limited-network window.
…nter scope)

q029 closes its burndown row at 2.61s in-protocol (F17 + cache stack); cold
isolated first-ask ~57s-class (S3-fetch floor). The F17 3-rep baseline's 5 perf
violations (q002/q004/q024/q030/q043) are attributed and are NOT F17: all five
are UNION/BIND-free so F17's switch-gated code is inert, and it is NOT cache
eviction (fetch_bytes≈0; artifact cache 90MB, 5.7x under the 512MiB cap). The
wall is the loadTable catalog GET (~1.5s vs ~0.4s at bless — network-path).

Filed as F21: the #1503 loadTable-pointer cache is TTL-window-bounded (300s +
prune-on-read), so it spares the GET only within ~5min of a table's last touch
(only 1 .pointer.json survived on disk). Mechanism question — TTL-window vs
per-table/keying gap — deferred to a design sketch. q043's 6-GET edw:name fan-out
is noted as an F20 widening candidate (class-constrained lang-tag star). doc-21
gets the dated TTL scope correction to its "warm first-ask = zero GET" claim.
…g note, SWITCHES inventory, fmt

- union.rs: add the coupling cross-reference the review asked for at the budget-met
  lever — count (1)'s soundness rests entirely on the forward/absorb classification
  in Operator::set_row_budget's doc, so an author who forwards a budget through an
  order-SENSITIVE operator breaks this lever, not the forwarding site. Documented at
  the dangerous end.
- SWITCHES.md: the standing switch-inventory ask — one page, one row per virtual
  perf lever (default / mechanism / PR / OFF-restores), the falsy convention, the
  loadTable TTL governing-bound note (#1503) + F21 pointer, and the corpus-side
  timeout semantics (timeout_s is CI headroom, not the bar).
- cargo fmt (union.rs own-delta hunks).

The FLUREE_R2RML_UNION_BUDGET naming nit is addressed by the SWITCHES.md scope row
(it gates BIND forwarding too) rather than a rename. The scan early-stop test-seam
suggestion is declined on binary-purity grounds (see review response).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant