Summary
_two_hop_cached_equal_domain_degree_counts caches degree frames by setattr-ing onto the caller's Plottable, keyed by id(nodes_obj)/id(edges_obj). After an in-place mutation of the caller's node/edge frame, the next gfql() on the same Plottable returns the stale answer, silently.
This is the exact failure the same file warns against, five times, forty lines above (gfql_fast_paths.py:206-208, and at :245, :282, :319, :373):
"Per-execution cache only (threaded via cache_store); NEVER setattr onto the caller's Plottable -- that leaked results across gfql() calls keyed by id(), returning stale answers after an in-place edge/node mutation (BLOCKER 1)."
_two_hop_cached_equal_domain_degree_counts (gfql_fast_paths.py:490) does precisely that.
Reproduction — verified on origin/master 1537e4676
dgx-spark, graphistry/test-rapids-official:26.02-gfql-polars, --gpus all, master tree mounted read-only. Query is the canonical two-hop count (equal node domains, equal edge match):
Q = ("MATCH (a {node_type:'Person'})-[{rel:'FOLLOWS'}]->(b {node_type:'Person'})"
"-[{rel:'FOLLOWS'}]->(d {node_type:'Person'}) RETURN count(*) AS numPaths")
nodes = pd.DataFrame({"id":[0,1,2,3], "node_type":["Person"]*4})
edges = pd.DataFrame({"src":[0,1,1], "dst":[1,2,3], "rel":["FOLLOWS"]*3})
g = graphistry.nodes(nodes,"id").edges(edges,"src","dst")
g.gfql(Q) # -> 2 (correct)
edges.loc[2,"rel"] = "BLOCKS" # in-place: edge 1->3 is no longer FOLLOWS
g.gfql(Q) # -> 2 *** WRONG, expected 1 ***
graphistry.nodes(nodes,"id").edges(edges,"src","dst").gfql(Q) # -> 1 (ground truth)
Engagement is asserted, not assumed — the memo helper was instrumented, and the run records memo_called=3, memo_HIT=1 (a genuine cache hit, not a coincidental value match):
| engine |
memo called / HIT |
before |
after in-place mutation |
fresh Plottable, same frames |
polars |
3 / 1 |
2 |
2 |
1 |
pandas |
3 / 1 |
2 |
2 |
1 |
polars-gpu |
3 / 1 |
2 |
2 |
1 |
All engines, not just polars — there are two call sites in _execute_two_hop_count_fast_path, one in the POLARS_ENGINES branch (:1910) and one in the else arm (:1960).
Why id() keying cannot work here
id() is stable across in-place mutation (the object is the same; only its contents changed) and is reused after garbage collection. So the key is simultaneously too coarse (misses mutation) and unsound (a new frame can inherit a dead frame's id and collide). The sibling caches avoid both by threading a per-execution cache_store instead of attaching to the caller.
Fix
Bring it to BLOCKER-1 parity: make it a per-execution cache threaded through cache_store rather than setattr onto base_graph.
Disclosure — this fix has a benchmark cost, and that should be stated up front
The cross-call memo is what makes the graph-benchmark q8 cell fast. Measured on dgx (RAPIDS 26.02, --gpus all, perf lock, RUNS=31, position-balanced, values identical across arms):
| scale |
q8 with warm memo |
q8 with memo disabled |
| 20k |
2.11 / 2.32 ms |
11.98 ms |
| 100k |
5.31 / 5.77 ms |
53.84 ms |
And the memo only pays off when a Plottable is reused across calls, which the benchmark harness does but ordinary one-shot use does not:
|
q8 reused Plottable |
q8 fresh Plottable |
Kuzu, same session |
| 20k |
2.31 – 2.74 ms |
14.23 – 21.54 ms |
2.79 – 4.22 ms |
| 100k |
5.60 – 5.87 ms |
52.79 – 53.59 ms |
9.37 – 17.72 ms |
So q8's published win is 6–9× better in the harness than the same query issued one-shot, and one-shot it is a 4–6× loss rather than a win. (q9 is mode-insensitive by contrast — 39.7 vs 39.0 ms at 100k — so that cell transfers.)
Fixing this correctness bug will cost q8 its board cell. That is the correct trade: a cached wrong answer is not a performance feature. Filing it explicitly so the number moves for a stated reason rather than quietly.
Related
Summary
_two_hop_cached_equal_domain_degree_countscaches degree frames bysetattr-ing onto the caller'sPlottable, keyed byid(nodes_obj)/id(edges_obj). After an in-place mutation of the caller's node/edge frame, the nextgfql()on the samePlottablereturns the stale answer, silently.This is the exact failure the same file warns against, five times, forty lines above (
gfql_fast_paths.py:206-208, and at:245,:282,:319,:373):_two_hop_cached_equal_domain_degree_counts(gfql_fast_paths.py:490) does precisely that.Reproduction — verified on
origin/master1537e4676dgx-spark,
graphistry/test-rapids-official:26.02-gfql-polars,--gpus all, master tree mounted read-only. Query is the canonical two-hop count (equal node domains, equal edge match):Engagement is asserted, not assumed — the memo helper was instrumented, and the run records
memo_called=3, memo_HIT=1(a genuine cache hit, not a coincidental value match):Plottable, same framespolarspandaspolars-gpuAll engines, not just polars — there are two call sites in
_execute_two_hop_count_fast_path, one in thePOLARS_ENGINESbranch (:1910) and one in theelsearm (:1960).Why
id()keying cannot work hereid()is stable across in-place mutation (the object is the same; only its contents changed) and is reused after garbage collection. So the key is simultaneously too coarse (misses mutation) and unsound (a new frame can inherit a dead frame's id and collide). The sibling caches avoid both by threading a per-executioncache_storeinstead of attaching to the caller.Fix
Bring it to BLOCKER-1 parity: make it a per-execution cache threaded through
cache_storerather thansetattrontobase_graph.Disclosure — this fix has a benchmark cost, and that should be stated up front
The cross-call memo is what makes the graph-benchmark q8 cell fast. Measured on dgx (RAPIDS 26.02,
--gpus all, perf lock, RUNS=31, position-balanced, values identical across arms):And the memo only pays off when a
Plottableis reused across calls, which the benchmark harness does but ordinary one-shot use does not:PlottablePlottableSo q8's published win is 6–9× better in the harness than the same query issued one-shot, and one-shot it is a 4–6× loss rather than a win. (q9 is mode-insensitive by contrast — 39.7 vs 39.0 ms at 100k — so that cell transfers.)
Fixing this correctness bug will cost q8 its board cell. That is the correct trade: a cached wrong answer is not a performance feature. Filing it explicitly so the number moves for a stated reason rather than quietly.
Related