Skip to content

[EXPERIMENTAL] perf(gfql): seeded-chain fast path — slices 1-2 shipped, slice 3 (lean combine) in design#1738

Draft
lmeyerov wants to merge 16 commits into
masterfrom
perf/gfql-seeded-chain-fastpath
Draft

[EXPERIMENTAL] perf(gfql): seeded-chain fast path — slices 1-2 shipped, slice 3 (lean combine) in design#1738
lmeyerov wants to merge 16 commits into
masterfrom
perf/gfql-seeded-chain-fastpath

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

What

Stack PR #6 (base: #1737#1736#1734#1735 → master). First slice of the evidence-driven Cypher-Search fast path (plan.md 'FAST-PATH EVIDENCE'): eliminates the two per-call O(E) costs that made seeded Cypher latency GROW with edge count (LDBC SF0.1→SF1 grew 4-5× on 11× data; Kuzu stays flat).

  1. Typed keep-mask cache (gfql/index/traverse.py): the feat(gfql): physical adjacency indexes for O(degree) seeded traversal #1658 index path rebuilt its simple-equality edge keep-mask via a full column compare (eq_str, ~2.8ms/1M rows) on EVERY seeded hop. Now cached per (resident frame, engine, scalar edge_match), recycle-safe via weakref.finalize (the perf(gfql): identity-stable _pl_nan_to_null + clean-cache (kills O(E) per-hop NaN re-scan on polars) #1735 clean-cache pattern).
  2. Augmented-frame cache (lazy/engine/polars/chain.py): the polars chain rebuilt with_row_index per call — an O(E) copy whose fresh object identity ALSO defeated every downstream id-keyed cache (including Replace NaNs with nulls since node cannot parse JSON with NaNs #1). One augmentation per resident frame; stable identity restored.
  3. (Also: a typed-edge slice cache in binding_rows_polars for the bindings-row path, same pattern.)

Evidence

  • Before: cProfile of official IS1 on 200k/1M polars graph showed eq_str 2.8ms/call + with_row_index per call; warm median 18.7-23ms.
  • After: both eliminated from the warm profile; remaining costs are ~25 lazy collects (~7ms — slice 2/2: plan fusion) + ~4.2ms Cypher re-compile (slice 3: plan cache).
  • Parity: 274 tests pass (138 index 4-engine + 136 polars cypher conformance), zero changes.

Safety of the caches

Pure functions of the resident frame; eviction on frame GC via weakref.finalize so a recycled id can never serve stale data while the source frame is alive; rebinding g.edges(...) yields a new frame object → new key → recompute (semantics unchanged).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL

@lmeyerov
lmeyerov force-pushed the feat/gfql-polars-rows-entity-groupby branch from e3260ad to 3e9840a Compare July 19, 2026 20:29
@lmeyerov
lmeyerov force-pushed the perf/gfql-seeded-chain-fastpath branch from 3a38a0c to b469dd0 Compare July 19, 2026 20:30
@lmeyerov lmeyerov changed the title perf(gfql): seeded-chain fast path 1/2 — kill per-call O(E) keep-mask + augmentation costs [EXPERIMENTAL] perf(gfql): seeded-chain fast path — slices 1-2 shipped, slice 3 (lean combine) in design Jul 19, 2026
@lmeyerov
lmeyerov force-pushed the feat/gfql-polars-rows-entity-groupby branch from 3e9840a to 509d564 Compare July 19, 2026 21:15
@lmeyerov
lmeyerov force-pushed the perf/gfql-seeded-chain-fastpath branch from b469dd0 to 2a65690 Compare July 19, 2026 21:17
@lmeyerov
lmeyerov force-pushed the feat/gfql-polars-rows-entity-groupby branch from 509d564 to 48a03d5 Compare July 19, 2026 21:33
@lmeyerov
lmeyerov force-pushed the perf/gfql-seeded-chain-fastpath branch from 2a65690 to 46e3386 Compare July 19, 2026 21:35
@lmeyerov
lmeyerov force-pushed the feat/gfql-polars-rows-entity-groupby branch from 48a03d5 to 74fe4a9 Compare July 19, 2026 23:49
@lmeyerov
lmeyerov force-pushed the perf/gfql-seeded-chain-fastpath branch 2 times, most recently from 7d46860 to 551cc63 Compare July 20, 2026 00:43
lmeyerov and others added 2 commits July 19, 2026 20:49
…gmentation caches)

IS1 profiling found two linear-with-E costs per seeded Cypher call on a
resident graph: the index path's simple-equality keep-mask rebuilt via a full
column compare (eq_str), and the polars chain's per-call with_row_index O(E)
copy whose fresh identity also defeated downstream id-keyed caches. Both now
recycle-safe caches keyed on the resident frame (weakref.finalize eviction,
the _pl_nan_to_null pattern). Profiled: both terms eliminated; 274 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…lice 2)

The _LazyShim construction eagerly with_row_index-copied both frames
O(N)+O(E) per call; lazy with_row_index numbers rows identically inside the
single fused collect. 283 tests pass (index + conformance + new regressions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
@lmeyerov
lmeyerov force-pushed the perf/gfql-seeded-chain-fastpath branch from 551cc63 to e4cc21b Compare July 20, 2026 03:50
@lmeyerov
lmeyerov changed the base branch from feat/gfql-polars-rows-entity-groupby to master July 20, 2026 03:50
lmeyerov and others added 13 commits July 19, 2026 22:29
…slice 3)

The Track-B combine semi-joins the FULL node/edge frames and re-sorts on
in-plan row indices EVERY call — 55ms of the ~72ms SF1 seed-lookup wall
(probe /tmp/probe_collects.py). When the chain added __gfql_edge_index__
itself, surviving edge ids ARE row positions: a sorted position gather
rebuilds the exact edge frame in original row order for O(result), and
node rows come the same way via a recycle-safe cached id->row-position
mapping (4th cache of the weakref.finalize family; frames with null/dup
ids cache a None verdict and stay on Track B). Per-step endpoint
semi-joins + alias flags run only on the small step/gathered frames,
mirroring _combine_edges/_combine_nodes semantics exactly.

Guards -> Track B fallback: multihop, pre-bound edge ids, non-node first
step, unseeded chains, >100k total step rows, unsuitable node frame.

Parity: 1000/1000 polars chain tests incl. new lean-vs-TrackB
differential suite (byte-identical frames + engagement assertions +
fallback pins for dup-id and pre-bound-edge-id graphs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…acks DataFrame.gather)

The SF1 re-measure on the 26.02 RAPIDS image (polars 1.35.2) failed every
lean-engaged query with AttributeError — DataFrame.gather is a 1.4x
addition. __getitem__ row selection is equivalent and supported on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
The lean attempt batch-collects step frames to bound result size; on
guard failure it previously returned None and Track B re-executed the
whole traversal inside its fused collect — +34-43% on big-wavefront SF1
queries (new-topics, job-referral). Return the materialized steps
instead and thread them into Track B, so the traversal runs once either
way. Protocol: ('lean', nodes, edges) | ('fallback', steps, labels) |
None (pre-collect declines).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
Full forward label frames (hub fan-out) were eagerly collected by the
lean attempt while Track B only ever touches them through fused lazy
semi-joins with pushdown — new-topics cypher +33% at SF1. The lean
combine needs just prev/next node-id sets and named-edge eid sets, so
collect those projections in the same batched collect_all; on guard
fallback label steps return to Track B untouched (lazy, fusible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
Pipelines that rebind node frames per call (whole-entity aggregation)
never hit the mapping cache and paid O(N log N) sort+unique per call
just to land in the guard fallback (new-topics cypher +35% at SF1).
Build the mapping only once the small-result guard passes; unsuitable
node frames now also return the materialized-steps fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
The eager step collect materialized full wavefront columns; both the
lean gathers and Track B rebuild output rows from the RESIDENT frames
and read only ids/endpoints/alias flags from steps — Track B's fused
plan got this for free via column pushdown, the explicit collect must
project. 1136 chain+conformance tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
The mapping build (O(N log N) sort + O(N) dup probe) ran per chain call
for pipelines that rebind node frames (fresh identity, zero cache reuse)
— the whole-entity aggregation path paid it every call (new-topics
cypher +32% at SF1). First sighting of a node frame now marks the cache
and serves nodes via the O(N) order-preserving is_in filter (same rows
and order as the semi-join); only a recurring frame builds the mapping.
Dup-id results (Track B dedupes with a first-occurrence pick) divert to
the materialized-steps fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
…o fused Track B

Full collection of projected step frames still executed the unpruned
traversal for big-wavefront queries (new-topics cypher +34% at SF1) —
Track B's single fused plan pushes the combine's semi-join predicates
INTO the traversal, which a split collect cannot. Collect each lazy step
frame limit-capped at guard+1: a frame at <= cap is complete and
reusable; any truncated frame aborts the lean attempt so Track B runs on
the ORIGINAL lazy steps with full fusion, probe cost bounded by the cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
In-memory limit pushdown cannot early-stop joins, so a truncated probe
means the traversal ran once for the probe and again in Track B —
new-topics cypher paid 2x traversal (+36%) on every call. Compiled-plan
caching reuses the ops objects across calls: memoize the truncation per
(edges frame id, ops ids), recycle-safe via weakref.finalize, so warm
calls go straight to the fused Track B plan. A recycled key can only
skip an eligible lean attempt — conservative, never incorrect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
The skip-memo keyed on frame identity never fires for pipelines that
rebind frames per call — every call is a fresh id, so new-topics cypher
kept paying the probe's full traversal (+35%). The augmented-edges cache
already distinguishes the cases: a HIT means this edges frame recurred
(resident graph), a MISS means per-call frames. Require the hit before
attempting the lean probe; resident graphs warm into the lean path on
their second call, rebound-frame pipelines go straight to fused Track B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
The trace on the real new-topics run showed the same edges frame
recurring but FRESH op objects per call — the identity-keyed memo could
never hit, so the truncating probe re-ran the traversal every call
(+40%). Key the memo on the structural chain shape instead (op kinds,
directions, hops, aliases, filter KEYS — seed values vary while fan-out
shape doesn't); collisions can only skip an eligible attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
LazyFrame.schema / .columns resolve the schema AND emit a
PerformanceWarning on every access; both ran per predicate per call on
the seeded fast path (the SF1 profile showed the two warning sites
firing on every query). Route polars schema reads through collect_schema
via a local _schema_of, and give filter_by_dict a lazy-safe column
probe. Zero PerformanceWarnings now; 200k/1M IS1 warm 12.1 -> 11.4ms.
Deliberately avoids row_pipeline.py so this does not conflict with the
pending #1714 OLAP rebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
_select_emits_temporal_constructor_text called the per-column
_has_temporal_constructor_text helper in a loop, each doing its own
select().item() collect — 6 collects per call on LDBC IS1. Scan every
String column in ONE fused select instead (same ^-anchored pattern, same
any() semantics, same swallow-failures-as-False parity), and read the
schema lazily-safely. 71 -> 66 collects/call on the 200k/1M probe.
1233 chain+row-pipeline+conformance tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6dQEcjdazEnzuvuwf73ZL
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.

1 participant