perf(gfql/polars): stop deduplicating semi-join key sides - #1784
Conversation
Measured on the real LDBC SNB SF1 polars lanePosition-balanced ABBA/BAAB — 4 samples per build, each build in each slot position exactly
4 faster, 0 slower, every result value-identical. A discarded run, disclosedThe first A/B of this branch is thrown away, not reported: I committed the That discarded run also showed IS4 at +14.0%, which would have been a regression. It did |
77e84b0 to
7a72a4c
Compare
975235e to
5d01b91
Compare
|
Status: keeping this draft for one reason only — it does not yet have a pyg-bench lock-in Everything else is in place: CHANGELOG entry, SF1 lane evidence (4 faster / 0 slower, rows One correction to my own test hygiene, since it is visible in the history: I committed |
| - **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`. | ||
|
|
||
| ### Performance | ||
| - **GFQL polars chain stops deduplicating semi-join key sides**: the native polars executor applied `.unique()` to every frame it fed into a `how="semi"` join. A semi-join emits a left row iff at least one matching right row exists, so duplicate keys can neither change which rows come back nor multiply them the way an inner join would — the deduplication was a full hash pass over the key column bought for no observable effect. On an unfiltered hop the key side **is** the node table, so this put **O(N) work inside a query whose answer is O(degree)**: the seeded single-hop plan built two such key frames per hop, each costing ~53 ms at 3.18M nodes — more than the rest of the query combined. The `.unique()` is now dropped everywhere the frame is provably a semi key side only (the `_semi` helper, the alias hop-window and next-edge endpoint gates, the two-hop fast path's endpoint gate, the `start_nodes` gate, and the single-hop planner's id frames). It is deliberately **kept** on the alias frame that feeds a `how="left"` join, where duplicates genuinely would multiply rows, and the eager multi-hop loop is untouched (its frames also flow into concat/anti-join bookkeeping, a separate argument). Measured on a 3.18M-node / 14M-edge polars graph (LDBC SNB SF1-shaped), a seeded typed hop goes **127.4 → 57.1 ms (2.23×)** with identical row counts; at 1.75M edges **102.8 → 31.4 ms (3.28×)**, confirming the removed cost scales with node count, not edge count. Parity held across 380 differential comparisons covering duplicate node keys, null ids, dangling edges, duplicate `start_nodes`, and 11 traversal shapes; the gfql and chain suites show identical failure sets before and after. Pinned by tests that assert the boundary rather than the speed: duplicates reaching a semi key side must not change results or multiply rows, a dangling endpoint must still be excluded (the gate is load-bearing, not vacuous), and duplicate `start_nodes` must be inert. |
There was a problem hiding this comment.
once benefit proven & locked in pyg-bench, and happy with postive/negative tests on either side of opt boundary, and ci still green, can land both sides
56c7949 to
5f4cade
Compare
604e37d to
7d508ab
Compare
|
| probe | pins | master | #1782 | #1783 | #1784 | #1785 |
|---|---|---|---|---|---|---|
indexed-typed-edge-match-hop[polars] |
#1782 | regression | pass | pass | pass | pass |
empty-left-typed-edge-miss[pandas] |
#1783 | regression | regression | pass | pass | pass |
polars-edge-order-materializations |
#1783 | regression | regression | pass | pass | pass |
semi-join-key-dedup-node-ladder[polars] |
#1784 | regression | regression | regression | pass | pass |
indexed-typed-edge-match-hop[pandas] |
#1782 | pass | pass | pass | pass | pass |
A monotone staircase: each PR flips exactly its own probes, nothing regresses, and #1785
holds all five. Growth over an 8× ladder:
indexed-typed-edge-match-hop[polars]: master 2.97× (2.55 → 7.59 ms) → perf(gfql): evaluate indexed edge_match on candidate rows, not the whole edge frame #1782 0.93×
(0.28 → 0.26 ms), i.e. flat.empty-left-typed-edge-miss: master 2.55× (9.20 → 23.45 ms) → perf(gfql): empty-left merge shrink + polars EORD/EID row-index dedup #1783 0.998×
(6.67 → 6.66 ms), i.e. flat.
Note the config-level exit code is cumulative — it is the stack gate, so on its own it
reads as "master/#1782/#1783 fail". The per-probe verdicts above are what [1] actually asks
for.
One probe does not discriminate — flagging rather than burying
indexed-typed-edge-match-hop[pandas] reports pass on master. It is not blind — it
measures 5.294× growth there. It passes because the verdict ANDs the ratio gate with a
5.0 ms min_absolute_delta_ms floor, and that variant's absolute delta at this ladder size is
only 2.11 ms. So the floor, which exists to stop flaky sub-noise failures, is suppressing a
real 5.3× growth.
The [polars] variant is what actually pins #1782; the pandas one currently rides along.
It should be fixed by raising base_edges for that probe until the delta clears the floor, or
by giving it a smaller per-probe floor — not by deleting it. It is one config change away from
being load-bearing, and it is a live instance of exactly the failure mode the lane's own README
warns about.
Two run traps worth recording
uv is not on the non-interactive ssh PATH (exit 127 — use $HOME/.local/bin/uv), and the
pyg-bench uv env has no polars (--with polars). Both produced silent-looking failures first.
…fix two claims Review skill found no correctness defect — every site whose `.unique()` was removed is a genuine semi key side (audited one by one), and the "does an undeduped key side make the hash build worse" objection was measured and REJECTED: at a 24M-row key side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`, because dedup must hash the same rows before it can shrink anything. These are the quality items. 1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read. It bounded per-key multiplicity at <=2, which is exactly the duplication the fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the deliberately-kept `.unique()` on the alias frame left all 8 parameterizations GREEN. Now compares against the pandas oracle exactly, and the same mutation fails 7 of 8. A test that cannot fail for its stated reason is worse than no test. 2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`. Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather than left to imply coverage that is not there. 3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit 06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that same function already use `isin` with no dedup, so this makes the engines agree. lint clean; 36 semi-dedup tests pass.
7d508ab to
29e8c41
Compare
Ready to merge — all three bars met
Beyond the bar
Competitive resultSecond flip, and this one needs no asterisk: IS5 Four weak tests of my own that mutation-checking caught, now fixed
Known limits, stated rather than buried
Not merging per your instruction — this is yours to land. #1785 stays draft pending its |
c08afb5 to
24ce01f
Compare
…fix two claims Review skill found no correctness defect — every site whose `.unique()` was removed is a genuine semi key side (audited one by one), and the "does an undeduped key side make the hash build worse" objection was measured and REJECTED: at a 24M-row key side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`, because dedup must hash the same rows before it can shrink anything. These are the quality items. 1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read. It bounded per-key multiplicity at <=2, which is exactly the duplication the fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the deliberately-kept `.unique()` on the alias frame left all 8 parameterizations GREEN. Now compares against the pandas oracle exactly, and the same mutation fails 7 of 8. A test that cannot fail for its stated reason is worse than no test. 2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`. Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather than left to imply coverage that is not there. 3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit 06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that same function already use `isin` with no dedup, so this makes the engines agree. lint clean; 36 semi-dedup tests pass.
29e8c41 to
57f739e
Compare
x-platform: cleanFull
Skip counts identical across all three, so nothing was silently skipped into looking green. Two scoping notes so the numbers aren't over-read:
|
A semi-join emits a left row iff at least one matching right row exists, so duplicate keys on the right cannot change the result -- and unlike an inner join it cannot multiply rows either. Every `.unique()` applied to a frame that is used *only* as a `how="semi"` key side is therefore a full hash pass bought for no observable effect. On an unfiltered hop the key side IS the node table, so this put O(N) work inside a query whose answer is O(degree): the seeded single-hop plan built two such frames per hop, and each cost ~53 ms at 3.18M nodes -- more than the rest of the query put together. Removed at the sites where the frame is provably a semi key side only: - chain.py `_semi` (the helper hardcodes how="semi") - chain.py alias hop-window gate, and the next-edge endpoint gate - chain.py endpoint gate in the two-hop fast path - pattern_apply.py start-nodes gate - hop_eager.py `_idframe_lf` -- all four consumers are semi key sides Deliberately NOT removed: `named` in the alias pass keeps its `.unique()` because it feeds a how="left" join, where duplicates WOULD multiply rows. The eager multi-hop loop's `_idframe` is untouched -- its frames also flow into concat/anti-join bookkeeping, which is a separate argument. Measured, 3.18M nodes / 14M edges, seeded typed hop, engine=polars: 127.44 -> 57.12 ms (2.23x), row counts identical. At E=1.75M: 102.83 -> 31.35 ms (3.28x) -- the removed cost scales with N, not E. Parity: 380 differential cases, 0 divergences, over duplicate node keys, null ids, dangling edges, duplicate start_nodes, and 11 traversal shapes. gfql + chain test suites: identical failure sets before and after.
…s semi key side Same argument as the chain-side change: a semi-join emits a left row iff at least one match exists, so repeated ids can neither change the result nor multiply rows. The cudf/pandas branches of this same function already use `isin` with no dedup, so this also makes the three engines agree.
…lumn I committed this test file without running pytest on it — the container parity probes covered the same ground, so the gap went unnoticed until the rebase. Three cases failed: start_nodes stands in for the node frame, so a key-only frame made the seed's own `id` predicate raise before reaching the semi-join under test. Mutation-checked now: dropping the `.unique()` that must STAY (the alias frame feeding a how='left' join) fails 7 of these tests, which is the row-multiplication regression they exist to catch. Restoring the removed `.unique()` on the start_nodes key side is correctly inert — it is a no-op by construction.
…fix two claims Review skill found no correctness defect — every site whose `.unique()` was removed is a genuine semi key side (audited one by one), and the "does an undeduped key side make the hash build worse" objection was measured and REJECTED: at a 24M-row key side, no-dedup is 150-186 ms / 0.66 GB against 470-486 ms / 1.12 GB with `.unique()`, because dedup must hash the same rows before it can shrink anything. These are the quality items. 1. `test_duplicate_node_keys_do_not_multiply_result_rows` was weaker than it read. It bounded per-key multiplicity at <=2, which is exactly the duplication the fixture introduces — so the bound ABSORBED the bug. Mutation-checked: dropping the deliberately-kept `.unique()` on the alias frame left all 8 parameterizations GREEN. Now compares against the pandas oracle exactly, and the same mutation fails 7 of 8. A test that cannot fail for its stated reason is worse than no test. 2. The `start_nodes` test docstring claimed it exercises `pattern_apply.py:70`. Instrumenting shows `rows_binding_ops_polars` is called ZERO times from this file — it goes through chain.py's `_semi` / node-only fast path. Claim corrected rather than left to imply coverage that is not there. 3. CHANGELOG enumerated six sites and omitted the seventh (`select_by_ids`, commit 06972b4). Added, with the reason it is safe: the cuDF and pandas branches of that same function already use `isin` with no dedup, so this makes the engines agree. lint clean; 36 semi-dedup tests pass.
57f739e to
b883011
Compare
perf(gfql/polars): stop deduplicating semi-join key sides
Stacked on #1783 (
perf/gfql-lean-empty-left). Review the top commit only.The bug
The native polars chain applied
.unique()to every frame it fed into ahow="semi"join:A semi-join emits a left row iff at least one matching right row exists. Duplicate keys
on the right cannot change which rows come back, and — unlike an inner join — cannot
multiply them either. So the deduplication is a full hash pass over the key column bought
for no observable effect.
That would be merely wasteful if the key side were small. It isn't: on an unfiltered hop
the key side IS the node table, so this put O(N) work inside a query whose answer is
O(degree). The seeded single-hop planner builds two such key frames per hop.
How it was found
Bisected, not guessed — and the first two hypotheses died:
collect_all= 108.8 ms of 140 ms, 2 callsnodes.select(key).unique()alone, N=3.18MDumping the optimized plans confirmed it:
UNIQUE[keep_strategy: Any] BY NoneoverDF["key",…] PROJECT 1/32 COLUMNS, repeatedly, feeding SEMI JOINs.Isolated on raw polars, the rewrite is worth 7× on this shape:
.unique()_idframeemitsThe fix
.unique()dropped only where the frame is provably a semi key side and nothing else:chain.py_semi(the helper hardcodeshow="semi")chain.pyalias hop-window gate, and the next-edge endpoint gatechain.pyendpoint gate in the two-hop fast pathpattern_apply.pystart_nodesgatehop_eager.py_idframe_lf— all four consumers (allowed_source_lf,allowed_dest_lf,target_final_lf,frontier_lf) are semi key sidesDeliberately kept:
namedin the alias pass. It feeds ahow="left"join, whereduplicates genuinely would multiply rows. The eager multi-hop loop's
_idframeis alsountouched — its frames flow into concat/anti-join bookkeeping, which is a separate argument
and a separate change.
Correctness
sweep, plus a new adversarial one built specifically because the usual synthetic graphs
have unique node keys and would make this rewrite trivially equivalent — so it exercises
duplicate node keys, null ids, dangling edges, duplicate
start_nodes, across 11traversal shapes and 4 graph variants.
graphistry/tests/compute/gfql+test_compute_chain.py: identical failure setsbefore and after (1 pre-existing GPU-driver collection error on this host, both sides).
Tests
test_engine_polars_semi_key_dedup.pypins the boundary, not the speed (pandas is theoracle throughout, so a divergence fails as a parity break rather than a guess):
.unique()on thehow="left"side would causenot vacuous, so a future "this gate accepts everything, drop it" shortcut fails here
start_nodesmust be inert — the one key side a caller controlsNot a benchmark hack
The rewrite keys on a structural property of the operator — a frame used only as a
how="semi"key side — and helps every traversal on every schema, engine and label. Noquery, constant, dataset or column name is recognized anywhere. It removes work rather than
adding a special case, so there is no path it can make slower.
Measured
Synthetic LDBC SNB SF1-shaped graph (3.18M nodes / 14M edges),
engine='polars',seeded typed hop, min of 5:
The larger speedup at fewer edges is the signature of the fix: the removed cost scaled
with node count, so it dominates more when there is less edge work to hide it.
Real-lane numbers to follow — this is a draft until the position-balanced SF1 A/B lands.