Skip to content

perf(gfql): serve NAMED patterns on the NATIVE chain fast path (~11x traversal / ~2.3x with rows) — no benchmark cell moves - #1796

Draft
lmeyerov wants to merge 1 commit into
masterfrom
perf/gfql-fastpath-named-aliases
Draft

perf(gfql): serve NAMED patterns on the NATIVE chain fast path (~11x traversal / ~2.3x with rows) — no benchmark cell moves#1796
lmeyerov wants to merge 1 commit into
masterfrom
perf/gfql-fastpath-named-aliases

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

DRAFT. A capability + cleanliness change with a large, re-measured win on the native g.gfql([...named...]) chain surface. It moves no benchmark cell and has no demonstrated effect on the Cypher surface — see "What this does NOT do", which is the most important section here.

Correction: this PR's original premise was wrong

The earlier description said "every Cypher multi-alias MATCH ... RETURN names its ops, so the entire Cypher surface is locked out of the sub-millisecond path", and used that to claim this was the lever for the lost graph-benchmark q3–q7 cells. An instrumented measurement of the q1–q9 lane at 20k, both engines, both arms, falsifies that, for three independent reasons, any one of which is fatal:

  1. Wrong branch. Every consult of _try_chain_fast_path on that lane is ops=1 (q5/q6/q7). This PR only edits the len(ops)==3 branch. The ops==1 gate is byte-identical between arms.
  2. Wrong layer. g.gfql(<cypher>) does not reach the chain fast path for these shapes at all: q1/q3/q4 are served by _execute_single_hop_grouped_aggregate_fast_path, q8/q9 by _execute_two_hop_count_fast_path, q2 additionally by _execute_seeded_typed_hop_fast_path — all in gfql_fast_paths.py, all returning before _chain_dispatch. That is where the 5–10 ms per-query floor actually lives.
  3. Wrong column. This fast path is pandas/cuDF only; the losing benchmark column is polars.

Net: 0 consults for q1/q2/q3/q4/q8/q9 pandas, 0 consults for all of q1–q9 polars, and only ops=1 declines for q5/q6/q7 — identical counts and sites on both arms. The change cannot move those cells and never could.

The change itself is real and live, which is a different failure mode from being inert. Positive control, same instrumentation, native 3-op named chain:

arm A (origin/master)  C1 named 3-op -> CALL:fastpath_consulted ops=3 -> DECLINE:L856
arm B (this branch)    C1 named 3-op -> CALL:fastpath_consulted ops=3 -> SERVE:served_seeded_typed_hop

L856 is exactly the gate this PR removes. Written as the benchmark lane's Cypher (MATCH (p {...})-[{...}]->(c {...}) RETURN c.city, count(p)), the same logical pattern produced zero instrumentation events on both arms. The feature works; it is simply unreachable from the surface the benchmark uses.

What the change is

_try_chain_fast_path rejected any op carrying an alias, via three gate lines. Naming is a projection concern, not a traversal one, so it should not decide which engine path runs. The alias flag columns the full path's combine_steps merges in are now reconstructed from the edges the fast path already returns (_tag_fast_path_aliases): combine_steps tags a node with an alias iff it still participates in a surviving edge, and those edges are exactly the surviving ones, so isin over their endpoint columns is the same predicate computed without the join. A seed whose edges all fail the filter yields an empty edge frame and is tagged False — the dead-end case, and why the tag keys on edges rather than on the node filter.

Declines kept deliberately:

  • undirected + named — an undirected edge makes a node reachable as either endpoint, so alias identity is not derivable from the endpoint columns.
  • duplicate alias names — E201 is raised by combine_steps; serving here would bypass the check and let alias reuse silently succeed.
  • a resident index that would actually serve the shape — via _resident_seed_indexes (index validity for these exact frames, not mere registry presence). A registry-presence spelling declines on every query and turns the whole optimization into a measured no-op.

Re-measured win (this head, rebased on 233b64c8)

200-node / 800-edge pandas graph, where data-proportional work is ~0, so this is the per-query plan floor. Five alternating paired runs of origin/master and this branch, each value the median of 200 reps after 20 warmups. The range across the five runs is given because the run-to-run spread is real and a single pair would overstate the precision:

native surface master this branch
g.gfql([n(name='x'), e_forward(name='r'), n(name='y')]) 25.2 ms (24.2-27.0) 2.3 ms (2.1-2.7) ~11x
the same + rows(binding_ops=...) 40.4 ms (38.1-43.6) 17.4 ms (16.8-19.8) ~2.3x

Re-measured deliberately alongside the suite rather than quoted from the earlier run: a lever can go inert while the regression count still looks right.

Values: the rows() output is byte-identical between arms (md5 over the canonicalized frame, dtypes included). The traversal edge frame is byte-identical. The traversal node frame is value-identical, with dtypes deliberately changed — see below.

Node-ladder shape check (fixed 200k edges, fixed seed degree, fixed 33-node/32-edge answer, 100k → 800k nodes): master 41.20 → 63.73 ms, this branch 3.76 → 11.09 ms. The win is a large constant, not the removal of a per-node term — which determines how the follow-up lane has to be written.

What this does NOT do

  • It does not move any benchmark cell. No pyg-bench lane exercises the native named-chain surface today.
  • It does not change the Cypher MATCH ... RETURN surface's routing for the measured shapes.
  • Under the standing rule that perf work ships with a pyg-bench proof plus a regression lock-in, this PR does not carry a perf claim on the board. It is a capability/cleanliness change with a disclosed microbenchmark; the lane is specified as follow-up below.

Follow-up: the pyg-bench lock-in this needs

Concrete and measured, not run here (the perf lock is held elsewhere). Target: pyg-bench configs/suites/gfql-perf-lockin-edge-ladder.yaml, whose chain-seeded-typed-hop workload is already exactly this shape — a named 3-op seeded typed hop — but declared engines: [polars].

  • A scaling ladder is the WRONG form here, and that is measured, not assumed. On the node ladder above, master reports 1.55× growth and this branch 2.95× — the fixed build looks worse, because its constant shrank so the residual O(N) term dominates the ratio. A max_growth_ratio probe would gate the wrong direction. The lane's existing timing probes all pin asymptotic terms; this win is not one, and the lane should say so.
  • PRIMARY probe: a structural count — the form the lane already uses for polars-edge-order-materializations and polars-chain-semi-joins, both chosen precisely because their win sits inside host spread. Add chain-fast-path-named-serves: run the existing chain-seeded-typed-hop query on engines: [pandas] and count non-None returns of _try_chain_fast_path, min_observed: 1, max_observed: 1. 0 means the named gate came back (or the shape stopped routing here) → invalid/regression, never pass. This is the lock-in, and it fails mechanically the day the win rots — the structural fix for the scar where a lever's win had silently gone to zero.
  • SECONDARY, evidence not gate: report the fixed-rung median next to rows_returned on both arms. The delta at the 100k-node rung is 41.2 → 3.8 ms, an order of magnitude above this class of host's 5–10% spread, so an absolute max_median_ms gate is defensible here — but per the lane's own rule it must be measured against both builds before being written down.
  • Validate the gate fails on the parent, as every other probe in that lane was.

Test changes: 4 deliberate rewrites, not fixes

The earlier description said "4 regressions" and CI showed 3. The discrepancy is now settled rather than restated:

  • test_pandas_int_bool_dtype_parity — the claimed 4th — passes. It pins the Cypher-layer seeded projection, which this PR does not touch.
  • test_engine_mismatch_declines does fail, and is the real 4th. It never ran in this PR's CI because it is pytest.importorskip("polars")-gated and the test-polars lane was SKIPPED. So: not cuDF/GPU-only, and it did exist at that head — CI simply could not see it. Locally with polars 1.42 the count is 4.

All four are rewritten deliberately, each with a comment stating the rule it now encodes and why the old one was an artifact:

  1. test_fast_path_gating_returns_none_for_ineligiblenamed_node moves from ineligible to eligible (that assertion encoded the gate, not a semantic the fast path could not meet). New declines added: named+undirected, duplicate aliases.
  2. test_fast_path_differential_parity_vs_full_pathnamed_node moves from _BYPASS_SHAPES to _FAST_SHAPES, six named shapes are added, and named_undirected is added to the bypass list.
  3. test_pandas_datetime_property_declines and 4. test_engine_mismatch_declines — the Cypher-layer decline is still asserted (that is what these tests exist for). Their assert_frame_equal incidentally locked the pandas merge upcast; values are still asserted identical and the dtype rule is now asserted explicitly on both sides.

New coverage for the new capability, which this PR previously had none of: test_fast_path_named_alias_columns_match_full_path — 9 named shapes × 2 engines, comparing the actual alias flag columns and per-row values fast-vs-full. The existing differential compares node/edge id sets only, so it cannot see a wrong alias tag. Serve-asserted, and it covers the dead-end case.

The dtype question is settled — ADOPT, verified rather than asserted

The pandas full path upcasts non-id int64 → float64 and bool → object through its rows-pivot merges. That is a pandas merge artifact, not a Cypher semantic. Verified before relying on it:

  1. openCypher TCK, tck/features/clauses/return/Return2.feature scenario [2] "Returning a node property value": CREATE ({num: 1}) / MATCH (a) RETURN a.num expects 1. The TCK value grammar (tck/README.adoc) writes an Integer as "a simple string of decimal digits" and a Float "in decimal form with all present decimals", so 1.0 would be a different expected value.
  2. Neo4j (reference engine) Cypher manual: INTEGER and FLOAT are distinct property types, and an integer property read back reports valueType(n.prop) = "INTEGER NOT NULL".
  3. This library's own other engines already agree. Measured on the exact query in the failing tests, the polars full path returns int64/bool. And cuDF's canonical pivot preserves source dtypes too — there is a Fixed entry in this same Development CHANGELOG section that exists specifically because a fast path was wrongly applying the pandas artifact on cuDF. Only the pandas merge upcasts, which is what an artifact looks like and what a semantic does not.

So int64/bool is the conformant result. The two failing "contract" tests encode the artifact, not a guarantee: they are decline tests whose subject is "the Cypher fast path declines and the answer is still right", and their frame comparison locked the dtype incidentally. The review that introduced that test class (plans/review-pr-1766/review.md, finding B1) explicitly listed "the full path's upcast is declared the bug and fixed" as one of three acceptable dispositions — it was never asserted as intended behaviour.

  • Not implemented: the targeted per-column cast. It would be work spent preserving a defect.
  • Not retried: the blanket cast. Already tried; took the suite 20 → 45 failures, because the upcast is a per-column artifact of which merges a column passed through, not a uniform rule.
  • Not used: check_dtype=False. That class exists to pin dtypes.

User-visible surface, stated plainly: where a named pandas pattern is served by this fast path, an integer node property returns int64 rather than float64 (30, not 30.0, including in JSON) and an alias flag returns bool rather than object. Values are unchanged. Known residual inconsistency, deliberately out of scope: the pandas full path and the Cypher-layer seeded projection still emit the artifact, so pandas can still report float64 for the same logical query depending on which internal path serves it. Aligning those is follow-up, not this PR.

Where the code lives

_tag_fast_path_aliases had been added to graphistry/compute/chain.py — a file that was being drained. It now lives in graphistry/compute/chain_fast_paths.py, which is the pre-existing direction, confirmed rather than recalled:

The move is mechanical — the body is unchanged apart from granular type annotations — so a rebase against concurrent typing work in that module is trivial. The module docstring now records the rule explicitly, so the next helper does not land in chain.py again.

Also in this PR

  • CHANGELOG entries added (there were none): one under Performance for the fast path, with the scope limits stated in the entry itself; one under Changed for the dtype adoption.
  • Granular typing on everything added or edited; the getattr(op, "_name", None) in the test helper is replaced by a typed signature and a direct attribute read.
  • Rebased from the stale d9fa0a95 onto 233b64c8.

🤖 Generated with Claude Code

https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB

@lmeyerov
lmeyerov force-pushed the perf/gfql-fastpath-named-aliases branch from e8ce879 to 290e5fb Compare July 27, 2026 22:37
@lmeyerov lmeyerov changed the title perf(gfql): serve NAMED patterns on the chain fast path — 2.25x, one product decision open perf(gfql): serve NAMED patterns on the NATIVE chain fast path (11.6x traversal / 2.27x with rows) — no benchmark cell moves Jul 27, 2026
@lmeyerov lmeyerov changed the title perf(gfql): serve NAMED patterns on the NATIVE chain fast path (11.6x traversal / 2.27x with rows) — no benchmark cell moves perf(gfql): serve NAMED patterns on the NATIVE chain fast path (~11x traversal / ~2.3x with rows) — no benchmark cell moves Jul 27, 2026
@lmeyerov
lmeyerov force-pushed the perf/gfql-fastpath-named-aliases branch from 290e5fb to f0aaf44 Compare July 27, 2026 22:48
`_try_chain_fast_path` rejected ANY op carrying an alias, so a named
`g.gfql([n(name='x'), e_forward(name='r'), n(name='y')])` fell to the full
two-pass BFS purely because the ops were named. Naming is a PROJECTION concern,
not a traversal one; it should not decide which engine path runs.

RE-MEASURED on a 200-node / 800-edge pandas graph (data work ~0, so this is the
per-query plan floor). Five ALTERNATING PAIRED runs vs origin/master 233b64c,
each value the median of 200 reps after 20 warmups (range over the five runs in
parentheses -- the run-to-run spread is real):
  named 3-op traversal      25.2 ms (24.2-27.0) -> 2.3 ms (2.1-2.7)    ~11x
  the same + rows(binding_ops)  40.4 (38.1-43.6) -> 17.4 (16.8-19.8)   ~2.3x
The rows() output is byte-identical between arms; the traversal output is
value-identical (dtypes deliberately changed, below).

SCOPE, AND THE EARLIER CLAIM WAS WRONG. The previous description said "every
Cypher multi-alias MATCH...RETURN names its ops, so the entire Cypher surface is
locked out". An instrumented run of the graph-benchmark q1-q9 lane at 20k, both
engines, both arms, falsifies that three independent ways: every consult on that
lane is ops=1 and this change only edits the len(ops)==3 branch; g.gfql(<cypher>)
for those shapes is served earlier by gfql_fast_paths.py
(_execute_single_hop_grouped_aggregate_fast_path, _execute_two_hop_count_fast_path,
_execute_seeded_typed_hop_fast_path) and never reaches here; and this path is
pandas/cuDF only while the losing column is polars. Zero consults on q1/q2/q3/q4/
q8/q9 pandas and on all of q1-q9 polars, identical on both arms. NO BENCHMARK CELL
MOVES. A positive control confirms the lever is live rather than inert (arm A
DECLINE at the removed gate, arm B SERVE, same native 3-op named chain).
Under the pyg-bench-proof-plus-lock-in rule this therefore carries NO perf claim
on the board; the lane is specified as follow-up in the PR description.

HOW THE TAGS ARE DERIVED: from the KEPT EDGES the fast path returns.
`combine_steps` tags a node with an alias iff it still participates in a surviving
edge, and those edges are exactly the surviving ones, so `isin` over their endpoint
columns is the same predicate without the join. A seed whose edges all fail the
filter yields an empty edge frame and is tagged False -- the dead-end case, and why
the tag keys on edges rather than on the node filter.

DECLINES KEPT DELIBERATELY: undirected + named (alias identity is not derivable
from endpoint columns there); duplicate alias names (E201 lives in `combine_steps`,
so serving them would BYPASS the check and silently succeed); and a resident index
that would actually serve the shape (`_resident_seed_indexes` asks about index
VALIDITY -- a registry-presence check declines on every query and makes the whole
thing a measured no-op).

CODE LOCATION: `_tag_fast_path_aliases` lives in `chain_fast_paths.py`, not
`chain.py`. That is the pre-existing direction, confirmed not recalled: the module
was created by 289c6ab "extract seeded fast-path specializations to dedicated
modules (#1755)" and its docstring states the one-way import rule. The docstring
now records it explicitly so the next helper does not land in chain.py again.

DTYPES: ADOPT the conformant ones. The pandas full path upcasts non-id int64 ->
float64 and bool -> object through its rows-pivot merges. Verified rather than
assumed that this is an ARTIFACT: openCypher TCK
clauses/return/Return2.feature [2] expects `1` for an integer node property (its
value grammar distinguishes Integer from Float); Neo4j treats INTEGER and FLOAT as
distinct property types and reports valueType()="INTEGER NOT NULL"; and this
library's own polars and cuDF canonical paths already return int64/bool for the
same query, so only the pandas merge upcasts. The fast path keeps int64/bool.
User-visible where a named pandas pattern is served: 30 rather than 30.0, and a
bool alias flag rather than object. Values unchanged. NOT aligned yet, deliberately
out of scope: the pandas full path and the Cypher-layer projection still emit the
artifact.

TESTS: 4 deliberate rewrites, each commented with the rule it now encodes.
test_fast_path_gating_returns_none_for_ineligible and the _FAST_SHAPES/_BYPASS_SHAPES
differential move named shapes from decline to serve and add the new declines;
test_pandas_datetime_property_declines and test_engine_mismatch_declines keep
asserting the Cypher-layer decline and identical values, with the dtype rule now
asserted explicitly instead of incidentally. NEW coverage for the new capability:
test_fast_path_named_alias_columns_match_full_path (9 shapes x 2 engines) compares
the alias flag COLUMNS and per-row values fast-vs-full, which the id-set differential
cannot see, and is serve-asserted.

CHANGELOG added (Performance + Changed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants