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
Draft
Conversation
lmeyerov
force-pushed
the
perf/gfql-fastpath-named-aliases
branch
from
July 27, 2026 22:37
e8ce879 to
290e5fb
Compare
lmeyerov
force-pushed
the
perf/gfql-fastpath-named-aliases
branch
from
July 27, 2026 22:48
290e5fb to
f0aaf44
Compare
`_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
lmeyerov
force-pushed
the
perf/gfql-fastpath-named-aliases
branch
from
July 28, 2026 03:01
f0aaf44 to
efe17f2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ... RETURNnames 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:_try_chain_fast_pathon that lane isops=1(q5/q6/q7). This PR only edits thelen(ops)==3branch. Theops==1gate is byte-identical between arms.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 ingfql_fast_paths.py, all returning before_chain_dispatch. That is where the 5–10 ms per-query floor actually lives.Net: 0 consults for q1/q2/q3/q4/q8/q9 pandas, 0 consults for all of q1–q9 polars, and only
ops=1declines 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:
L856is 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_pathrejected 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'scombine_stepsmerges in are now reconstructed from the edges the fast path already returns (_tag_fast_path_aliases):combine_stepstags a node with an alias iff it still participates in a surviving edge, and those edges are exactly the surviving ones, soisinover 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 taggedFalse— the dead-end case, and why the tag keys on edges rather than on the node filter.Declines kept deliberately:
combine_steps; serving here would bypass the check and let alias reuse silently succeed._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/masterand 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:g.gfql([n(name='x'), e_forward(name='r'), n(name='y')])rows(binding_ops=...)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
MATCH ... RETURNsurface's routing for the measured shapes.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, whosechain-seeded-typed-hopworkload is already exactly this shape — a named 3-op seeded typed hop — but declaredengines: [polars].max_growth_ratioprobe 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.polars-edge-order-materializationsandpolars-chain-semi-joins, both chosen precisely because their win sits inside host spread. Addchain-fast-path-named-serves: run the existingchain-seeded-typed-hopquery onengines: [pandas]and count non-Nonereturns of_try_chain_fast_path,min_observed: 1, max_observed: 1.0means the named gate came back (or the shape stopped routing here) →invalid/regression, neverpass. 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.rows_returnedon 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 absolutemax_median_msgate is defensible here — but per the lane's own rule it must be measured against both builds before being written down.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_declinesdoes fail, and is the real 4th. It never ran in this PR's CI because it ispytest.importorskip("polars")-gated and thetest-polarslane wasSKIPPED. 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:
test_fast_path_gating_returns_none_for_ineligible—named_nodemoves 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.test_fast_path_differential_parity_vs_full_path—named_nodemoves from_BYPASS_SHAPESto_FAST_SHAPES, six named shapes are added, andnamed_undirectedis added to the bypass list.test_pandas_datetime_property_declinesand 4.test_engine_mismatch_declines— the Cypher-layer decline is still asserted (that is what these tests exist for). Theirassert_frame_equalincidentally 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 → float64andbool → objectthrough its rows-pivot merges. That is a pandas merge artifact, not a Cypher semantic. Verified before relying on it:tck/features/clauses/return/Return2.featurescenario [2] "Returning a node property value":CREATE ({num: 1})/MATCH (a) RETURN a.numexpects1. 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", so1.0would be a different expected value.INTEGERandFLOATare distinct property types, and an integer property read back reportsvalueType(n.prop) = "INTEGER NOT NULL".int64/bool. And cuDF's canonical pivot preserves source dtypes too — there is aFixedentry 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/boolis 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.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
int64rather thanfloat64(30, not30.0, including in JSON) and an alias flag returnsboolrather thanobject. 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 reportfloat64for 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_aliaseshad been added tographistry/compute/chain.py— a file that was being drained. It now lives ingraphistry/compute/chain_fast_paths.py, which is the pre-existing direction, confirmed rather than recalled:289c6abe"refactor(gfql): extract seeded fast-path specializations to dedicated modules (perf(gfql): Cypher seeded-lookup abstraction tax -- 10-800x over native g.hop() (chain-executor combine_steps, not index-miss) #1755)";chain.pyalready imports_seeded_typed_hop_pandas_cudfand_resident_seed_indexesfrom it.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.pyagain.Also in this PR
getattr(op, "_name", None)in the test helper is replaced by a typed signature and a direct attribute read.d9fa0a95onto233b64c8.🤖 Generated with Claude Code
https://claude.ai/code/session_015YsqAZQLbqjSDrYSFz2GoB