You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* perf(gfql): shrink the right side of a left-merge when the left is empty
`_lean_prefilter_right` shrinks `right` to the keys present in `left` before a
how='left' merge. It declined to do so when `left` was EMPTY -- the one case where
shrinking is both maximally profitable and trivially correct: no left key can match
anything, and a left merge keeps only the right rows that DO match, so the result is
empty whatever `right` holds.
The cost of declining is graph-sized. Instrumenting a single-node named query on a
3.18M-node / 14M-edge graph showed the dominant work was:
safe_merge(left=0x1, right=14000000x4, how='left')
i.e. a zero-row intermediate joined against every edge in the graph. Returning a
zero-row slice of `right` keeps the identical columns and dtypes -- so the merge still
produces the same schema -- without materializing the frame.
Measured on that graph: a named seed-only query goes 112.64 -> 17.29 ms (6.5x), same
result shape. Patterns whose intermediates are non-empty are unaffected.
Parity: 650 differential comparisons (pandas + polars x 13 shapes x 25 seeds, dense
graph, null-carrying columns) -- 0 divergences. Index + lean-combine suites: no new
failures (38 pre-existing GPU-image failures unchanged, 184 passed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VARwnAqmvczhz5EmP7TYyZ
* perf(gfql/polars): reuse the synthetic edge id as the stable edge order
The native polars chain executor attaches a synthetic edge id (`EID`) via
`with_row_index` when the graph has no edge binding, then — a few hundred lines later —
materializes a SECOND `with_row_index` column (`EORD`) over that same frame to restore
eager edge order for the fused combine. Both are 0..n-1 over the same frame in the same
order, because the second runs on the frame the first produced, so the second is pure
duplication of a graph-sized materialization.
Reuse EID as EORD when it was attached. Both columns were already dropped before
returning, so the single drop now covers it (guarded on `EID != EORD` for the case where
the graph brought its own edge binding and EORD is still a separate column).
Measured on a 3.18M-node / 14M-edge polars graph with polars-engine indexes, a seeded
typed pattern: 148.22 -> 135.43 ms (-8.6%). `with_row_index` was ~34 ms/query across the
eager calls before this.
Parity: 108 polars-path cases (9 traversal shapes x 12 random seeds, engine='polars' with
polars-engine indexes) hashed over column ORDER, dtypes, row count and full content --
0 differ, 0 raised. This is a new sweep: the existing 650-case differential runs
engine=auto, which resolves to pandas and never exercises this executor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VARwnAqmvczhz5EmP7TYyZ
* docs+test(gfql): CHANGELOG and both-direction tests for the lean-combine shrink
Owner's merge conditions for this PR were a CHANGELOG entry, correctness tests in
both directions, and a pyg-bench lane (the last is separate).
Tests pin both sides of the new empty-left branch:
- empty left shrinks to zero rows AND keeps columns/dtypes identical, so the
downstream merge still produces the same schema;
- the shrunk merge equals the unshrunk merge at the call site's actual how='left';
- non-empty lefts (1, 5, 999, 1000 rows — spanning both sides of the ratio gate)
are byte-identical to the unshrunk merge, i.e. the new branch perturbs nothing;
- a non-empty left whose keys miss entirely still yields one null-filled row per
left row, rather than being collapsed;
- end-to-end: a seed matching nothing gives lean-on == lean-off through chain().
Mutation-checked. Reverting the branch to `return right` fails the shrink test.
A second mutation (also shrink when no keys overlap) is NOT caught — correctly:
under how='left' both forms give null-filled rows, so there is no behaviour to
distinguish. The docstring says so rather than implying coverage it does not have.
Also records the safety precondition in the CHANGELOG: the shrink is used at exactly
one call site, which is how='left'; a how='right'/'outer' merge retains unmatched
right rows and would NOT be safe to shrink this way.
* review(#1783): fix a silent no-op on float indexes; drop a dead guard; pin edge order
Review skill found no correctness defect (the adversarial question — is how='left'
really the only consumer of _lean_prefilter_right — came back CONFIRMED: one
non-test caller, the local is dead on the next line, and every other merge in
chain.py builds its right side independently). These are the quality items.
1. `right[0:0]` is LABEL-based slicing on a float index — pandas routes those
through slice_indexer — so it returned ONE row and the shrink silently did
nothing. Now `right.iloc[0:0]`, positional on every index type. The RESULT was
always correct (a 0-row left yields a 0-row how='left' merge either way), which
is why this needed its own test: the bug was an invisible perf no-op, not a
wrong answer. Mutation-checked — reverting to `[0:0]` fails the new test.
2. Docstring said "Only shrinks when `left` is materially smaller", which the new
empty-left branch contradicts. Restated, and now records that the whole helper
is pandas-only in practice (`_lean_engine_ok` is checked first), so there is no
cross-engine exposure.
3. Removed `if added_edge_index and EID != EORD:` — dead by construction, since the
only branch setting added_edge_index also sets EORD = EID.
lint clean; 1022 lean-combine + polars-chain tests pass.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -12,6 +12,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
12
12
- **GFQL secondary node property indexes (`create_index('node_prop', column=...)` / `g.gfql_index_node_props([...])`)**: a seed predicate on a NON-key column — `MATCH (m {id: 42})` where the graph's node id binding is some other column — previously cost a full node scan, because the registry only indexed the node-id binding and the CSR adjacencies. A property index is the same pay-as-you-go sidecar as the existing kinds: sorted distinct values over node **row positions** (CSR, so duplicate values are indexable), never reorders `.nodes`, fingerprint-validated so a `.nodes()` rebind is treated as absent (safe miss, never a wrong answer), engine-polymorphic (numpy host / cupy on-device), and policy-gated (`off`/`use`/`auto`/`force`). The seeded fixed-hop planner picks the **most selective** indexed scalar predicate in the seed filter using a free CSR-offset estimate, gathers those candidates, and applies every remaining predicate to them — so results are identical whether the index is present, absent, stale, or cost-gated out. `show_indexes()` lists property indexes; `drop_index('node_prop', column=...)` drops one. Only integer columns are indexable today (float NaN ordering, strings on cupy, and nulls all decline to the scan); widening that is additive. **Perf (dgx-spark, official LDBC SNB SF1, 3.18M nodes, warm median, value-identical 19-row result):** interactive-short IS7 `71.6 ms -> 19.5 ms` (**3.7x**), with a one-time `112 ms` build — the seed lookup itself goes from a `51.2 ms` scan to `0.096 ms`.
13
13
14
14
### Performance
15
+
- **Seeded chain combine stops joining against the full frame when the intermediate is empty**: `_lean_prefilter_right` shrinks the big side of the combine's `how='left'` merge to the keys actually present on the left, but it declined to do so in the one case where shrinking is both maximally profitable and trivially correct — an **empty** left. A left merge keeps only the right rows that match, so a zero-row left yields a zero-row result whatever the right side holds; the merge was nonetheless materializing the whole graph-sized frame. It now hands back a zero-row slice of `right` (same columns and dtypes, so the merge still produces an identical schema). Measured: a single-node query whose 0-row intermediate was joined against 14M edges went **112.64 → 17.29 ms**. The shrink is used at exactly one call site, which is `how='left'`; a merge that retains unmatched right rows (`right`/`outer`) would NOT be safe to shrink this way, and the tests pin both directions — the empty-left case must return an empty result, and the non-empty cases must be byte-identical to the unshrunk merge.
16
+
-**Native polars chain reuses the synthetic edge id as its stable row order**: when the executor had already added a synthetic edge-index column it also added a second, separate row-index column purely to restore input order at the end — two full `with_row_index` passes over the edge frame, and a redundant column carried through every intermediate. The existing synthetic id is already a contiguous, order-preserving row index, so it is now reused as the sort key and dropped once at the end. Only the pre-existing column is reused; when no synthetic id was added the separate order column is still created, so graphs that bring their own edge id are unaffected. Value-identical including row order (148.22 → 135.43 ms on a 3.18M-node / 14M-edge polars graph).
15
17
- **GFQL indexed typed-edge traversals stop scanning the whole edge frame (#1658)**: the index path built the simple-equality `edge_match` mask over ALL `E` edges — one `(series == val)` over the full column — and then read that mask only at `rows[edge_keep[rows]]`, the handful of positions the CSR adjacency lookup had already returned. That put an **O(E) predicate scan inside an O(degree) traversal**, which is why an indexed seeded typed hop scaled with the graph while the underlying `g.hop()` stayed flat. The predicate is now evaluated on the gathered candidate rows instead: `_build_edge_row_filter` does the schema-level validation up front (the dtype-mismatch decline that keeps error parity with the scan is O(1) and unchanged) and `_EdgeMatchRowFilter.mask_for(rows)` applies `col == val` to just those rows, using each frame's native `==` exactly as before (so cuDF string columns stay on the cuDF layer). This is a cost tradeoff, not a free win, and it is bounded rather than assumed: candidate-row evaluation beats one whole-column compare only while the candidates stay a small fraction of the frame, and a fixed-point walk that reaches most of the graph inverts it — the out- and in-indices are filtered separately, so it can gather up to 2E elements by random access against the eager form's single sequential pass over E (measured before the guard: an undirected `to_fixed_point` typed walk gathered 1.94×E and ran 1.2–1.6× SLOWER than master). A cumulative-cost guard now switches to the whole-column mask once gathered rows reach E/8, so total predicate work is bounded at ~1.125×E in that regime while a seeded hop — which gathers ~degree — never approaches the threshold and never builds it. An evaluation failure mid-traversal abandons the indexed path entirely so the scan fallback stays parity-safe. Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed-edge query with alias markers goes **309.9 → 57.4 ms (5.4×)**, value-identical, with the full-column compare (previously 248 ms, 81% of the query) gone from the profile. Parity held across 650 differential comparisons — pandas + polars × 13 traversal shapes × 25 random seeds on a dense graph with null-carrying string and numeric columns — comparing columns, dtypes, row counts and full content, including the decline paths (membership `edge_match` still routes to the scan; a dtype mismatch still raises the same `GFQLSchemaError`). One honest limit on error parity: only the O(1) dtype gates are parity-exact. A *data-dependent* comparison failure (e.g. a list-valued cell in an object column) is now observed only if it lands in the gathered candidates, so the indexed path can succeed where the scan raises. That needs pathological cell values to reach, but it is a real narrowing of the previous guarantee. Pinned by two structural regression tests that assert the *shape* rather than a wall-clock number: the predicate must see only candidate rows, and growing the graph 8× at fixed degree must not grow the number of rows it examines.
16
18
- **GFQL indexed fixed-hop bindings now dispatch BEFORE the canonical traversal (pandas / cuDF / native Polars)**: the resident-index fixed-hop path (`rows(binding_ops=...)`, the Cypher multi-alias `MATCH … RETURN` shape) was consulted only *inside* row materialization — after the engine had already run the full canonical traversal — so its compact path bag was computed on top of the graph-sized work it exists to avoid. Both the pandas/cuDF chain boundary and the native Polars chain now ask the same shared, structural gate first and, only when it can serve the WHOLE middle exactly, skip the canonical traversal and hand the compact state to the unchanged row materializer. Engagement stays operator/index/dtype/cost based — no query, schema, or hop-count recognition — and every unsupported, seeded, prefiltered, policy-bearing, shortest-path, or cost-gated shape still declines to canonical execution with identical results. On a standard LDBC SNB SF1 interactive-short profile this removed the redundant two-hop typed-mask and 16-merge buckets and cut the profiled call ~11.9× (1263 ms → 106 ms), exact 19-row oracle preserved.
17
19
-**Indexed seed lookup now covers a unique node id PLUS extra scalar constraints**: `{node_id: v, other: w}` previously fell back to a full node-table scan even though the unique node-id index can gather at most one row; it now gathers that one row and applies the remaining constraints to it. Missing ids and non-matching extra constraints return the same empty result as before, and duplicate-id graphs (which cannot build the index) are unaffected.
0 commit comments