Skip to content

perf(gfql): let a datetime search skip the fields that cannot change the answer - #2110

Open
lmeyerov wants to merge 1 commit into
masterfrom
perf/gfql-datetime-index-kernel
Open

lmeyerov wants to merge 1 commit into
masterfrom
perf/gfql-datetime-index-kernel

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

What

DatetimeSearchIndex.matches evaluated all six rendered fields over every row on every call.
Three of those passes are decidable from the selection alone, before a row is read:

  • a field no value of which renders the term contributes nothing — and when that holds for
    every field, the answer is empty without touching the column;
  • a field every value of which renders the term means every present row matches whatever the
    other fields hold, so the answer is the present mask;
  • once the accumulated mask is all true, no remaining field can add to it.

Within a field, comparing against each chosen value costs one pass per value while a lookup-table
gather costs one pass regardless. _COMPARE_UPTO = 11 is where those meet, measured.

The answer is unchanged. This only stops computing parts of it that cannot matter.

Why it matters: 2024 was the easy case

Measured at 30M before this change — the term a live-typing session opens with is the expensive one:

term fields surviving pruning keystroke density
2 day,year,hour12,minute,second 489.6 ms 100%
20 day,year,minute,second 426.4 ms 100%
2024 year 241.5 ms 10%

0, 1, 5 all land at ~490 ms. Every session's first keystroke is a single character.

Measured (dgx, under the perf lock, 3 interleaved arms each, exactness asserted per term)

search_any_mask end to end at 30M rows:

term master this branch
2 474.8 ms 178.0 ms 2.7×
20 413.2 177.6 2.3×
0 471.0 178.6 2.6×
9 473.7 233.0 2.0×
5 471.1 285.0 1.7×
2024 237.8 182.6 1.3×
typing 2→20→202→2024→20241 1,543.1 893.9 1.7×

Every term now bottoms out at ~178 ms, and that floor is the cache-key digest (blake2b over
240 MB, 173.4 ms measured, 72.7% of a keystroke) — the match has left the critical path. That
digest is a separate, API-level question and is untouched here.

No regression on small frames (faster or equal at 100 → 1M rows); the only marginal cell,
5 at 100 rows, reverses at 1,000 rows.

Tests pin the ROUTE, because the answer cannot distinguish these

A version that quietly stopped taking the shortcuts would still be exact, and the existing
differential suite (the render as oracle, 6 shapes × 5 zones × ~40 terms) would still pass. The
new class counts the whole-column passes matches performs.

Every shortcut is mutation-verified:

mutation fails
drop the full-field short-circuit test_a_field_every_value_of_which_matches_reads_no_rows
drop the empty fast path test_a_term_no_value_can_contain_reads_no_rows
_COMPARE_UPTO = 0 (always gather) test_one_selected_value_is_a_comparison_not_a_gather
_COMPARE_UPTO = 100 (always compare) test_many_selected_values_switch_to_a_gather

My first version of those tests counted only equal/take and did not catch the empty fast
path — with no live field the loop never runs, so the counts stayed 0 either way while the code
still allocated a scratch buffer and did a final logical_and. Widened to count logical_and.

Gates, run locally before the first push

ruff/lint · mypy (352 files) · pyright guard (230 grandfathered, no growth) · comment-density ·
changed-line coverage 28/28 = 100% · graphistry/tests/compute/gfql/ 5,838 passed.

The comment-density guard caught a 'slower' in a test docstring and a 3-line #: run; both
rewritten rather than baselined.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

Stack

This is PR 1 of 3 (base) in a stack that lands together, in order: #2110 kernel → #2111 sha256 cache key → #2112 thrash detector + budget. Each is rebased onto the one before it, so the diff shown is only this PR's change. Merge #2110 first; GitHub retargets the next base automatically. Combined measurements are in #2112.

…the answer

`matches` evaluated all six fields over every row, every time. Three of those passes are
avoidable from the selection alone, before a row is read:

* a field NO value of which renders the term contributes nothing, and when that holds for every
  field the answer is empty without touching the column;
* a field EVERY value of which renders the term means every present row matches whatever the
  other fields hold, so the answer IS the present mask;
* once the accumulated mask is all true, no remaining field can add to it.

Within a field, selecting rows by comparing against each chosen value replaces gathering through
a lookup table while few values are chosen. `_COMPARE_UPTO` is the measured crossover on a
30M-row column, not a round number, and both sides of it are reachable.

The answer is unchanged -- this only stops computing parts of it that cannot matter. The
differential suite (the render as oracle, six shapes x five zones x forty terms) covers that,
and a new class pins the ROUTE, because a version that quietly stopped taking these shortcuts
would still be exact and every differential test would still pass. Each shortcut is
mutation-verified: removing it, or moving the threshold to either extreme, fails a test that
names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

This branch has not been deployed

No deployments
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