Skip to content

perf(gfql): row-evaluator text sniffers decide on a sample before scanning a column (#2116 3c) - #2128

Merged
lmeyerov merged 7 commits into
masterfrom
perf/gfql-row-sniffers-early-negative-3c
Oct 4, 2026
Merged

lmeyerov merged 7 commits into
masterfrom
perf/gfql-row-sniffers-early-negative-3c

Conversation

@lmeyerov

@lmeyerov lmeyerov commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Summary

#2116 item 3c reported cuDF IN ... OR at ~670 ms. Profiled before touching anything (20k nodes / 100k edges, pandas, warmed medians, local = direction only): the cost is not cuDF-specific and not the IN. WHERE a.id IN [50 ids] OR b.kind = 'zzz' takes 545 ms on pandas and 402 ms on cuDF (plain IN 26 / 55 ms): the cross-alias OR keeps the predicate out of the alias prefilter, and then the string equality b.kind = 'zzz' pays

  • ~670 ms in order_detect_temporal_mode — eight regex full-matches over every value of a 100k-row object column (1.6M fullmatch calls), and
  • ~160 ms + ~80 ms in _gfql_series_is_list_like / _gfql_series_is_mapping_like (astype(str) + regex over all rows),

for both operands (the broadcast scalar too), before the compare itself (IN is 26 ms of it).

All three probes are all-rows conjunctions (.all()), so a failing 16-value sample settles them exactly; the full scan runs only when the sample passes. For the list/mapping probes, a sampled non-null str value is already forced False by the probe's own actual_string rule, so it is an immediate False.

Measured on 100k rows: temporal 151 ms → 5.5 ms, list-like 34 → 2.7 ms, mapping-like 34 → 2.7 ms per probe; a 100k all-dates column is still detected (date, 33 vs 34 ms — the full scan still runs when the sample passes). Old-vs-new equivalence: 176 series (temporal text, constructors, list/map text, real lists/maps, words, nulls, mixed families, failures only past the sample, native dtypes) — 0 mismatches.

Not in this PR: the unseeded MATCH (a)-[e]->(b) RETURN b LIMIT 5 (81 ms vs 3.6 ms native) is the full 100k-row binding table built before LIMIT — a pushdown/lazy-table design item, filed separately.

Test plan

  • graphistry/tests/compute/gfql/row/test_sniffers_sample_first_3c.py (18): answers preserved per family, failure past the sample still counted, native dtypes, and a counting pin that a 100k string column sniffs on ≤16 values
  • gfql/row + gfql/cypher + temporal coverage + datetime search index suites: 3815 passed, 0 failed
  • changed-line coverage 100%, bin/lint.sh, mypy clean
  • CI green at the head

🤖 Generated with Claude Code

https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp

…nning a column (3c)

A string equality on a 100k-row object column paid ~670 ms regex-fullmatching every value
against eight temporal patterns plus ~240 ms in the list-like and mapping-like probes, for
both operands. Each probe is an all-rows conjunction, so a failing 16-value sample settles it
exactly; the full scan runs only when the sample passes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov

lmeyerov commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Real-GPU receipt (dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, cudf 26.02.01 / cupy 13.6.0 / polars 1.35.2) at a47a332: 102 passed (sniffer pins, ORDER BY null placement, temporal coverage, test_engine_polars_gpu); CHANGELOG-only commit since (853ec1a) changes no code.

@lmeyerov

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Read-only review (parallel session) — sample-first sniffers, head 853ec1a

Nothing here touches the branch; findings only, fixes deferred.

What it changes: order_detect_temporal_mode tries each all-rows regex conjunction on head(16) of the text first and
runs the full column scan only when the sample passes; _gfql_series_is_list_like / _gfql_series_is_mapping_like
return False as soon as a sampled non-null value is an actual str.

Correctness check (by reading the full functions on the head, not just the diff):

  • Temporal: each mode is all() over rows, so a failing sample is a sound negative; a passing sample still runs the
    full scan, so a column that fails only past row 16 keeps its old answer. Mode ORDER is preserved
    (date, datetime, time, date_constructor, datetime_constructor, time_constructor). Sound.
  • List/mapping: the old code computed actual_string = (series == series.astype(str)) and forced those rows False
    before .all(); a str value always equals its own astype(str), so any sampled str already made the
    old answer False. The early return is exactly equivalent. Stringified lists ("[1, 2]") were never list-like
    by this probe before (they are actual strings) and still are not. Sound.
  • Engine parity: uses .head, series_str_fullmatch, _gfql_series_to_pylist — all already engine-polymorphic;
    cuDF Series.head exists. No new pandas-only call. Not run on cuDF here (no runnable cuDF locally) — the PR
    body's local numbers are pandas; a dgx cuDF smoke of the three probes would close that.

Tests (test_sniffers_sample_first_3c.py, 18): answers preserved per family with and without a trailing None;
failure past the sample still counted (both orders); native dtypes untouched; counting pin via monkeypatched
series_str_fullmatch asserting max(calls) <= _GFQL_TEMPORAL_SNIFF_SAMPLE (structured, not message-matching). Good.

Findings

  • SUGGESTION (testing): the body cites a 176-series old-vs-new equivalence sweep with 0 mismatches, but the sweep is
    not committed. Commit it as a parametrized test (or a scripts/ check) so the equivalence stays pinned when the
    regexes change.
  • SUGGESTION (perf evidence): the win is only realised when the sniffer is reached, i.e. the cross-alias OR
    shape. A pyg-bench pin is not needed for this PR by itself, but the colleague's [FEA] typecheck invalid api=... value #285 sentinel set already
    targets 3a/3c — confirm it includes an IN ... OR row-form point so the 545 ms → ~? ms claim has a receipt.
  • SUGGESTION (scope note): the body says the unseeded MATCH ... RETURN b LIMIT 5 binding-table cost is filed
    separately — link the issue number in the PR.
  • Repo conventions: no new broad excepts (the two except Exception around series == text / astype(str) are
    pre-existing), vectorized, pure. No new source file (no coverage-baseline entry needed).

Recommendation: merge once CI is green; nothing blocking found.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ropeBMLJUuy6ViYwy15ud

lmeyerov and others added 2 commits October 4, 2026 01:26
…elog states the one changed answer

Review found the entry filed under the shipped 0.59.1 section, the temporal sniffer still rendering
the whole column before sampling it, the list and mapping probes still rendering columns their
sample already rules out, and the "identical answers" claim false for one shape. Rendering is the
cost, so each probe now renders its sample first: on 100k rows an object column of timestamps goes
from 331 ms to 6 ms for the temporal check and 230 ms to 6 ms for the list check. Answers match
master on 30 adversarial shapes; the one correction -- a column mixing arrays with list-looking
text is no longer read as a list column -- is pinned and stated in the changelog, now under
Development.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
…ers-early-negative-3c

# Conflicts:
#	CHANGELOG.md
@lmeyerov

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Your three suggestions plus my own pass found six items; all six are fixed at 0c741d0, and two of them were larger than reported.

The "identical answers" claim was false, and the CHANGELOG entry was in a shipped release. Both confirmed. The entry has moved from ## [0.59.1 - 2026-10-03] into [Development]. The claim is now a measurement: an A/B of master against this head over 30 adversarial column shapes (nulls first, a failing value past the sample, all-null, empty, shorter than the sample, category, string dtype, bytes, Decimal, nested lists, mixed list and map, tuples, paren text, arrays) gives one divergence, and it is a correction rather than a regression:

master: list_like([np.array([1,2]), "[3, 4]"]) = True
head:                                          = False

Master answered True only because series == text raises on the array and the error was swallowed, which left the real string row undetected. A column mixing arrays with list-looking text is not a list column. Pinned in test_an_array_column_with_list_looking_text_is_not_a_list_column, and stated in the CHANGELOG as the one changed answer.

Sample-first was incomplete, and on the expensive half. The temporal sniffer still ran astype(str) over the whole column before taking head(16) — rendering is the cost being avoided, so the sample is now rendered on its own and the column only when a mode survives. The list and mapping probes now regex their sample before rendering the column too. Measured on 100k rows, warmed, median of the same harness:

column check before after
object timestamps temporal 331 ms 6 ms
object timestamps list-like 230 ms 6 ms
plain strings temporal 157 ms 5 ms
object ints list-like 56 ms 5 ms

No cuDF pin for a compute/gfql/row/ change. Added: the three sniffers answer the same on six column shapes, parametrized pandas and cuDF.

List and mapping engagement rested on timing. Now mechanism-pinned the way the temporal one was: test_the_list_and_mapping_probes_render_only_the_sample counts the rows handed to the regex and asserts the column is never rendered when the sample rules it out.

Bench attribution. Standing: the only arm with an A/A control is the combined 3a+3c run (1908.8 ms against master's 3297.8 and 3244.8, A/A spread 1.00 to 1.03), and the threshold is a master times 1.5 regression bound rather than an improvement pin. I am not claiming an isolated 3c number from it; the four local before/after pairs above are what this PR stands on.

Also fixed from your list: the comment run the density guard flagged in ordering.py is one line.

… receipt measured it

My cuDF parametrization asserted pandas' answer for a mapping column. cuDF holds mappings as a
struct column and cudf 26.02 cannot render a struct to text, so the temporal and list probes raise
there; master raises identically. The divergence is now its own pin instead of a shared row that
could never pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_012Me1E7ZdDuGqJGu3mMEzhp
@lmeyerov

lmeyerov commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

GPU receipt at the current head.

dgx-spark, GB10, graphistry/test-rapids-official:26.02-gfql-polars, head c9e75d5:

graphistry/tests/compute/gfql/row/test_sniffers_sample_first_3c.py
graphistry/tests/compute/gfql/test_engine_polars_gpu.py
  -> 92 passed, 0 failed   (EXIT=0)

The first pass at 0c741d0 failed one cell, and it was my own test's fault rather than the code's: the cuDF parametrization asserted pandas' answer for a mapping column. Probing a master-tip tree and this head side by side on the same GPU gives the identical result on both:

maps: temporal=RAISE NotImplementedError  list=RAISE NotImplementedError  map=True

cuDF holds mappings as a struct column and cudf 26.02 cannot render a struct to text, so the text probes raise there on master too. The shared row could never pass, so at c9e75d5 it is replaced by a per-engine pin of the real divergence: pandas renders and declines, cuDF raises, and the mapping probe answers True on both.

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