Skip to content

perf(gfql): build grouped-aggregate outputs in one pass to keep pandas blocks consolidated - #1922

Open
lmeyerov wants to merge 3 commits into
fix/gfql-1918-hop-semantics-round2from
perf/gfql-q1-block-consolidation
Open

perf(gfql): build grouped-aggregate outputs in one pass to keep pandas blocks consolidated#1922
lmeyerov wants to merge 3 commits into
fix/gfql-1918-hop-semantics-round2from
perf/gfql-q1-block-consolidation

Conversation

@lmeyerov

Copy link
Copy Markdown
Contributor

Recovers the one real regression the whole-stack board A/B found: pandas q1 +11.3% @20k / +6.9% @100k, q2 +10.0% / +6.7% (q2 runs q1's shape as its first leg). polars was flat and every board cell returned byte-identical values, so it was pure overhead.

Bisected to a single 4-line hunk in #1901's first commit. Making a.id AS id survive as an output required moving the key rename() before the per-output column assignments — but rename() returns a consolidated copy, so running it first leaves the later per-column writes creating unconsolidated blocks, and the ~200k-row inner merge then pays per-block take plus _merge_blocks/vstack. Instrumented on the real q1: parent 1 block / 5.58 ms, culprit 2 blocks / 7.63 ms, against a +2.6 ms board delta; cProfile agreed with identical call counts and a byte-identical compiled plan.

Fix: build the projected frame in ONE construction (a dict of Series handed to df_cons) instead of assigning column-by-column after the rename — which is also this repo's standing cheap-write guidance. No private pandas API. The .copy() became unnecessary and is gone. The correctness #1901 added is intact: the key is still renamed away from node_col, so a.id AS id survives and an output named like the node-id column cannot corrupt the join key.

Local microbenchmark (10 interleaved rounds × 9 reps, medians of round medians — not the board; a dgx A/B confirms separately):

shape blocks base → fix lookup merge query total
grouped-agg, int key 2 unconsolidated → 1 consolidated 11.41 → 6.78 ms (−40.6%) 36.72 → 30.01 ms (−18.3%, fix faster 10/10 rounds)
grouped-agg, int + string 3 → 2 consolidated 14.63 → 10.16 ms (−30.6%) 52.58 → 45.49 ms (−13.5%, 10/10)
string key only (control) 2 consolidated → 2 consolidated 9.79 → 9.79 ms −0.3% (ns)
two-star grouped count 3 → 2 consolidated 11.70 → 8.41 ms (−28.1%) −2.0% (7/10)

The string-only row is the honest control: that shape is already consolidated at base, so there is nothing to win — the mechanism confirming itself rather than a uniform speedup. Result-frame sha256 identical base vs fix on all four shapes.

Sibling sweep: all 113 rename(columns=…) sites in graphistry/compute/ checked for a following column-write; exactly two had the pathology (the culprit and the two-star grouped-count twin), both fixed. The polars twins already build in one select/with_columns, consistent with polars measuring flat on the board.

Gates: full-tree failure set byte-identical (md5 matched), fast path still engages, typecheck 329 clean, hygiene guard no growth — the first draft needed a cast() that tripped the explicit-cast guard, fixed by annotating the dict properly rather than raising the cap.

Not claimed: the two-star sibling's query-level −2.0% is weak and shouldn't be quoted (its merge-level −28% and block-state change are unambiguous); no cuDF verification on a GPU-less box; board confirmation still outstanding.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi

…s consolidated

#1901 made `a.id AS id` survive as an output by moving the join-key rename()
BEFORE the per-output column assignments. That fixed the KeyError but cost
pandas throughput: rename() returns a CONSOLIDATED copy, so in the old order it
ran last and the lookup entered the ~200k-row inner merge as one block. Running
it first leaves the later column-by-column writes to fragment the frame, and the
merge then pays per-block take plus _merge_blocks/vstack. Whole-board A/B saw
pandas q1 +11.3% @20k / +6.9% @100k and q2 +10.0% / +6.7% (q2 runs q1's shape as
its first leg); polars flat; every cell byte-identical, i.e. pure overhead.

Fix: build the key rename and every projected output in ONE frame construction
(dict of columns -> df_cons(engine)(...)), which consolidates by dtype naturally.
No private pandas consolidation API. Correctness from #1901 is preserved -- the
key is still renamed away from node_col, so `a.id AS id` survives as an output
and an output named like the node-id column cannot corrupt the join key (pinned
in test_count_and_param_semantics.py).

Same treatment for the sibling two-star grouped-count lookup, which had the
identical rename()-then-per-column-write shape on the same hot merge.

Local microbenchmark (NOT the board; a dgx board A/B confirms separately).
200k-edge q1 shape, 10 interleaved process rounds x 9 reps each, this branch tip
(BASE) vs this commit (FIX), medians of round medians; `lookup` block state spied
in situ on pd.DataFrame.merge:

  grouped-aggregate lookup, int group key (`c.bucket`)
    blocks   BASE 2, not consolidated  ->  FIX 1, consolidated
    merge    11.41 ms  ->   6.78 ms   (-40.6%, n=120 each)
    query    36.72 ms  ->  30.01 ms   (-18.3%, FIX faster in 10/10 rounds)
  grouped-aggregate lookup, int + string group keys
    blocks   BASE 3, not consolidated  ->  FIX 2, consolidated
    merge    14.63 ms  ->  10.16 ms   (-30.6%)
    query    52.58 ms  ->  45.49 ms   (-13.5%, 10/10 rounds)
  grouped-aggregate lookup, string group key only
    blocks   BASE 2, consolidated  ->  FIX 2, consolidated  (BASE already lands
             consolidated here -- a single object column self-assigned)
    query    43.75 ms  ->  43.63 ms   (-0.3%, no change, as expected)
  two-star grouped count, two string group keys (120k-edge shape)
    blocks   BASE 3, not consolidated  ->  FIX 2, consolidated
    merge    11.70 ms  ->   8.41 ms   (-28.1%, n=100 each)
    query    82.42 ms  ->  80.76 ms   (-2.0%; the merge is a small slice of this
             query, so the total-level win is weak -- 7/10 rounds)

Values identical before and after on every fixture measured (sha256 of the
canonicalized result frame matches for all four shapes).

Gates on this GPU-less box: graphistry/tests/compute/gfql -> 96 failed, 8809
passed, 707 skipped, 32 xfailed on BOTH the branch tip and this commit, with a
byte-identical FAILED set (md5 19e4fee752c39848ff2c6db122249d14). gfql/cypher
-k "not cudf" 3306 passed / 0 failed. test_compute_chain.py + test_compute_hops.py
138 passed, 2 failed (both cudf-only). ruff, mypy (329 files), type-hygiene and
cypher-surface guards all clean. The grouped-aggregate fast path still ENGAGES
(test_fast_path_engagement.py green).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
@lmeyerov

Copy link
Copy Markdown
Contributor Author

Board A/B: 18 slots per build per scale (108 slots, 1944 cell measurements), frozen builds sha-verified byte-exact against their git blobs, --network none, zero errors. Values identical in all three builds, every cell, every slot.

Verdict: recovers, but I will not claim provably 100%.

scale cell TIP vs BASE FIX vs BASE (recovery) FIX vs TIP (help)
100k pandas q1 +8.1% SIG +2.8% p=0.34 ns −4.9% SIG
100k pandas q2 +7.6% SIG +2.7% p=0.38 ns −4.6% SIG
20k pandas q1 +15.4% SIG +2.9% p=0.85 ns −10.9% SIG
20k pandas q2 +14.5% SIG +4.1% p=0.49 ns −9.1% SIG
both polars q1/q2 (control) ns ns ns — flat within ±2.2%

FIX vs BASE is statistically indistinguishable from zero in all four pandas cells, and FIX vs TIP is a large Bonferroni-significant improvement. Honest caveat: every FIX-vs-BASE point estimate sits positive (+2.7% to +4.1%) and the bootstrap recovery fraction is 65% [56,112] / 65% [54,109] at 100k, so a residual up to ~+3.5% at 100k cannot be excluded. What the data firmly excludes is anything near the original 8%/15%. The defensible statement is "recovered to within noise, with a possible ~3% residual at 100k" — not "fully recovered".

The regression reproduced somewhat larger than first measured (+8.1/+7.6 @100k vs +6.9/+6.7; +15.4/+14.5 @20k vs +11.3/+10.0), consistent with the known cross-session drift of intermediate heads.

q5–q9 @100k, FIX vs TIP: all ten cells ns. The two-star hunk did not perturb q5/q6/q7 — which are exactly its shape — and also produced no measurable win there, consistent with its weak local result. The one cell worth a re-check is pandas q6 @20k (FIX vs TIP +1.3%, uncorrected-sig only, ns under Bonferroni; +0.8% ns at 100k).

Separate finding, not this PR's: polars q8 shows TIP vs BASE +7.8% SIG @100k with FIX vs TIP ns — a stack-level residual this change neither causes nor fixes. That is the known trail-correctness cost (self-loop exclusion from count(*)), previously measured at +3.7% after the #1907 optimizations; the larger figure here needs a re-measure at the final tip before release rather than being quoted as-is. Favourable movers, unattributed: polars q9 −4.8% SIG @100k and −12.1% @20k, polars q6 −9.3% @20k.

lmeyerov and others added 2 commits August 15, 2026 11:31
…onsolidation branch

CI was red on three lanes -- test-core-python (3.11),
test-pandas-compat-gfql (legacy, py3.9) and test-gfql-core (3.14) -- with the
same two failures in every lane:

  FAILED graphistry/tests/compute/test_hop.py::TestMultiHopForward::
    test_hop_fixedpoint_undirected_does_not_revisit_seed_via_same_edge
    AssertionError: assert {'a', 'b', 'c', 'd', 'e'} == {'b', 'c', 'd', 'e'}
  FAILED graphistry/tests/compute/test_hop.py::TestMultiHopForward::
    test_hop_fixedpoint_undirected_excludes_unrediscovered_seeds_in_disconnected_components
    AssertionError: assert {'a', 'b', 'x', 'y'} == {'b', 'y'}

One root cause, not three, and not the version skew the three-Python spread
suggested: the failure is identical on py3.9/legacy-pandas, py3.11 and py3.14,
and it reproduces on py3.12 locally.

This branch was cut from 98c0441, an intermediate #1918 state in which the
undirected fixed-point wavefront re-admitted the seed. The base branch has since
advanced to 0ddf565, which reconciles the wavefront onto to_fixed_point and
restores the seed-exclusion semantics those two pins assert. The branch never
picked that up, so it carried the pre-fix hop.py under post-fix pins.

The pins are correct as written and are left untouched: hopping undirected to a
fixed point from seed {a} over the chain a-b-c-d-e must return the wave front
{b, c, d, e}; `a` is only re-admitted if some edge rediscovers it, and the sole
edge incident to `a` is the one already traversed to leave it.

Merge is clean. The branch's own content delta against base is unchanged --
still exactly the 19/10 line block-consolidation edit in gfql_fast_paths.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
Cascading base update after #1920 took its base and, critically, finally
picked up the 23 commits it was behind. ZERO conflicted files -- the earlier
abort on this branch was correct: the hop.py conflict belonged in #1920, and
once resolved there this merge is mechanical.

This is the merge that should clear the test-gfql-core (3.12) coverage-floor
failure: the temporal test files the per-file floors need are now on the head
(tests/compute/gfql/cypher/test_temporal_arithmetic_folding_branches.py,
tests/compute/gfql/cypher/test_native_temporal_resolution.py) along with
tests/compute/gfql/coverage_baselines/ci-pandas-py3.12.json.

Gates: no conflict markers, ruff clean, type-hygiene guard clean, cypher
surface guard pass, mypy shows only the 4 known polars-skew errors.
The two undirected-fixed-point regression detectors in test_hop.py pass.
Fast-path + hop-semantics + boundary-matrix + temporal + trail suites =
1048 passed, 1 failed ([cudf], no GPU here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MF7uRZLKZaD6Q9FGWSmyXi
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