Repository navigation
GFQL: avoid spurious entity-text stringification of returned entities (return structured/Arrow frames) #1650
Description
Summary
GFQL's Cypher result path materializes node/edge entities as Cypher display strings ("entity-text", e.g. (:Label{id: 1, val: 61, kind: 'b'})) instead of as structured/Arrow values. Benchmarks show this row-wise stringification is the single largest framework cost for whole-entity returns — and worse, it runs spuriously in paths whose output never contains entity-text at all (e.g. where_rows). This issue proposes design-level fixes so we stop stringifying entities we don't need to.
Evidence
Benchmarks on a quiet box, synthetic graph (nodes = edges/10), median-of-5 wall-clock; per-layer split via cProfile self-time. Full data: variant ladder + layer decomposition (MATCH ... RETURN ... vs direct API).
1. RETURN a returns a single string column, not a node table.
g.gfql([n({"val": gt(50)})]) # -> (980, 3) node TABLE: id, val, kind (cheap)
g.gfql("MATCH (a) WHERE a.val>50 RETURN a") # -> (980, 1) STRINGS: "({id:1, val:61, kind:'b'})"
g.gfql("MATCH (a) WHERE a.val>50 RETURN a.val") # -> (980, 1) plain val column (cheap)Entity-text rendering (str-render + temporal/escape regex) is the dominant layer of RETURN a:
| engine | RETURN a wall @1m |
entity-text share | engine-compute share |
|---|---|---|---|
| pandas | 181 ms | 56% | 36% |
| cudf | 100 ms | 27% | 48% |
| polars | 27 ms | ~0% (native pl.concat_str) |
46% |
RETURN a.val (no entity-text) is ~6× cheaper than RETURN a on pandas (28 vs 181 ms @1m).
2. Spurious stringification: where_rows renders entity-text it then discards.
g.gfql([rows(), where_rows(expr="val > 50")]) returns a plain 3-column table ({id:51, val:51, kind:'a'}) — yet on a 10k-node filter it fires 1,600,060 re.Pattern.fullmatch calls plus pandas string-array ops, via graphistry/compute/gfql/row/entity_text.py + entity_props.py (series_str_match, _normalize_temporal_constructor_series, numeric/temporal masks). Layer split @1m: 86% of the where_rows call (pandas) / 81% (cudf) is string rendering whose result is never emitted. Polars: ~0%.
| engine | where_rows wall @1m |
string-render share |
|---|---|---|
| pandas | 238 ms | 86% |
| cudf | 192 ms | 81% |
| polars | 3.1 ms | ~0% |
So on pandas/cudf, where_rows is ~30× slower than the equivalent filter_dict matcher and ~600–1500× the raw frame-op floor — almost entirely spurious stringification.
Root cause
Entity values are materialized as Cypher display text rather than as structured data, and that rendering is reached from row-pipeline paths regardless of whether the final result actually projects entity-text. The text form is a presentation format (matching cypher-shell / the conformance oracle), not a data requirement. The native graph API already returns structured node/edge tables and pays none of this.
Proposed design-level solutions (discussion)
- Structured / Arrow-native entity returns. Represent
RETURN aas a struct columna: {id, val, kind}(Arrow struct — faithfully "one return item = one column of node values", lossless, ~free to build) or as flatteneda.id, a.val, ...columns. Eliminates the entity-text layer for whole-entity returns; output is directly usable without re-parsing a string. - Don't render entity-text in paths that don't emit it. Gate the row-pipeline so
where_rows/ interior ops never invoke entity formatting (thewhere_rowscase above is pure waste regardless of Replace NaNs with nulls since node cannot parse JSON with NaNs #1). - Opt-in
return_mode("text"default for cypher-fidelity vs"frame"/"struct"), so the structured path is available without breaking the conformance/TCK oracle, which currently expects the rendered text form (the main constraint on changing the default). - Interim, if we keep text: vectorize the renderer and dedup before rendering — measured ~10× over-render today (the entity-text is built over the ~490k pre-dedup match intermediate, then deduped to ~49k); and dtype-gate the temporal/float regexes (numeric columns can't match). These are stopgaps; Replace NaNs with nulls since node cannot parse JSON with NaNs #1–Don't columns to rename them, use
bindingsto refer to them instead #2 are the real fix.
Why it matters
For whole-entity / filter cypher queries on pandas & cudf, framework stringification — not engine compute — is the bottleneck, and a chunk of it (the where_rows case) produces output that's thrown away. Structured returns would be faster and more useful (no string to re-parse). Polars already sidesteps the perf hit via native vectorized rendering, but even there the structured form would be the better contract.
Repro
import pandas as pd, graphistry
from graphistry.compute.ast import n, rows, where_rows
from graphistry.compute.predicates.numeric import gt
nn=10000
nd=pd.DataFrame({"id":range(nn),"val":[i%100 for i in range(nn)],"kind":[["a","b","c"][i%3] for i in range(nn)]})
ed=pd.DataFrame({"s":[i%nn for i in range(100000)],"d":[(i*7)%nn for i in range(100000)]})
g=graphistry.nodes(nd,"id").edges(ed,"s","d")
print(g.gfql([rows(), where_rows(expr="val > 50")])._nodes.head()) # plain table out...
# ...but cProfile shows ~1.6M re.fullmatch via row/entity_text.py + entity_props.pyCode touchpoints: graphistry/compute/gfql/cypher/result_postprocess.py (_format_node_entities), graphistry/compute/gfql/row/entity_text.py, graphistry/compute/gfql/row/entity_props.py, row-pipeline projection planner.
Activity
perf(gfql): dtype-gate numeric columns out of spurious string-detecti…
feat(gfql #1650): structured flattened whole-entity returns
test(gfql): update whole-entity RETURN tests to structured-flatten fo…
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
docs(gfql): regression-guard the absent-entity text fallback (#1650)
test(gfql): guard both fast-path shapes fire policy hooks (#1650)
test(gfql): guard both fast-path shapes fire policy hooks (#1650)
feat(gfql #1650): structured flattened whole-entity returns
test(gfql): update whole-entity RETURN tests to structured-flatten fo…
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
docs(gfql): regression-guard the absent-entity text fallback (#1650)
feat(gfql #1650): structured flattened whole-entity returns
test(gfql): update whole-entity RETURN tests to structured-flatten fo…
test(gfql): guard both fast-path shapes fire policy hooks (#1650)
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
15 remaining items
refactor(gfql): single source of truth for the dtype-gate kind set
feat(gfql #1650): structured flattened whole-entity returns
test(gfql): update whole-entity RETURN tests to structured-flatten fo…
fix(gfql #1650): dedup whole-entity flat columns vs property projecti…
refactor(gfql #1650): tighten result-projection typing + trim comments
Merge pull request #1656 from graphistry/dev/gfql-1650-structured-ret…
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
docs(gfql): regression-guard the absent-entity text fallback (#1650)
perf(gfql): dtype-gate temporal-text detection to avoid spurious stri…
feat(gfql/polars): native lazy Polars engine, reconciled onto #1656 s…
docs(gfql): regression-guard the absent-entity text fallback (#1650)
review: strengthen EXISTS whole-entity RETURN regression test
lmeyerov commentedon Jul 7, 2026
Verified solved on HEAD (branch dev/gfql-cypher-std-conformance, 2026-07-06) — all four proposed solutions shipped across the prior #1650 sessions. Closing with evidence.
HEAD repro of the issue claims:
MATCH (a) WHERE a.val>50 RETURN a→ flat structured columnsa.id, a.val, a.kind(proposal 1: flatteneda.*emission,result_postprocess._flat_entity_columns; flat-not-struct because pandas cannot group/sort struct columns).[rows(), where_rows(expr=...)]on the issue repro → 1re.fullmatchcall total (was ~1.6M) (proposal 2: no rendering in non-emitting paths).RETURN a/RETURN DISTINCT a/ORDER BY+LIMIT/where_rows/ groupedcount(a): entity-text work ≈ 0 (≤2 trivial helper calls, ≤4 fullmatch), all outputs structured.Where the design landed:
alias.fieldcolumns are the default result form (_apply_result_projection_pandas,structured=True).render_entity_text()— used by the conformance/TCK oracle; the data path never pays it (proposal 3, inverted: structured is default rather than a mode flag).is_non_textual_scalar_dtype,result_postprocess.py) (proposal 4).Remaining text renders — all legitimate, none spurious:
gfql_unified.pystructured=False): the text value is consumed by the alignment machinery (regression-guarded), rare path.properties(n): renders text to emit the requested map-text output (correct output, could someday be more direct — not waste).Perf residual for whole-entity/point queries is NOT stringification anymore — it is the O(N) row-pipeline pass tracked in #1670 (being worked next).
fix(gfql): null-fill whole-entity projection emits #1650 flattened co…