From 236a7aa76b859eedd446e7ccee8f4958dfb56c5b Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Tue, 28 Jul 2026 03:21:27 -0700 Subject: [PATCH 1/3] docs(changelog): withdraw the "q8 stays a win over Kuzu" claim (#1825) #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 #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 #1823 actually reports -- that its lane leaves q8 unchanged -- is explicitly preserved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3b2dafe0..269e8af239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **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 -- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN . AS k, 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. +- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN . AS k, 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. - **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. - **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. - **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. From b8e713cc20950a9021ef17e00ed5b1c3be176d09 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Tue, 28 Jul 2026 09:23:22 -0700 Subject: [PATCH 2/3] fix(ci): restore executable bit on bin/test-polars.sh The stacking upload rewrote the tree entry as mode 100644, so the test-polars lanes died with 'Permission denied' (exit 126) before running a single test. --- bin/test-polars.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bin/test-polars.sh diff --git a/bin/test-polars.sh b/bin/test-polars.sh old mode 100644 new mode 100755 From 8fc4afb304a3947d7a9db8aa5fd2952035112a04 Mon Sep 17 00:00:00 2001 From: lmeyerov Date: Tue, 28 Jul 2026 09:33:02 -0700 Subject: [PATCH 3/3] docs: delete the q8 ratio cells instead of footnoting them The data contract refuses a unit=x cell with comparison_allowed=false -- "a ratio over figures never established as comparable" -- and it is right to. Marking the q8 polars-vs-kuzu ratio non-quotable left the number sitting in the published data for someone to quote. Deleted instead. The raw per-engine q8 figures stay, caveated and non-comparable. Also withdraws the matching prose: gfql/performance.rst still asserted "GFQL wins q8", which is the exact claim this PR exists to retract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YYZRXegrALuXd3NHH5evqx --- CHANGELOG.md | 2 +- docs/source/_data/gfql_benchmarks.json | 30 -------------------------- docs/source/gfql/performance.rst | 11 ++++++++-- 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f2b7644c..545731956f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Performance - **The polars two-hop `count(*)` builds ONE lazy plan instead of five eager collects**: `MATCH (a {..})-[{..}]->(b {..})-[{..}]->(c {..}) [WHERE ..] RETURN count(*)` lowers to a fast path that computes the answer as a degree product — semi-join each edge arm against its two node domains, count in/out degree per middle node, sum `in*out`. Every one of those ops was issued EAGERLY, i.e. as its own `lazy().collect()`, so each intermediate materialized in full and with every edge column still attached, and no filter or projection could be pushed across an op boundary: profiled on the 20k graph-benchmark dataset the four-semi-join chain built a **199,939-row** wide frame that the degree join immediately reduced to 120,586, and 88–96% of the query's wall time sat inside those collects. The DISTINCT-domain case — the three node domains and/or the two edge matches are not all equal, which is what a `WHERE` on the middle or end node produces — is now expressed as a single lazy plan collected once, so polars pushes the src/dst projection into the semi-joins. The algebra is unchanged (same semi-joins, same `group_by().len()`, same `(in*out).sum().fill_null(0).cast(Int64)`), so the value is identical including openCypher's count-over-no-rows `0` on an empty match. The EQUAL-domain case is deliberately NOT routed through it: that shape takes a degree-count branch whose counts are memoized on the Plottable across calls, and fusing it would trade a cross-call cache hit for a per-call replan — it is byte-identical to before, and a test asserts the fused lane is not even *called* for it. The lane DECLINES (falling through to the untouched eager code, never answering differently) for non-eager-polars frames and for an edge column already carrying a degree-counter name. 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 cross-engine graph-benchmark lane (all nine OLAP queries, same query text per engine), one perf lock per scale, position-balanced `K M S S M K` slots ×2, per-slot medians (never best-of), rows and canonical values compared on every cell: **`engine='polars'` distinct-domain two-hop `count(*)` `16.91 → 10.02 ms` at 20k (−40.7%) and `68.04 → 37.96 ms` at 100k (−44.2%)**, per-slot ranges non-overlapping at both scales (20k 15.90–18.05 vs 9.23–10.29; 100k 65.9–69.7 vs 36.5–39.3). Against same-session embedded Kuzu that turns the 20k cell from a 1.55× LOSS into a **1.09× WIN** (Kuzu 10.90 ms, ranges non-overlapping) and widens the 100k cell from a 1.25× win to **2.23×** (Kuzu 84.73 ms). The equal-domain cell is UNCHANGED by this PR at both scales (20k 2.44 → 2.30 ms, 100k 5.46 → 5.62 ms — ranges overlap = TIE, which is the point of the measurement). That cell's headline number is NOT one-shot-honest and this changelog does not claim it as a win: the degree counts it reports are memoized across calls onto the caller's `Plottable` (#1825), so a warm repeat call is fast while a one-shot query loses to embedded Kuzu; this PR neither causes nor fixes #1825. What this PR does do for that shape is make its MEMO-MISS branch — the branch a one-shot query, a rebound `Plottable`, or any future removal of the cross-call memo actually runs — build the two degree frames as ONE lazy plan (`collect_all` over a shared filtered-edge sub-plan) instead of materializing the whole filtered edge frame and grouping it twice eagerly. Same algebra, same values; the memo HIT returns before reaching it, so a warm call is untouched. **Measured** the same way (dgx-spark, perf lock, position-balanced `A B B A B A A B` slots, per-slot medians, 21 runs + 5 warmup per slot, value- and row-identical across arms): equal-domain memo-MISS **`10.94 → 8.25 ms` at 20k (−24.5%, ranges 10.24–11.10 vs 7.65–8.71, non-overlapping)** and **`48.27 → 33.13 ms` at 100k (−31.4%, 47.65–48.89 vs 32.68–33.66, non-overlapping)**; the same query issued ONE-SHOT on a fresh `Plottable` goes `12.19 → 9.18 ms` at 20k and `49.67 → 34.62 ms` at 100k. The memo-HIT cell (20k 1.84 → 2.14 ms, 100k 5.41 → 5.84 ms), the distinct-domain cell, and a `bind_only` control all come back as TIEs, which is what shows the change is confined to the miss branch. Every other cell on both engines is a TIE. The pandas arm — untouched by this change — reproduces the reference board to +0.3%…+4.1%, which is what shows the harness matches it. Value identity is the gate throughout: a differential over 972 query shapes × 6 graphs (5,832 comparisons, 4,815 with the fused lane engaged) found zero divergences from the eager code or from the pandas oracle, and pinned tests cover multiplicity (parallel edges, self-loops, duplicate node rows), empty matches, non-numeric ids, degenerate column bindings, and both declines. - **A single-key pure `count(*)` with provably LOW group cardinality skips polars' partitioned group-by**: inside the fused single-hop grouped-aggregate lane, `group_by(maintain_order=True).agg(pl.len())` carries a FLAT ~2 ms coordination cost that exists only at low group cardinality — measured on dgx-spark (polars 1.35.2, 20 threads, interleaved, 90 samples/arm/cell, 214 cells, ZERO value mismatches), int keys at 20,000 rows go 32 groups `2.054 ms` → 48 groups `0.411` → 64 groups `0.291`. `value_counts` has no such cost, so for a pure `count(*)` it is the same value for a fraction of the time. It is NOT a drop-in: `value_counts` scales WORSE with input rows and at 1,000,000 rows loses even at 2 groups (`4.137 → 8.591 ms`), and applied ungated the identical formulation makes the matched graph-benchmark **q1** cell (~20,000 groups over ~200,000 rows) **2.7 ms slower at 20k and 8.6 ms slower at 100k** — q1 is a cell that currently wins, so an ungated swap trades one cell's loss for another's regression. The formulation is therefore chosen only behind two STATIC, O(1), **upper** bounds: group cardinality ≤ the HEIGHT of the alias node frame supplying the group key (every group value is a property value of some row of that one frame, so distinct values cannot exceed its height), and aggregate input rows ≤ the height of the already-filtered EDGE frame (the semi-joins only remove rows; the property inner-join can multiply them, so the row bound is only claimed once exactly one alias carries properties and its node ids are unique — a check that runs on a frame already known to be ≤ 32 rows). Both bounds over-estimate, and over-estimating is the safe direction: a loose bound can only DECLINE a shape the fast formulation would have served, never route a high-cardinality aggregate into it. The thresholds — **32 groups and 100,000 rows** — were fixed from the crossover curve BEFORE the formulation was validated on any query, because a threshold chosen after seeing the verdicts is unfalsifiable; 48 groups already fails at 0.96× and 150,000 rows at 0.80× on string keys. Strictly additive: every decline falls through to the untouched `group_by`, so the blast radius is a decline away from zero. It DECLINES a non-single-key or non-pure-`count(*)` aggregate (including `count()`, which counts non-null values rather than rows), a group key not supplied by exactly one alias, a second alias also contributing property columns, a group-key alias frame that is too tall / missing its node-id column / carrying duplicate node ids, and an edge frame over the row bound. **Measured** on dgx-spark under the exclusive perf lock, matched graph-benchmark q1–q9 lane on the canonical query text (`gb_queries.py`, md5 `6e7ae268a5a41742587fcb87854b6e27`), 24 position-balanced slots per scale (12 per arm), Kuzu 0.11.3 re-run in-session, per-slot medians: **q4 at 20k `4.96 → 3.73 ms` (−1.23 ms, −24.7%), the two arms' slot ranges NOT overlapping**, which takes the board's last 20k loss from `1.65×` to `1.25×` of same-session Kuzu. Under the board's overlap rule that scores a TIE rather than a loss, but the overlap is **0.015 ms** and the median still favours Kuzu, so it is reported as *a loss narrowed to near-parity, not parity*. Engagement is exactly one cell: on the real board data the gate is consulted for q1/q2/q3/q4 and **admits only q4 at 20k** — q4 at 100k declines because its 7,117-row City frame carries only 3 distinct countries and an O(1) height bound cannot see the 3, and q1/q2 decline on BOTH bounds at both scales. q1, q2, q3 and q8 are arm-vs-arm ties with overlapping ranges, and the pandas arm — which never enters this polars-only lane — ties on all nine cells at both scales, a built-in null control. Value identity is the gate on the number: one canonical value per query across every slot, both arms, both engines and both scales, matching Kuzu on every cell. -- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN . AS k, 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. +- **The polars single-hop GROUPED AGGREGATE builds ONE lazy plan instead of ~7 eager collects**: `MATCH (a {..})-[{..}]->(b {..}) [WHERE ..] RETURN . AS k, 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 withdrawal is carried out in the published data rather than footnoted onto it: the two `graphbench.{20k,100k}.q8.polars_vs_kuzu` ratio cells are DELETED (the docs data contract refuses a ratio whose operands are not established as comparable, which is the right rule — a caveated ratio is still a ratio someone will quote), while the raw per-engine q8 figures remain as non-quotable, non-comparable cells carrying the disclosure. `gfql/performance.rst` no longer reads "GFQL wins q8". 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. - **Plan-time constant folding for GFQL row expressions, and one canonical residual shape (#1800)**: the Cypher lowering serializes every row predicate it cannot push into `filter_dict` back to canonical predicate *text*, and both the row evaluators and the connected-join fast-path residual translator consume that text. There was no constant folding, so `toLower(i.interest) = toLower('Fine Dining')` and `toLower(i.interest) = 'fine dining'` reached those consumers as two different strings for the same predicate — and the fast-path translator recognized only the first. Since a *single* untranslatable residual declines the **entire** fused single-collect two-star plan, the second (equally idiomatic, and the spelling the graph-benchmark suite uses) dropped the query onto the eager per-op-collect path and onto the `where_rows` chain evaluator for every alias. A new pass (`graphistry/compute/gfql/expr_const_fold.py`) now evaluates pure, deterministic, literal-only sub-expressions at plan time, bottom-up, so the two spellings collapse into one and the translator learns a single shape; the residual matcher is correspondingly *narrower* than before, and covers `toLower`/`lower`/`toUpper`/`upper` uniformly rather than `toLower` alone. Folding is gated by a stated criterion, not a list: a call folds only if it is (P) pure and deterministic, (A) argument-closed after bottom-up folding, (E) **engine-invariant on those argument values**, and (T) total on them. (E) is load-bearing rather than ceremonial — pandas>=3 defaults to an Arrow-backed `str` dtype whose `utf8_lower`/`utf8_upper` are SIMPLE per-codepoint case mappings where polars' and Python's are FULL ones (#1802), so `toUpper(n.name) = 'STRASSE'` already answers differently on the two engines — and the region where Python, Rust (polars), Arrow (pandas>=3), libcudf and Java (the Cypher reference) provably agree is ASCII. **String folds therefore require ASCII arguments and decline otherwise**, which is a deliberate narrowing: the previous two-sided fast path compared a polars-lowercased COLUMN against a Python-lowercased LITERAL, an unproven Rust-vs-Python case-table assumption that a non-ASCII literal now declines out of instead of guessing at. Nine functions fold: `toLower`/`lower`/`toUpper`/`upper`/`size`/`substring`, plus `head`/`tail`/`reverse` — which on GFQL's surface are STRING operations (`row/dispatch.py` implements them as `.str.get(0)` / `.str.slice(start=1)` / `.str[::-1]`, and `eval_sequence_fn_scalar` checks `isinstance(value, str)` FIRST for `reverse`) and therefore fold under the same ASCII gate as the case folds; their LIST overloads parse to a `ListLiteral` argument, which the per-call argument guard already declines on its own. **Every decline is now filed under the MECHANISM that stops it, and every mechanism carries a witness a test executes**, to a stated bar: a disqualifier with no constructible violating expression is a guess, not a criterion, and the function either folds or its reason is restated as POLICY rather than CORRECTNESS. Three mechanisms have witnesses. (1) The **aggregates** — the only genuinely load-bearing deny-set here, because `count(1)` is argument-closed and `int`-valued and would sail through every structural guard the driver has; the witness is that the same call answers `1` over a one-row match and `12` over a twelve-row one, so folding it to a literal would be wrong. (2) The functions that are **not argument-closed in the shape the lowering emits** — `keys(n)`, `labels(n)`, `type(e)`, the internal `__node_entity__(a)` markers, and the quantifiers, which parse to a `QuantifierExpr` and so never reach the name lookup at all; the witness is the parsed node. (3) The functions whose literal-only call the engine answers with a **type the driver's contract guard rejects**: `sqrt`/`floor`/`ceil`/`ceiling`/`round`/`toFloat` return `float`, `toBoolean` returns `bool`, `range` returns a `list`, and the internal simple-CASE marker returns `bool` — so even a perfect folder could not fold them, and the witness is the engine's own value. (That is also where the `round` entry gets put in proportion: its neo4j-tie / JDK-6430675 reasoning is real and lives in the row kernel's docstring, but it does no work in this pass, because `round(1.5)` is `2.0` and the guard rejects it before any tie rule can matter.) Two groups have **no witness and now say so** rather than dressing a preference up as a correctness claim: `abs`/`sign`/`coalesce` are declined **by POLICY** — their literal-only value is guard-passing and identical to a plain Python fold, so folding could not change an answer, and the perf gain on literal-only arithmetic is nil — and `toString`/`toInteger` are labelled **UNVERIFIED**, because the claimed cuDF-vs-pandas float→string divergence could not be reproduced on any engine a CI lane here runs; a test asserts agreement on every engine it CAN reach, so a GPU lane would either turn that claim into a real witness or expose these as policy declines too. This replaces a 40-entry free-text reason table that nothing read — `fold_constants`' only name-keyed gate is the registry lookup, so the table could be neither right nor wrong — and three of its entries (`head`/`tail`/`reverse`) were in fact misclassified, citing an argument-closure reason that describes only their list overload. The classification is still enforced as a **partition of GFQL's entire Cypher function surface** by a test, so a newly added function cannot silently default to any side, and a folder that raises is treated exactly as a decline — a fold can never turn a runtime error into a plan-time crash. Each foldable entry carries three tests: the literal-only call folds to a PINNED literal, the same function with a non-literal argument comes back untouched, and — the one that actually protects the change — the folded and unfolded plans answer identically on the same data, per engine. One asymmetry is disclosed rather than smoothed over: on polars the UNFOLDED spelling of `head`/`tail`/`reverse` over a literal has no native row-op lowering and raises, while the folded spelling is a plain literal comparison it runs natively, so on that engine the pass WIDENS native coverage rather than merely renaming a predicate. A `$param` is substituted before the pass runs, so a parameterized `toLower($p)` canonicalizes exactly like a written literal and reaches the same fast lane; that is safe because the compiled-plan cache already keys on the parameters as well as the query text, and a test pins it end to end across engines. Measured on the graph-benchmark board's own query text (dgx-spark, polars, perf lock held, Kuzu 0.11.3 re-run in the same interleaved session): see the PR for the per-slot numbers. - **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. - **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. diff --git a/docs/source/_data/gfql_benchmarks.json b/docs/source/_data/gfql_benchmarks.json index 729bfc466d..b97b146f14 100644 --- a/docs/source/_data/gfql_benchmarks.json +++ b/docs/source/_data/gfql_benchmarks.json @@ -593,21 +593,6 @@ "value": 5.06, "workload": "graph-benchmark 100k q8" }, - "graphbench.100k.q8.polars_vs_kuzu": { - "board_quotable": false, - "comparison_allowed": false, - "competitor": "kuzu", - "decimals": 2, - "disclosures": [ - "q8's polars figure is a WARM cross-call memo hit, not a one-shot measurement: the two-hop equal-domain degree counts are cached onto the caller's Plottable keyed by id() (#1825). Cold/fresh arms measure 13.18/14.60 ms at 20k and 49.68/53.02 ms at 100k against the warm 2.02/5.09 ms quoted here, with a bind_only control at 0.02-0.04 ms ruling out re-binding. A one-shot q8 LOSES to embedded Kuzu by 3.1-5.2x at 20k and 2.8-6.0x at 100k. The memo affects all engines. Withdrawn from the board pending a regenerated run (#1827)." - ], - "engine": "polars", - "run": "graphbench-q1q9-100k-20260726", - "status": "ok", - "unit": "x", - "value": 1.93, - "workload": "graph-benchmark 100k q8 \u2014 GFQL-polars vs kuzu" - }, "graphbench.100k.q8.polars_vs_pandas": { "board_quotable": true, "comparison_allowed": true, @@ -1290,21 +1275,6 @@ "value": 2.07, "workload": "graph-benchmark 20k q8" }, - "graphbench.20k.q8.polars_vs_kuzu": { - "board_quotable": false, - "comparison_allowed": false, - "competitor": "kuzu", - "decimals": 2, - "disclosures": [ - "q8's polars figure is a WARM cross-call memo hit, not a one-shot measurement: the two-hop equal-domain degree counts are cached onto the caller's Plottable keyed by id() (#1825). Cold/fresh arms measure 13.18/14.60 ms at 20k and 49.68/53.02 ms at 100k against the warm 2.02/5.09 ms quoted here, with a bind_only control at 0.02-0.04 ms ruling out re-binding. A one-shot q8 LOSES to embedded Kuzu by 3.1-5.2x at 20k and 2.8-6.0x at 100k. The memo affects all engines. Withdrawn from the board pending a regenerated run (#1827)." - ], - "engine": "polars", - "run": "graphbench-q1q9-20k-20260726", - "status": "ok", - "unit": "x", - "value": 1.22, - "workload": "graph-benchmark 20k q8 \u2014 GFQL-polars vs kuzu" - }, "graphbench.20k.q8.polars_vs_pandas": { "board_quotable": true, "comparison_allowed": true, diff --git a/docs/source/gfql/performance.rst b/docs/source/gfql/performance.rst index 069d339c3e..e981ca94fb 100644 --- a/docs/source/gfql/performance.rst +++ b/docs/source/gfql/performance.rst @@ -138,8 +138,15 @@ OLAP multi-join On the graph-benchmark OLAP multi-join queries at 100k-node scale (100,000 persons / 2.4M ``FOLLOWS``) with ``engine='polars'``, against embedded Kuzu 0.11.3 on the same host -and in the same session: GFQL wins q8, and **q9 runs in 66.6 ms vs 84.1 ms (1.26×)**. Both -sides run the same canonical query text and return identical values. +and in the same session, **q9 runs in 66.6 ms vs 84.1 ms (1.26×)**. Both sides run the same +canonical query text and return identical values. + +The q8 comparison is **withdrawn**, not merely caveated. The figure it rested on was a warm +cross-call memo hit rather than a one-shot measurement: the two-hop degree counts are cached +onto the caller's graph object (#1825), so a cold run measures 13.2–14.6 ms at 20k and +49.7–53.0 ms at 100k against the 2.0/5.1 ms once quoted. Measured one-shot, q8 **loses** to +embedded Kuzu. The ratio is gone from the published data rather than footnoted, and the +comparison returns only once a regenerated run (#1827) supports it. When not to use GFQL ~~~~~~~~~~~~~~~~~~~~