Skip to content

perf(query): DISTINCT+LIMIT early termination via lazy var-length expansion - #210

Merged
knoguchi merged 1 commit into
mainfrom
perf/distinct-limit-early-termination
Aug 26, 2026
Merged

perf(query): DISTINCT+LIMIT early termination via lazy var-length expansion#210
knoguchi merged 1 commit into
mainfrom
perf/distinct-limit-early-termination

Conversation

@knoguchi

Copy link
Copy Markdown
Owner

Why

RETURN DISTINCT ... LIMIT k (no ORDER BY, no aggregation) may legally stop once k distinct projected rows exist. Two blockers: the final-clause early stop only understood plain row counts, and expand_variable_row materialized every var-length path for an input row before anything downstream ran — [:KNOWS*1..3] at avg degree ~100 builds ~1M paths in that Vec, which is the entire cost of the benchmark's IC1. BENCHMARKS.md documented this as legal-but-unimplemented.

What

  • VarExpandIter replaces the eager per-row BFS with an explicit-stack DFS yielding rows as paths are discovered. Identical path set and edge-isomorphism exclusions; relationship_expansion still meters every edge and count_stream still counts emitted rows. Two documented behavior notes: emission order within a hop is now depth-first (row order without ORDER BY is unspecified — full workspace + TCK pass unchanged), and the unbounded *0.. depth-cap error is discovered lazily (identical for fully-drained queries; a satisfied DISTINCT+LIMIT may now succeed where the eager version errored after doing all the work anyway).
  • collect_rows_until_distinct — the DISTINCT counterpart of final_stream_limit: pulls the pipeline while projecting rows through the RETURN items and deduping with exactly dedup_rows's key, stopping at SKIP+LIMIT distinct rows. materialize_return then re-applies the same dedup to the ≤cap kept rows, so semantics are decided by the existing machinery.

Measured (LDBC-style SF 0.1)

query before after
IC1 *1..3 DISTINCT LIMIT 20 643 ms 1.84 ms (350x)
IC5 2-hop DISTINCT LIMIT 20 71 ms 714 µs (100x)
everything else unchanged vs main under identical conditions

Verification

  • Laziness proven, not assumed: a new test gives the query a relationship-expansion budget that full enumeration exhausts (ResourceLimit) but the DISTINCT+LIMIT run stays under — plus a correctness test asserting the LIMITed rows are a duplicate-free subset of the full DISTINCT set, with SKIP composition and LIMIT-larger-than-set cases
  • cargo test --workspace green; SF 0.1 --ignored workload test green; TCK 3880/3880 unchanged; fmt + both clippy configs clean
  • Docs: README + BENCHMARKS.md streaming/target sections updated, CHANGELOG entry added

…ansion

RETURN DISTINCT ... LIMIT k with no ORDER BY and no aggregation may
legally stop as soon as k (plus SKIP) distinct projected rows exist,
but two things prevented it: the final-clause early-stop only handled
plain row counts (a distinct cap is data-dependent, not a take(n)), and
expand_variable_row materialized every path for an input row before
anything downstream saw one — for [:KNOWS*1..3] at avg degree ~100
that Vec of ~1M paths WAS the query's cost, so no consumer-side stop
could help.

Two halves:
- VarExpandIter replaces the eager BFS: an explicit-stack depth-first
  enumeration yielding each qualifying path's row as discovered. Same
  path set, same edge-isomorphism exclusions, same guards
  (relationship_expansion per edge, count_stream downstream); emission
  order becomes depth-first (unspecified without ORDER BY), and the
  unbounded *0.. depth-cap error is discovered lazily — identical
  outcome for any fully-drained query, but a satisfied DISTINCT+LIMIT
  may now finish before reaching the offending branch (the eager
  version did all that work and then discarded it).
- collect_rows_until_distinct, final_stream_limit's DISTINCT
  counterpart: pulls the pipeline while projecting each row through the
  RETURN items and deduping with exactly dedup_rows' key
  (eval_return_expr -> value_hash_key), stopping at SKIP+LIMIT distinct
  rows, so materialize_return's semantics decide what counts as
  distinct.

Measured (LDBC-style SF 0.1, ldbc_style_ops): IC1 friends *1..3
DISTINCT LIMIT 20: 643 ms -> 1.84 ms; IC5 fixed-2-hop DISTINCT LIMIT:
71 ms -> 714 us. All other suite rows unchanged against main measured
under identical conditions. Laziness is regression-tested with a
relationship-expansion budget that full enumeration exhausts but the
early stop stays under.

TCK: 3880/3880, unchanged.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 91.01124% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
marsdb-query/src/executor.rs 91.01% 16 Missing ⚠️

📢 Thoughts on this report? Let us know!

@knoguchi
knoguchi merged commit 3858fa9 into main Aug 26, 2026
12 of 13 checks passed
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.

2 participants