perf(gfql): per-type column-stat facts — make the dense kernel reachable on typed graphs - #1858
perf(gfql): per-type column-stat facts — make the dense kernel reachable on typed graphs#1858lmeyerov wants to merge 11 commits into
Conversation
Registry keyed (role, column, type_column, type_value); build/consult/ tests/benchmark-motivation to follow. Parked per owner sequencing: benefit-lock and merge #1854/#1856 first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
build_col_stats_facts_by_type: one grouped agg per (frame, column, type_column) yielding a fact per type value, on polars and pandas/cudf. Declines by explicit precondition (absent column, non-integer or null-bearing values, float/null type keys, empty frame, >256 partitions); aggregation errors propagate. Partition-fact validity spans the type column too, since editing it re-partitions the frame. Consult path, tests and micro-lock still to come. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Whole-frame facts prove nothing on a typed graph: the domain is a strict subset of the node frame (so no interval hint is computed at all today) and the endpoint interval spans every label. The consult now resolves partition facts keyed by the single scalar equality a typed pattern lowers to, falling back to whole-frame facts; a miss anywhere still costs the scan, never an answer. _partition_key_from_match admits ONLY a lone scalar equality -- a further-filtered domain is no longer the partition, so its ids need not stay dense. gfql_index_col_stats gains node_type_column/edge_type_column, which raise when unusable (asked for by name), and the registry key pins widen to the 4-tuple. Measured on the pinned typed fixture (P ids 0-2 dense, F-edges P->P, X-edges reaching C): no facts -> no hint, no proof, scan; whole-frame facts -> same; per-type facts -> hint (0,2), bounds proved, scan skipped. Answer identical in all three arms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Cross-engine depth for the typed consult, matching the bar set on #1856: the 3-arm gate (none / whole-frame / per-type) now runs per engine, so a partition fact that resolves on one backend and not another is a failure rather than an unmeasured gap. cudf arms exercise in CI's GPU lane (this box has no libnvrtc, which fails every cudf case here identically on master). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Both fixed by declaration, not by raising a cap: - _column_to_pylist takes SeriesT and returns List[Union[str, int]] (the values are type keys or bounds), replacing Any/List[Any]. - The two _partition_key_from_match call sites drop redundant casts -- edge_match and filter_dict are already declared Optional[dict]. Guard OK, ruff clean, mypy clean on both changed modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Correction to the PR body: the board cannot engage this as writtenI claimed above that a board-visible win is "plausible but unmeasured." That understates the problem, and I want it on the record before review. Per-type facts are opt-in by column name only. The board data is otherwise a perfect fit: its frames carry This does not affect the correctness of the change or the receipted micro (pyg-bench #181 requests the type columns explicitly, which is why it engages). It affects only the claim about the board. Two follow-ups, kept separate on purpose:
So: merge this for the mechanism and the micro, and treat "does the board move" as open until (1) lands and a lane runs. |
Idiomatic Cypher was silently excluded from the typed path. `(a:Person)` lowers
to the BOOLEAN `{'label__Person': True}` and `-[:FOLLOWS]->` to
`{'type': 'FOLLOWS'}`; the gate rejected bools, so the node-side dense-interval
hint could never resolve for label syntax and the whole benefit was confined to
explicit property maps (`{kind: 'P'}`). Every existing typed fixture used
property maps, so nothing caught it.
The builder already handled boolean type columns (two partitions, dtype gate
passes), so this is a consult-side fix plus widened annotations.
Pins: label-syntax engagement end to end per engine (hint resolves, value
identical), and the admission matrix now asserts the bool form is admitted while
label-plus-another-predicate is still refused.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
GFQL rewrites an absent label__X into a membership test on a 'labels' LIST column (resolve_filter_column), so a list-valued type column is reachable in practice. It is not equality-addressable -- a query never yields a list-valued partition key, so such a fact could never be consulted -- and the engines disagreed natively: pandas RAISED 'unhashable type: list' from the groupby while polars grouped by list and silently built unusable facts. Neither is acceptable; both now decline by explicit precondition. Pins: list-column decline per engine, plus the positive twin so the decline did not catch ordinary string keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Membership hazards audited, two engine-divergence bugs fixedReviewer raised that "some optimization may short circuit based on membership." Audited; here is what I found and what I changed. The short-circuit is sound, and here is whyThe dense kernel skips the domain semi-join when Overlapping labels do not weaken it: a node being both Verified empirically: 120 randomized trials with multi-label nodes and duplicate node rows (40 with the hint engaged) — zero divergences from the no-facts oracle. Plus hand cases for overlapping labels, duplicate rows in one label, and the same id in both label groups. Multi-label patterns cannot ride a single-label fact
Two real bugs found and fixed1. The boolean label form was silently excluded. 2. List-valued type columns diverged across engines. Still open, for a follow-up — the two conventions disagree
|
Closes the discovery question without guessing. bind(schema=GraphSchema(...)) already names the type partitions -- NodeType.labels maps to GFQL's label__<L> convention, EdgeType.name to the relationship type -- so using it is a declaration, not a column-name sniff. Derived candidates are restricted to columns the frame actually carries and SKIP when unusable (they were not asked for by name, unlike node_type_column/edge_type_column, which still raise and still win for their role). No schema bound => nothing inferred and no extra build, so callers who never opted in see zero change. Edges offer BOTH candidates on purpose: schema.py declares label__<Name> booleans while the Cypher lowering emits type == '<Name>', and until that is reconciled a fact on an unqueried column is wasted build time, never a wrong answer -- so covering both is the safe direction. Pins: schema-declared build + end-to-end engagement, the no-schema zero-change twin, and absent-label skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Schema integration: type partitions now come from the declared schemaReviewer asked to research the existing node/edge type surfaces and integrate rather than bolt on. Done — and it resolves the discovery question without any column-name guessing. What exists on master
What this PR now does
This is a declaration, not a heuristic: the user said these are the types. It deliberately does not sniff column names by cardinality, which would make performance depend on whether someone happened to name a column Contract, kept consistent with what was already there:
One unresolved conflict, handled defensively
Until that is reconciled I offer both edge candidates and keep whichever the frame carries. A fact on a column no query names costs build time and never correctness, so covering both is the safe direction — but the conflict is worth fixing at the source, and I would rather flag it than pick a winner inside a perf PR. Remaining question for youShould |
Real-GPU verification on GB10 (not just CI)Earlier I said the cuDF arms "exercise in CI's GPU lane." That was an assumption; it is now measured. Run on dgx-spark (NVIDIA GB10) through Environment, printed by the run itself rather than assumed: That second line is the NVRTC JIT probe that fails on my dev box, so this is genuinely the path local runs cannot reach. Result: 183 passed, 0 failed, 0 skipped across Confirmed by name, every parametrization: So the boolean- Build |
| ) | ||
|
|
||
|
|
||
| _MAX_COL_STATS_PARTITIONS = 256 |
There was a problem hiding this comment.
extern ? do we ahve such constants somewher ealready?
The signature was imprecise three ways. Union[bool, str, int] is REDUNDANT -- Python types bool as a subtype of int, so mypy already accepted True for Union[str, int]; spelling all three implied a distinction that does not exist. The same domain was also respelled at five sites, and the one property that IS load-bearing was undocumented. PartitionValue now names it once, in the registry where ColStatsFact lives, and carries the reasoning: bool is admitted deliberately (a:Person lowers to label__Person == True), and because True == 1 and they hash alike, a bool-keyed and an int-keyed partition of the SAME column are the SAME registry key -- unreachable in practice (a column is bool- or int-dtyped, not both) and harmless where reachable (flag == 1 on a bool column selects the True rows). Pinned so it cannot change silently. The match parameter keeps Dict[str, Any] with the rationale comment already used by filter_mask_by_dict: filter values are heterogeneous by contract (scalars, lists, ASTPredicate). Hygiene findings drop 4556 -> 4555; mypy clean on all three changed modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
…ild.py build.py had four getattr(series.dtype, 'kind', None) probes plus a getattr capability sniff. All are now explicit; build.py contains no getattr at all. _dtype_kind() encapsulates the dtype read once, with the measurement that justifies it: pandas' is_integer_dtype and friends CANNOT be used here because they RAISE TypeError on cudf ListDtype -- precisely the exotic dtype this module must decline cleanly rather than crash on. The None default was dead code: pandas.api.extensions.ExtensionDtype DEFINES .kind, so every pandas and cudf dtype has one (verified across ArrowDtype, Interval, Period, Sparse, Categorical, DatetimeTZ, and cudf List/Struct/Decimal128). Typed access plus a localized type: ignore, per the engine-agnostic typing convention. _column_to_pylist now dispatches on the ENGINE it is given rather than probing for a to_arrow attribute -- the engine is known at every call site. build_node_prop_index's array probe goes too: numpy/cupy arrays always carry dtype.kind. Behavior re-verified: list columns still decline on both engines, scalar keys still build identically, nullable Int64 still facts, float still declines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Follow-on to #1854, which landed as no-harm / no-help on the multi-type GraphBench board. That was structural, not a tuning miss, and this fixes the cause.
Why whole-frame facts cannot work on a typed graph
So on the board,
_facts_prove_boundsreturned False every time and the O(E) endpoint scan always ran.1. Does this replace whole-frame stats, or build on them?
Purely additive. Whole-frame facts are still built and still used. Nothing about #1854's behavior changes.
Facts gain two extra key components, so the registry key goes from
(role, column)to(role, column, type_column, type_value). The whole-frame fact is simply the(…, None, None)entry — same fact, same build, same consult, now just one point in a larger keyspace.At build time both are produced:
At consult time they are used for different query shapes, with whole-frame as the fallback:
MATCH (a)-[]->(b))(a:Person),{kind:'P'})Verified on a mixed graph: the untyped query still derives its whole-frame hint
(0, 5)while the four partition facts sit alongside unused. A miss at any step costs the scan, never an answer.2. How the type-tag field is chosen
Three sources, in precedence order. None of them guess.
a) Explicit, by name — wins for its role.
gfql_index_col_stats(node_type_column='kind', edge_type_column='rel'). Asked for by name, so an unusable request raises rather than skipping silently.b) A declared schema — used automatically, because it is a declaration.
bind(schema=GraphSchema(...))already names the types:NodeType.labelsmaps to GFQL'slabel__<Label>convention andEdgeType.nameto the relationship type. Derived candidates are restricted to columns the frame actually carries and skip when absent or unusable — a schema is a contract for the whole graph, and a frame legitimately carries only part of it.c) Nothing else. No schema bound and no param ⇒ nothing is inferred and no extra work is done, pinned by an explicit negative test. In particular there is no column-name sniffing (
type/category/kind) and no cardinality probing: that would make performance depend on whether someone happened to name a columnkind, cost a distinct-count per candidate on everyindex_all(), and be hard to retract once shipped. If we want it later,node_type_column='auto'is the opt-in shape.Whichever source supplies it, the consult side keys off what the query itself says — the single scalar equality a typed pattern lowers to — so a fact built on a column no query names is wasted build time, never a wrong answer.
Two conventions are both supported, because both occur:
(a:Person)→ the boolean{'label__Person': True}-[:FOLLOWS]->→ the string{'type': 'FOLLOWS'}{'kind': 'P'}Known conflict, handled defensively:
schema.pydeclares edge types aslabel__<Name>booleans (EdgeType.columns, line 338) while the Cypher lowering emitstype == '<Name>'(lowering.py:3048). Node labels agree; edges do not. Until that is reconciled at the source I offer both edge candidates and keep whichever the frame carries. Worth fixing, but not by picking a winner inside a perf PR.3. Observed benefit
GB10 reference (pyg-bench #181 — quiet gate, perf lock, board image, build
3e162e427). Board-shaped graph: Person ids dense in [0, 400k), City beyond; 2.4M FOLLOWS (P→P) + 400k LIVES_IN (P→C); the board's q8 two-hop count restricted to Person/FOLLOWS.count=14396131in every arm; the micro asserts value parity. A dev-box run read 1.38x and is superseded — the GB10 numbers are the reference.Scope of that claim, stated plainly: this is a motivation lock on one shape, not a board result. The multi-type board lane is owed once this lands, and it needs pyg-bench #182's
--index all+typesto engage at all (gfql_index_all()passes no type columns, so a lane without it would measure exactly zero — a harness gap, not a null result).Correctness notes
The dense kernel skips the domain semi-join when the partition's ids provably fill
[min,max]. That check isn_unique == max - min + 1— a claim about the id set — so duplicate rows cannot fake density, and overlapping labels do not shrink a label's id set. Multi-label(a:Person:Employee)lowers to two keys and the one-equality rule refuses it, so an intersection can never ride a single-label fact. Fuzzed over 120 randomized multi-label / duplicate-row graphs (40 with the hint engaged): zero divergences from the no-facts oracle.Two bugs surfaced during that audit and are fixed here: the gate rejected bools, so idiomatic Cypher labels could never engage (every fixture used property maps, so nothing caught it); and list-valued type columns diverged across engines (pandas raised
unhashable type: 'list', polars silently built unusable list-keyed facts) — both now decline by explicit precondition.Pins
Three-arm gate (no facts / whole-frame / per-type — hint and proof appear only in the last, answer identical in all three) across pandas, polars and cuDF; label-syntax engagement per engine; the partition-key admission matrix; extra-predicate hint refusal; list-column decline plus its positive twin; schema-declared build and engagement; the no-schema zero-change twin; absent-label skip; unusable-request raise.
Verified on real GPU (dgx-spark GB10 via
dgx-guard/safe_run.sh, so--gpus allis in force): 184 passed, 0 failed, 0 skipped oncudf 26.02.01 / cupy 13.6.0. Zero skips is the load-bearing part — the cuDF arms genuinely ran rather than opting out, which a local run cannot do (nolibnvrtc, so every cuDF case fails identically on master).Two things in this PR are cuDF-only code paths that a dev box physically cannot execute, so they were exercised directly on device rather than inferred:
That last pair matters: the
_dtype_kindhelper exists precisely because pandas'is_integer_dtyperaisesTypeErroron cudfListDtype, and this confirms the replacement declines cleanly on real device data while scalar keys still build identically across all three engines.🤖 Generated with Claude Code
https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi