Group Count Pushdown Rule
Rule name: group_count_pushdown (on by default, seventh in the pipeline).
What it rewrites
A "count per label" directly behind a leading v() or e() is fused into the start step, which
then computes the counts from the graph's label index instead of producing one traverser per element
and grouping them:
v().group_count().by(T.label) → v().group_count().by(T.label) (one fused step)
v().group().by(T.label).by(__.count()) → v().group_count().by(T.label)
e().group_count().by(T.label) → e().group_count().by(T.label)
v().has_label("person").group_count().by(T.label) → v(labels: ["person"]).group_count().by(T.label)
The accepted spellings are group_count().by(T.label), and group().by(T.label) followed by
by(Count) or by a child traversal that is a plain count(). The fused step keeps any ids and
labels that Source Filter Pushdown already folded into the start step.
Why
The counts per label are already known to the label index. Without the rule, the scan creates a traverser for every element and the grouping step reads each element's label and updates a map.
When it does not fire
- The grouping step does not directly follow the start step: anything between them that was not
folded into
v()/e()(out(),has("age", ...), a secondhas_label) stops the rule. - The traversal does not start with
v()/e(). - The key is not
T.label:group_count().by("age"),group_count().by(__.label()). group().by(T.label)without a secondby(), or with a value projection other than a count.- The start step was already fused with a
count()by Count Pushdown (which runs later, so this only matters for hand-built plans).
Example
$ graphersal -e 'g.v().group_count().by(T.label).profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v().group_count().by(T.label) 1 0 1 166.792µs 86.66
TOTAL: execute: 192.459µs 86.66
=====================================================================================================
Optimizer rules applied: group_count_pushdown
Disabled, the scan produces six traversers that the grouping step consumes:
$ graphersal -e 'g.with("optimizer.disabled", ["group_count_pushdown"]).v().group_count().by(T.label).profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v() 1 0 6 1.833µs 1.36
group_count().by(T.label) 1 6 1 106.208µs 78.70
TOTAL: execute: 134.958µs 80.06
=====================================================================================================
Combined with a folded label filter:
$ graphersal -e 'g.v().has_label("person").group_count().by(T.label).profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v(labels: ["person"]).group_count().by(T.label) 1 0 1 5.458µs 16.21
TOTAL: execute: 33.667µs 16.21
=====================================================================================================
Optimizer rules applied: source_filter_pushdown, group_count_pushdown
The rule saves the scan, not the result: the cost of building the result map stays. The large
graph gives every vertex its own label (V_0_0, V_0_1, ...), so the result has 111110 entries,
building it dominates both plans, and the fused plan only saves the few milliseconds of the scan:
$ graphersal --graph large -e 'g.v().group_count().by(T.label).profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v().group_count().by(T.label) 1 0 1 18.220ms 99.83
TOTAL: execute: 18.251ms 99.83
=====================================================================================================
Optimizer rules applied: group_count_pushdown
$ graphersal --graph large -e 'g.with("optimizer.disabled", ["group_count_pushdown"]).v().group_count().by(T.label).profile()'
Traversal Metrics
Step Call In Out Time % Dur
=====================================================================================================
v() 1 0 111110 1.536ms 9.23
group_count().by(T.label) 1 111110 1 15.087ms 90.59
TOTAL: execute: 16.653ms 99.82
=====================================================================================================
Results
Unchanged. Every vertex is counted once, under its primary label, the value label() returns; a
vertex without a label is not counted, with or without the rule. A
multi-label vertex counts once, under its first label:
$ graphersal --graph empty -e 'g.add_v(["Person", "Admin"]).next(); g.add_v("Person").next(); g.add_v().next(); g.v().group_count().by(T.label).to_list()'
#{"Person": 2}
Without a label filter, the fused step reads the per-primary-label counts from the storage's
statistics (GraphStorage::statistics),
which the graph keeps up to date on every mutation: no scan at all, one map entry per label. A
storage that keeps no statistics is scanned instead, with the same result. With a folded label
filter the fused step fetches the matching vertices once each and groups them by primary label.
In a child traversal that receives several traversers at once (union(__.V().groupCount().by(T.label))),
every incoming traverser stands for its own pass over the graph, so the counts are multiplied by
the number of incoming traversers, as in the unfused plan.
Interaction with other rules
Runs after Source Filter Pushdown (whose folded filters it keeps) and
before Count Pushdown. A count() after the fused step is not folded again;
it counts the single map the fused step produces.