Skip to content

GFQL: avoid spurious entity-text stringification of returned entities (return structured/Arrow frames) #1650

Description

@lmeyerov

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)

  1. Structured / Arrow-native entity returns. Represent RETURN a as a struct column a: {id, val, kind} (Arrow struct — faithfully "one return item = one column of node values", lossless, ~free to build) or as flattened a.id, a.val, ... columns. Eliminates the entity-text layer for whole-entity returns; output is directly usable without re-parsing a string.
  2. 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 (the where_rows case above is pure waste regardless of Replace NaNs with nulls since node cannot parse JSON with NaNs #1).
  3. 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).
  4. 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 #1Don't columns to rename them, use bindings to 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.py

Code 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions