Skip to content

perf(gfql): index low-cardinality columns so label and type predicates compare codes - #2087

Open
lmeyerov wants to merge 1 commit into
perf/gfql-array-side-hop-loopfrom
perf/gfql-category-index
Open

lmeyerov wants to merge 1 commit into
perf/gfql-array-side-hop-loopfrom
perf/gfql-category-index

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

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-replies that 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_all now builds a CategoryIndex for 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:

  • Nulls get a reserved code, never handed out for a queried value, so a null row matches no scalar predicate. That is exactly what the canonical filter's three-valued logic already decides, and it is what makes the index useful in practice: the SNB label columns are null-heavy after their concat, and a null-refusing version skipped every one of them.
  • A value the column never holds matches nothing, answered here rather than deferred, because that is the same answer the filter gives.
  • Boolean and integer values never share a code. True == 1 in 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_all from 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:

query base this branch
recent-replies 6.087 / 5.930 / 6.033 4.595 / 4.658 / 4.997
message-replies 2.716 / 2.812 / 2.649 2.175 / 2.122 / 2.232

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.

scale engine query master stack delta
SF0.1 polars recent-replies 29.291 7.142 -75.6%
SF0.1 polars message-replies 8.102 1.947 -76.0%
SF0.1 polars seed-lookup 1.068 0.958 -10.3%
SF0.1 pandas recent-replies 44.743 27.241 -39.1%

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.

Against the competitor arms on eligible cells, the stack leads 7 and loses 3: message-replies
now beats Kuzu by about twelve times, and recent-replies has gone from a 5.4x loss against
Neo4j to 1.32x. The two seed-lookup cells and recent-replies remain losses, budgeted in the
plan's remaining-gap document.

Tests

graphistry/tests/compute/gfql/index/test_category_index.py covers 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_scanning pins 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

…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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
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