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
docs(changelog): withdraw the "q8 stays a win over Kuzu" claim (graphistry#1825)
graphistry#1823's entry closed with "q5 and q8 are ties at both scales in both runs,
and q8 stays a win over Kuzu". The first half is a real measurement of what
that lane does. The second half is a board-position claim that does not
survive re-measurement, and it is now merged on master where it round-trips
as fact.
q8's headline number is a CROSS-CALL MEMOIZATION artifact. The two-hop
equal-domain degree counts are cached onto the CALLER's Plottable keyed by
id(), so the figure quoted on the board is a warm repeat call:
warm 2.02 ms @20k 5.09 ms @100k
cold (memo delattr'd) 13.18 49.68
fresh Plottable 14.60 53.02
bind_only control 0.02 0.02 <- rules out re-binding
A ONE-SHOT q8 therefore loses to embedded Kuzu by 3.1-5.2x at 20k and
2.8-6.0x at 100k. The memo itself is filed as graphistry#1825, because keying a cache
by id() on the caller's object also returns a stale answer after an in-place
edge or node mutation -- so the cell is not merely optimistic, the mechanism
behind it is a bug.
Reconciled rather than deleted: the withdrawn sentence is quoted in place so
the correction is traceable from the claim, and the measurement graphistry#1823 actually
reports -- that its lane leaves q8 unchanged -- is explicitly preserved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,7 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
14
14
- **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`.
15
15
16
16
### Performance
17
-
- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN <alias>.<prop> AS k, <agg> AS v ORDER BY .. [LIMIT n]` lowers to a fast path that semi-joins the edge frame against both node domains, inner-joins the projected properties on, groups, sorts and limits. Every one of those ops was issued EAGERLY — each its own `lazy().collect(_eager=True)` — so each intermediate materialized in full, the `select([src, dst])` projection could not be pushed into the semi-joins, and the `head()` could not reach back into the plan at all. That path serves three of the nine matched graph-benchmark cells (q1, q3, q4), and 74–96% of each of those queries' wall time sat inside those collects. The same op sequence is now expressed as a single lazy plan collected once — the algebra is character-identical (same `.unique()` id frames, same semi-joins, same un-deduplicated property lookups, same `group_by(maintain_order=True).agg(..)`, same per-key `nulls_last` sort, same `head`), so the value is identical, row ORDER included. The lane is strictly additive: the eager code is untouched and is the fallback on every decline. It DECLINES — never answering differently, only forgoing the speedup — for a non-eager-polars input frame, a property column missing from its alias' node frame (the eager twin discovers that MID-CHAIN and declines the whole fast path, so the guard is hoisted ahead of plan construction rather than left to be discovered after a plan already exists), source and destination bound to the same edge column, a projected column colliding with an endpoint column or the internal lookup key, an untranslatable aggregate, and — the correctness crux — **a result row order that ORDER BY does not fully determine**. Without every group key in the sort, the eager twin's order falls back to `maintain_order=True` group first-appearance order over an EAGER join output, which a lazy plan is free to change by re-ordering or re-siding joins; measured with an ungated variant of the same plan over 4 graph sizes × 4 seeds × 4 order-undetermined shapes, 47 of 64 comparisons diverged from the eager twin, and under `LIMIT` the divergence is a different ROW SET rather than a different row order. Not a GPU change: like the eager code and the fused two-star lane, it collects on CPU polars for both `polars` and `polars-gpu`. **Measured** on dgx-spark, matched graph-benchmark q1–q9 lane, one perf lock per experiment, master tree vs PR tree position-balanced `M P P M P M M P`, per-slot medians (never best-of), rows and canonical values compared on every cell, and replicated end to end in a second independent run: **`engine='polars'` q1 `13.31 → 8.96 ms` (−32.7%), q3 `8.54 → 5.53 ms` (−35.3%), q4 `6.99 → 5.05 ms` (−27.8%) at 20k**, per-slot ranges non-overlapping on all three in both runs; at 100k q1 `42.59 → 31.45 ms` (−26.2%) and q4 `12.19 → 10.43 ms` (−14.4%), with q3 `−9.1%…−13.0%` (non-overlapping in one run, overlapping in the other). Against same-session embedded Kuzu at 20k that widens q1 from a 1.12× win to **1.66×**, moves q3 from a **1.37× LOSS to a TIE** (Kuzu 6.29 ms; the two slot ranges overlap, so it is a tie and not a win), and narrows q4 from a 2.10× loss to **1.51× — still a loss**. The cells this lane is not called for are unchanged: q5 and q8 are ties at both scales in both runs, and q8 stays a win over Kuzu. The pandas arm — untouched by this change — reproduces the reference board to +0.8%…+9.4% and same-session Kuzu reproduces it to −2.2%…+1.5% on the cells at issue, which is what shows the harness matches it. Value identity is the gate throughout: a differential over 19 shapes × 10 graphs, compared ROW-ORDER and COLUMN-ORDER sensitively against both the eager code and the pandas oracle, found zero divergences from the eager code; pinned tests cover multiplicity on BOTH arms of the hop (duplicate node rows, parallel edges, self-loops), null placement on group keys and on aggregate values, empty matches, dangling endpoints, non-numeric ids, degenerate column bindings, and every decline. One PRE-EXISTING divergence is disclosed rather than quietly changed: the polars property lookup is not deduplicated by node id while the pandas one is, so a node table carrying the same id twice multiplies matched rows on polars only — the fused lane reproduces the eager polars answer exactly, and a test pins both sides.
17
+
- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN <alias>.<prop> AS k, <agg> AS v ORDER BY .. [LIMIT n]` lowers to a fast path that semi-joins the edge frame against both node domains, inner-joins the projected properties on, groups, sorts and limits. Every one of those ops was issued EAGERLY — each its own `lazy().collect(_eager=True)` — so each intermediate materialized in full, the `select([src, dst])` projection could not be pushed into the semi-joins, and the `head()` could not reach back into the plan at all. That path serves three of the nine matched graph-benchmark cells (q1, q3, q4), and 74–96% of each of those queries' wall time sat inside those collects. The same op sequence is now expressed as a single lazy plan collected once — the algebra is character-identical (same `.unique()` id frames, same semi-joins, same un-deduplicated property lookups, same `group_by(maintain_order=True).agg(..)`, same per-key `nulls_last` sort, same `head`), so the value is identical, row ORDER included. The lane is strictly additive: the eager code is untouched and is the fallback on every decline. It DECLINES — never answering differently, only forgoing the speedup — for a non-eager-polars input frame, a property column missing from its alias' node frame (the eager twin discovers that MID-CHAIN and declines the whole fast path, so the guard is hoisted ahead of plan construction rather than left to be discovered after a plan already exists), source and destination bound to the same edge column, a projected column colliding with an endpoint column or the internal lookup key, an untranslatable aggregate, and — the correctness crux — **a result row order that ORDER BY does not fully determine**. Without every group key in the sort, the eager twin's order falls back to `maintain_order=True` group first-appearance order over an EAGER join output, which a lazy plan is free to change by re-ordering or re-siding joins; measured with an ungated variant of the same plan over 4 graph sizes × 4 seeds × 4 order-undetermined shapes, 47 of 64 comparisons diverged from the eager twin, and under `LIMIT` the divergence is a different ROW SET rather than a different row order. Not a GPU change: like the eager code and the fused two-star lane, it collects on CPU polars for both `polars` and `polars-gpu`. **Measured** on dgx-spark, matched graph-benchmark q1–q9 lane, one perf lock per experiment, master tree vs PR tree position-balanced `M P P M P M M P`, per-slot medians (never best-of), rows and canonical values compared on every cell, and replicated end to end in a second independent run: **`engine='polars'` q1 `13.31 → 8.96 ms` (−32.7%), q3 `8.54 → 5.53 ms` (−35.3%), q4 `6.99 → 5.05 ms` (−27.8%) at 20k**, per-slot ranges non-overlapping on all three in both runs; at 100k q1 `42.59 → 31.45 ms` (−26.2%) and q4 `12.19 → 10.43 ms` (−14.4%), with q3 `−9.1%…−13.0%` (non-overlapping in one run, overlapping in the other). Against same-session embedded Kuzu at 20k that widens q1 from a 1.12× win to **1.66×**, moves q3 from a **1.37× LOSS to a TIE** (Kuzu 6.29 ms; the two slot ranges overlap, so it is a tie and not a win), and narrows q4 from a 2.10× loss to **1.51× — still a loss**. The cells this lane is not called for are unchanged: q5 and q8 are ties at both scales in both runs. **Correction (#1825):** the claim originally made here that q8 *"stays a win over Kuzu"* does not survive re-measurement and is withdrawn. That cell's headline number is a CROSS-CALL MEMOIZATION artifact — the two-hop equal-domain degree counts are cached onto the caller's `Plottable` keyed by `id()`, so a warm repeat call is fast (2.02 ms @20k / 5.09 @100k) while a cold or never-warmed one is not (13.18 / 49.68 and 14.60 / 53.02 fresh), with a `bind_only` control at 0.02–0.04 ms ruling out re-binding as the confound. A ONE-SHOT q8 loses to embedded Kuzu by 3.1–5.2× at 20k and 2.8–6.0× at 100k. The measurement this entry actually reports — that this lane leaves q8 unchanged — is unaffected by the correction. The pandas arm — untouched by this change — reproduces the reference board to +0.8%…+9.4% and same-session Kuzu reproduces it to −2.2%…+1.5% on the cells at issue, which is what shows the harness matches it. Value identity is the gate throughout: a differential over 19 shapes × 10 graphs, compared ROW-ORDER and COLUMN-ORDER sensitively against both the eager code and the pandas oracle, found zero divergences from the eager code; pinned tests cover multiplicity on BOTH arms of the hop (duplicate node rows, parallel edges, self-loops), null placement on group keys and on aggregate values, empty matches, dangling endpoints, non-numeric ids, degenerate column bindings, and every decline. One PRE-EXISTING divergence is disclosed rather than quietly changed: the polars property lookup is not deduplicated by node id while the pandas one is, so a node table carrying the same id twice multiplies matched rows on polars only — the fused lane reproduces the eager polars answer exactly, and a test pins both sides.
18
18
- **Native polars chain combine is proportional to the traversal result, not to the graph**: two graph-sized terms sat inside a combine whose answer is a handful of rows, and both are gone. (1) `_combine_edges` ran the prev/next endpoint gates for EVERY step, including the node steps whose edge frame is `g._edges.clear()` — zero rows. The eager combine skipped those, but the collect-once rewrite lazified the step frames and `.lazy()` erases the height, so the skip silently went dead. The cost lands on the side that is NOT empty: for the first step the gate's key side is the whole node table, and polars builds the hash table on that side before discovering the probe side has no rows (isolated: 6.99 ms for one such join at N=2M, and a chain pays one per node step). The pre-lazy row count is now recorded when the step frame is still eager and an empty step is dropped from the id union — it can contribute no ids, so the result is unchanged by construction. The skip keys on KNOWN-empty only; a frame that arrives already lazy reports no height and is planned normally. (2) The output node rows were materialized in TWO passes over the node table — one for the ids the steps kept, one more for the surviving edges' endpoints the first pass missed — then concatenated. The output node set is the UNION of those two id sides, so the ids are unioned first and the node table is read ONCE. The row-level `unique(subset=[node])` is preserved verbatim: those rows feed `how="left"` alias joins where a node table carrying the same id twice would multiply rows. Measured on synthetic LDBC-IS5-shaped graphs (one-row answer, polars-engine resident indexes), varying one dimension at a time: **at fixed E=2M, N=250k → 4M went 10.17 → 45.48 ms before and 7.60 → 17.17 ms after (2.65× at 4M, and the node-count slope is 3.7× flatter)**; the edge-count slope is unchanged, as expected for a node-side fix, with the constant ~6 ms lower. Parity: identical full frames (all columns, row order included) across 280 shape × graph combinations and 400 duplicate-node-id combinations, plus the 1003-case polars chain differential suite. Pinned by tests that assert the boundary rather than a wall clock: the node universe must not appear in the edge plan at all, and the node table must be read at most once per query.
19
19
- **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, the single-hop planner's id frames, and the index layer's `select_by_ids` polars branch — where the cuDF and pandas branches already used `isin` with no dedup, so the three engines now agree). 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.
20
20
- **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.
0 commit comments