Skip to content

perf(gfql): make schema-derived per-type facts opt-in, and build them in one pass - #1859

Merged
lmeyerov merged 3 commits into
masterfrom
perf/gfql-colstats-bytype-flag
Aug 9, 2026
Merged

perf(gfql): make schema-derived per-type facts opt-in, and build them in one pass#1859
lmeyerov merged 3 commits into
masterfrom
perf/gfql-colstats-bytype-flag

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1858, from measuring the build cost nobody had measured.

The measurement

Board-shaped graph, 420k nodes / 2.8M edges, polars (dev box):

cost
col_stats whole-frame only 4.3 ms
+ node per-type +40.6 ms (n_unique per partition, needed for the density proof, dominates)
+ edge per-type +52.0 ms (two grouped passes over 2.8M edges — src and dst separately)
both +103.8 ms, on a 262 ms gfql_index_all = +39%

Query side gains ~2.5 ms on a typed q8, so break-even is ~40 queries of that shape. On the 9-query board, eight of nine queries would pay and get nothing.

Two fixes

Single pass. build_col_stats_facts_by_type now takes a sequence of columns and aggregates them together, so the edge frame is grouped once rather than once per endpoint: edge side +52.0 → +28.0 ms, total +103.8 → +75.1 ms. Declines are all-or-nothing across the requested columns, so a caller cannot receive a partial answer and read it as complete.

Opt-in. Building from a bound GraphSchema now needs col_stats_by_type=True on gfql_index_col_stats() / gfql_index_all(), default False. Binding a schema is a declaration about the data, not consent to a costlier index build — with a schema bound the opt-in measures +9.2%, and it scales with label count since label__X costs one grouped pass per label. Explicit node_type_column= / edge_type_column= requests are unaffected: asked for by name, still built, still raising when unusable.

A regression the pins caught

The shared pass initially fingerprinted every fact across all aggregated columns, but get_col_stats_valid recomputes over {column, type_column} — so every partition fact looked stale on lookup and silently stopped engaging. Values stayed correct (a miss just scans), which is exactly why it needed an engagement pin rather than a value pin to catch. Fingerprints are now per column.

Pins

Single-pass vs per-column equivalence; all-or-nothing decline across columns; the opt-in gate on both entry points including that whole-frame facts are unaffected; plus every #1858 pin still green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi

lmeyerov and others added 3 commits August 8, 2026 07:44
… in one pass

Measured: per-type facts add ~104ms to a ~262ms gfql_index_all on a 420k-node /
2.8M-edge graph (+39%), and only typed count shapes can spend that -- roughly 40
such queries before the build pays for itself. Two fixes.

SINGLE PASS: the edge role aggregated each endpoint binding separately, so the
edge frame was grouped twice. build_col_stats_facts_by_type now takes a SEQUENCE
of columns and aggregates them together: edge side +52.0 -> +28.0 ms, total
+103.8 -> +75.1 ms. Decline is all-or-nothing across the columns so a caller
cannot get a partial answer and read it as complete.

OPT-IN: building from a bound GraphSchema now requires col_stats_by_type=True on
gfql_index_col_stats()/gfql_index_all(), default False. Binding a schema is a
declaration about the DATA, not consent to a costlier index build; with a schema
bound the opt-in measures +9.2%, and it scales with label count since label__X
costs one grouped pass per label. Explicit *_type_column requests are unaffected.

Caught by the pins while making the change: the shared pass initially
fingerprinted every fact across ALL aggregated columns, but get_col_stats_valid
recomputes over {column, type_column}, so every partition fact looked stale and
silently stopped engaging. Fingerprints are now per column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Reviewer caught the inconsistency behind '+1.8% with no schema, because the flag
builds nothing': a flag that builds nothing is not a cheap flag, it is a silent
no-op. Setting col_stats_by_type=True is an EXPLICIT request, so by the contract
already used for node_type_column=/edge_type_column= it must raise rather than
quietly do nothing.

It now does, with the two reasons distinguished because the fixes differ: no
schema bound (bind one, or name the columns), versus a bound schema declaring no
type column the frames carry. Partial coverage still skips -- a declared label
the frame lacks is legitimate, since a schema is a contract for the whole graph.

Also stops the dead work: the schema candidate helpers ran unconditionally,
scanning frame columns even with the flag off.

Re-measured with the no-op configuration gone: the honest opt-in cost is +28.5ms
on a 265ms index_all = +10.7% (2 label columns + 1 edge type column), and it
scales with label count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…ench

Perf magnitudes in a public repo double as an optimization roadmap. The
docstrings added with this change quoted build costs and break-even counts
outright; they now state the SHAPE of the constraint -- a grouped pass per type
column, one per label under label__X, spendable only by typed count shapes --
which is what a reviewer or a future maintainer needs to not delete the gate.
The numbers move to pyg-bench (#184), which is also the standing rule: claims
live in pins and receipts, not in prose.

Reasoning is deliberately NOT stripped: _dtype_kind still records that pandas'
is_integer_dtype raises on cudf ListDtype, because that is a correctness
constraint someone would otherwise 'simplify' away. Only the timings go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
@lmeyerov
lmeyerov merged commit 72273b1 into master Aug 9, 2026
69 checks passed
lmeyerov added a commit that referenced this pull request Aug 9, 2026
Facts are a pure accelerator, so a dead one is invisible: the query still returns
the right answer via the scan, every value test stays green, and you pay the
build for nothing. That is the failure mode behind three bugs in this stack --
the boolean label form never engaging, the shared-pass fingerprint making every
fact look stale, and col_stats_by_type silently no-oping.

Each consult now records op=col_stats with an outcome whose cases need different
fixes: absent / stale / insufficient / served. 'insufficient' is what a
whole-frame fact always is against a typed pattern -- the structural limitation
that took a full investigation to diagnose is now one trace line.

Gated by the same _trace_active() check the adjacency decisions use, so it costs
nothing outside index_trace()/gfql_explain; pinned both ways.

It also makes engagement testable against a PUBLIC surface rather than by
patching private callees -- which matters: patching a name that another module
imported directly does not intercept, and produces a false negative that reads
like a dead optimization.

Stacked on #1859.

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