Repository navigation
perf(gfql): index low-cardinality columns so label and type predicates compare codes - #2087
Conversation
6a0da09 to
153a7cf
Compare
|
Your instinct on boundary testing found a silent wrong answer in this PR. Fixed in The defect
All three are correct at the parent, so this branch introduced them. The docstring asserting "the canonical filter does not conflate them" was simply wrong about its own oracle. Fix: Why the test could not see it
Rewritten so it cannot recur: the corpus walks the direction that has edges; every case asserts the oracle's row count, so a case cannot silently go vacuous; Verified by reinstating the bug: 5 of the new cases fail with it, 41 pass with the fix. Your question about more indexes — measured, and it points somewhere specificI timed the primitives the traversal actually calls, at this head, LDBC SNB SF0.1 polars, as a share of each query's own median:
Three conclusions, each killing or backing a candidate:
Build cost and accounting — two gaps I have not closed yetWorth stating rather than burying, and I would rather fix them before this merges if you agree:
Local suite at this head: 13289 passed, 24 failed — the same polars-conformance set that fails identically on the untouched parent. |
24b3fed to
581ac83
Compare
|
Two more commits since the comment above, head is now
Two docstrings asserted the opposite of the code and are corrected: both What I did not optimize, deliberately:
|
Provenance check: do the numbers in the body still hold at the current head?The DGX A/B table in the description was measured at Engagement is byte-identical. Category lookups served vs declined, on the real LDBC SNB SF0.1 fixture:
The one decline in each was already there before the fix — it is a column with no category index, not a cross-type scalar. No SNB predicate is cross-type, so the fix costs this benchmark nothing. Timings, interleaved A/B with an A/A control, 5 rounds, arms alternating, local box, medians of per-run medians in ms. Arm A is
Every B-vs-A delta is at or inside its own A/A floor; seed-lookup's floor is larger than its effect. Canonical rows matched expectations in all 15 runs. So the published numbers stand at the current head. They are not being relabelled to it: they were measured at |
581ac83 to
67e08ea
Compare
e875f31 to
b4a82f3
Compare
67e08ea to
6247d3e
Compare
b4a82f3 to
c8e835b
Compare
…s compare codes A label or edge-type predicate is a scalar equality on a column with a handful of distinct values. gfql_index_all now codes those columns once at build time, and the array bindings path answers the predicate by comparing the candidates' codes instead of filtering a frame. Nulls get a reserved code that is never handed out for a queried value, so they match nothing, exactly as the canonical filter's three-valued logic already decides; a value the column never holds matches nothing for the same reason. Boolean and integer values never share a code, because True == 1 in Python and the canonical filter does not conflate them. Polars only; every other engine, dtype, null-free requirement and cardinality above the cap declines to the canonical filter, which is the same answer. The property-index engagement probe now watches both filter seams. It asserted that the seed was gathered from the index rather than scanned, and the work moving to the positional filter had made it blind rather than false. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
A traversal reaches an endpoint as an id and then has to find that node's row to read its properties, which searches the whole node key array once per hop. gfql_index_all now resolves both endpoint columns through the node id index once, so the hop gathers the row instead of searching for it. The fact declines to build when any endpoint id is absent from the index, since a row denoting no node would be worse than the search it replaces, and its validity spans both frames because it is edge values resolved through the node index. It is an accelerator only: dropping it changes no row, which the tests assert directly by running the same queries with it disabled. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
The guard that stops raw Cypher temporal-constructor text reaching a caller scans the projected result every time a projection runs. A projection that copies a column verbatim inherits that column's own answer, so gfql_index_all now resolves it once per String column and the array path reads the verdict instead of scanning. A String literal has no source column and is checked directly. Without a verdict for a frame the answer is "could leak", which declines to the canonical route: the fact can only ever avoid a scan, never admit text the scan would have caught. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…to "no rows"
The category index answered a scalar predicate by comparing codes. `_code_for` matched
on `type(value) is type(expected)`, and a miss was treated as "the column holds no such
value", returning an empty position set. But a polars filter DOES coerce across these
types, so a cross-type scalar got an empty result where the canonical route returns rows:
{"flag": True} on an Int64 column served [] vs 2 rows canonical
{"flag": 1.0} on an Int64 column served [] vs 2 rows canonical
{"label__Message": 1} on a Boolean column served [] vs 3 rows canonical
All three are correct at the parent commit, so this branch introduced them. The docstring
claiming "the canonical filter does not conflate them" was wrong about its own oracle.
`_code_for` now returns three states: the code; None for a value of the right type the
column does not hold, which genuinely matches nothing; and _DECLINE when the scalar's
type differs from the column's values, or the column is all-null and offers no type to
compare against. _positions_via_category_index defers to the canonical filter on _DECLINE.
Same-type predicates still serve, so the fast path is not lost.
The test written for exactly this case passed anyway, because the whole differential
corpus seeded at n({"id": 1}) and walked e_forward -- and node 1 has no outgoing edge, so
every case compared [] to []. A fast path that drops every row is invisible to that. The
corpus now walks the direction that has edges, every case asserts the oracle's row count
so it cannot silently go vacuous, the helper turns point-rows off and asserts that both
the specialization and the category lookup actually served, the canonical leg is a real
oracle (index_policy="off" with the sibling routes off, not another fast path), and a new
parametrized family covers the cross-type scalars plus a unit pin on the three states.
Verified by reinstating the bug: 5 of the new cases fail with it, 41 pass with the fix.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…and report what it costs Three hygiene items on the new index structures, each measured. Build cost. `build_category_index` ran an exact distinct pass on every admitted-dtype column before the cardinality cap could reject it, so a wide frame paid a full pass per column that yields no index. It now rules out a clearly-over-cap column with `approx_n_unique()` first; the margin keeps the sketch's error away from the cap and the exact check still decides everything near it, so the cap itself cannot move. On a 200k-row, 30-high-cardinality-string-column frame: `gfql_index_categories` 230 -> 56 ms, whole `gfql_index_all` 0.490 -> 0.376 s against a 0.069 s base. Engine gate. `build_endpoint_rows_fact` had none, so a pandas graph built a fact only the polars array path can read. Polars only now, matching its only consumer. Memory accounting. `index_nbytes` skipped the new per-row arrays and `show_indexes` did not report the new structures at all, so the pay-as-you-go memory signal understated exactly the things that scale with the data. Both now cover categories, endpoint rows and temporal text: on a 20k-node/80k-edge graph that is 1.38 MB of a 4.89 MB total that was previously invisible. `test_every_kind_carries_per_row_reason` pinned an exact kind set and now uses ALL_REPORTED_KINDS, so a future fact that forgets to report fails instead of passing quietly. Two docstrings asserted the opposite of the code and are corrected: CategoryIndex and build_category_index both claimed columns with nulls are not indexed. Nulls get a reserved code, which is what makes the null-heavy label__* columns indexable at all. Pins: the exact-pass counter, a 63/64/65/200 cap boundary sweep, the pandas/cuDF decline, and an nbytes-coverage assertion. The end-to-end cases are marked as engagement pins so the routes-off lanes report result divergences rather than a disabled route. The endpoint-rows build remains 204 ms on 800k edges. That is real per-edge work bought deliberately to remove a per-hop search, a declared setup step like the adjacency build; it is disclosed, not optimized away. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
`build_category_index` admits `pl.Categorical` alongside `pl.String`, and the build joins on a mapping frame it constructs itself -- the string-cache-sensitive case -- but nothing tested it. `pl.Enum` is not admitted and nothing tested that either. A dtype sweep now checks codes against the rows they came from for String and Categorical and asserts Enum declines, and an end-to-end family compares a Categorical predicate against the canonical filter, including a value the column never holds, with the expected row count asserted so a case cannot go vacuous. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…ull and stale cases Applying the same boundary standard to the code added in the last two commits, which had gaps of its own. The cap boundary was only checked on strings, but the sketch that rules out clearly-over-cap columns is approximate -- a sketch reading differently on integers would silently cost those indexes. Now swept 63/64/65/200 across String, Categorical, Int64 and UInt8; the cap does not move on any of them. The all-null column had a unit assertion on the empty value map and nothing end to end. It still builds, every row carrying the reserved null code, and the temptation is to read an empty map as "matches nothing" -- right here by accident, wrong as a rule. The lookup declines and the canonical filter decides, now asserted on both. The new show_indexes rows had no stale or engine-mismatch coverage, which is the half that matters: a fact reporting itself usable after its frame was rebound is worse than one that is absent. Rebinding the nodes frame now must stale the node-role facts and the endpoint fact that spans both frames, while the edge-role facts stay valid, and a mismatched engine preview must make every new kind unusable with the shared wording. Verified across all ten routes-off lanes: 68 pass in eight, 49 pass with 19 engagement pins skipped in indexed-kernel and polars-bindings-select. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
6247d3e to
0eb4e19
Compare
…esign `gfql_index_categories` described eligibility as "no nulls", but nulls are exactly what this index codes: `build_category_index` gives them a reserved code, which is what makes the null-heavy `label__*` columns indexable and is where most of the measured win comes from. The two docstrings in this PR disagreed with each other. The contract is already pinned by tests -- `test_category_index.py` codes Boolean/String/Int64 columns containing None and asserts the reserved code is never handed out for a queried value -- so the prose is cut to a pointer rather than restating a rule it got wrong. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…or it The endpoint-row resolver was the one place in this PR that decided control flow from a duck-typed lookup: `getattr(values.dtype, "kind", "")`. Its neighbour twelve lines up already does `str(keys.dtype.kind)` on the same kind of value from the same helper, so the probe was defending against a case the codebase elsewhere states cannot happen -- `col_to_array` returns a numpy or cupy array and those always carry `dtype.kind`. mypy agrees across 349 files with the attribute read in place. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
Retargeted to master, plus two fixes found in a final pass
The coverage baseline was re-checked in both directions after the merge, since a wrong one turns master's lint red and a red lint lane skips every test lane repo-wide: 1. A docstring that contradicted its own design
Verified against the built index rather than the prose: a null-bearing column yields
2. The one dynamic-typing probe this PR addedScanning only this PR's own added product lines turned up exactly one: (The sibling probe at Verification on the merged tree
|
…tree The vendored numbers were pre-release and four runs were 52-61 compute commits past their measuring commit, against a policy limit of 12 -- this PR's own drift gate was failing on them. seed-lookup published 1.018 ms at SF0.1 where the shipped tree measures 0.218, and 1.363 at SF1 where it measures 0.281. Re-vendored from pyg-bench with SNB measured on pygraphistry f283a30 (master with #2084, #2086, #2087, #2088, #2090) and GraphBench on 24c0b1e, both under the DGX idle gate and host perf lock with canonical rows asserted identical across engines, scales and repetitions. docs/test_bench_numbers.py now passes: 37 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…ing a frame A seeded point query gathers the seed's incident edges into a frame and then filters that frame. Attributing seed-lookup's polars calls by call site showed where its time actually goes: 0.233 ms in the edge filter, 0.132 in the collect underneath it, and 0.175 gathering the candidates -- 46% of a 1.17 ms query spent materializing rows that the predicate then throws away. The candidates are row POSITIONS before they are a frame. When the edge predicate is one the category index from graphistry#2087 can answer, the filter runs on uint8 codes and only the surviving rows are gathered. That is the same structure graphistry#2087 built for the array traversal, reused by the point path -- which is the concrete answer to "does the indexing work hint at more indexes we can use for the others": the index already existed, the point path just was not asking it. Anything the index cannot answer falls back to gather-then-filter unchanged, so the canonical filter stays the authority on 3-valued and dtype behaviour. `_index_edge_rows` is now a thin wrapper over a new `_index_edge_positions`, so the gather and the lookup are separable without changing what the gather does. Measured interleaved, arms alternated, 5 rounds x 41 reps, against its own A/A control (LDBC SNB SF0.1, polars, local): seed-lookup 1.019 -> 0.476 ms (-53.3%, A/A floor -5.0%, ranges disjoint) message-creator 0.572 -> 0.540 (-5.5%, floor +4.1%) message-replies 1.774 -> 1.710 (-3.6%, floor -0.6%) recent-replies 3.449 -> 3.522 (+2.1%, floor +1.4%) message-content 0.395 -> 0.395 ( 0.0%, floor +1.4%) Only seed-lookup moves beyond its own floor, and it moves by ten times that floor with non-overlapping ranges. Rows correct in every arm and repetition. NO bench claim is made here: local and bench are not related by a ratio (same commit, the ratio across queries spans 0.84x to 2.54x), so the competitor comparison needs its own bench run. Tests cover both sides of the boundary from the first commit: three indexed predicates (one survivor, several survivors, a value the column never holds) against gather-then- filter as oracle with expected row counts asserted; six predicates the index cannot answer (absent, empty, unindexed column, cross-type scalar, missing column, mixed) which must fall back and still agree, including agreeing on the exception; an engagement pin that fails if the narrowing never runs, verified by disabling it; and the no-index case. Registered in the polars lane and marked route_engaged, verified across all ten routes-off modes. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
Problem
A label or edge-type predicate is a scalar equality on a column with a handful of distinct values. On the seeded bindings path each one still cost a frame filter: select the predicate column, gather the candidate rows, filter, read the survivors back. On LDBC SNB IC8
recent-repliesthat is seven such filters, about 1.7 ms of a 6.0 ms query.Measured in isolation, the same predicate answered from a coded column costs 0.011 ms against 0.179 for a node label, and 0.004 against 0.145 for an edge type.
Change
gfql_index_allnow builds aCategoryIndexfor each eligible column: a per-row integer code plus the value-to-code map. The array bindings path answers a predicate by comparing the candidates' codes, which is one array gather.Three decisions carry the correctness:
True == 1in Python; the canonical filter does not conflate them, so the code lookup matches on type as well as value.Everything else declines to the canonical filter, which is the same answer: other engines, float and other dtypes, and cardinality above 64.
Index build is a declared setup step like the adjacency build, not lazy per-query work. On SF0.1 it takes
gfql_index_allfrom 101.5 ms to 144.7 ms.Measured
LDBC SNB SF0.1, H684 index lane recipe, local box, interleaved against the base branch over three rounds:
About -23% and -20%. Point queries are unchanged.
DGX A/B on the benchmark recipe
Three runs per arm against merged master 65c359b, canonical rows identical in every cell.
This measures the whole stack (#2084 + #2086 + this), since each builds on the last.
Every other cell is flat or inside its own noise. SF1 pandas new-topics reads +10.3%, which is
the cell whose identical-code A/A control spans 472.8 to 567.5 ms — see #2084 for that
investigation; its GFQL surface reaches none of this code.
This branch carries three commits: category indexes, then an endpoint-row index that resolves each
edge endpoint to its node row at build time, then a temporal-text fact that turns the projection's
constructor-text guard into a build-time lookup.
Against the competitor arms,
message-repliesnow beats Kuzu by about sixteen times.recent-replieshas gone from a 5.4x loss against Neo4j to parity: its three runs are5.122, 5.607 and 5.528 ms against Neo4j's 5.425, which straddle it. That is parity, not a win,
and it should not be quoted as one. The two
seed-lookupcells andrecent-repliesremain losses, budgeted in theplan's remaining-gap document.
Tests
graphistry/tests/compute/gfql/index/test_category_index.pycovers code round-trips across Boolean, String and integer columns with and without nulls; declines for float, high cardinality, absent columns and non-polars engines; staleness against both a cloned frame and a reshaped one; and seven end-to-end predicate shapes checked against the canonical filter, including a value the column never holds and the boolean-versus-integer conflation guard.Index suite 1127 passed. Broad suite over
tests/compute/gfql, chain specializations, chain and hops: 16150 passed, 1 failed — the cuDF zero-hop test that fails identically on master with this box's cuDF version.One existing test needed a fix rather than a waiver.
test_node_property_index_seeds_without_scanningpins that a seeded query gathers its seed from the property index instead of scanning, by recording the widths passed to the frame filter. With the predicate answered positionally that probe recorded nothing, so it was blind rather than false. It now watches both filter seams; the assertion is unchanged.Stacked on #2086, which is stacked on #2084. Review those first.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp