Count Pushdown Rule

Rule name: count_pushdown (on by default, eighth in the pipeline).

What it rewrites

A count() directly behind a leading v() or e() is fused into the start step, which then computes the number without producing a traverser per element:

v().count()                        →   v().count()            (one fused step)
e().has_label("knows").count()     →   e(labels: ["knows"]).count()
v("1", "2", "99").count()          →   v("1", "2", "99").count()

How the fused step counts:

Start stepSource of the number
v() / e() without ids or labelsthe graph's element count, no iteration at all
with labels (v(labels: ...))the label index; a vertex carrying several of the labels counts once
with ids (v("1", "2"))one lookup per id; ids that do not exist (or, with labels, whose element carries none of them) are not counted

The count() does not have to be the last step: v().count().is(6) fuses as well, and the following steps run on the single count value.

Why

v().count() on an unfiltered graph becomes a constant-time read instead of a scan.

When it does not fire

  • Any step between the start step and count() that was not folded into the start step: v().out().count(), v().has("age", P.gt(30)).count(), v().as("a").count(), a second has_label.
  • The traversal does not start with v()/e().
  • count(Scope.local): it counts the elements of a collection, not the stream.
  • The start step was already fused with a count per label by Group Count Pushdown.

Example

On the large graph (111110 vertices):

$ graphersal --graph large -e 'g.v().count().profile()'
Traversal Metrics
Step                                                         Call      In     Out       Time    % Dur
=====================================================================================================
v().count()                                                     1       0  111110    3.125µs     4.45
                                                      TOTAL:             execute:   70.208µs     4.45
=====================================================================================================
Optimizer rules applied: count_pushdown

$ graphersal --graph large -e 'g.with("optimizer.disabled", ["count_pushdown"]).v().count().profile()'
Traversal Metrics
Step                                                         Call      In     Out       Time    % Dur
=====================================================================================================
v()                                                             1       0  111110    1.557ms    98.25
count()                                                         1  111110       1        0ns     0.00
                                                      TOTAL:             execute:    1.585ms    98.25
=====================================================================================================

The fused step's Out column shows the counted number (111110), not the one traverser that carries it.

On the modern graph, combined with Source Filter Pushdown:

$ graphersal -e 'g.e().has_label("knows").count().profile()'
Traversal Metrics
Step                                                         Call      In     Out       Time    % Dur
=====================================================================================================
e(labels: ["knows"]).count()                                    1       0       2    1.667µs     5.31
                                                      TOTAL:             execute:   31.417µs     5.31
=====================================================================================================
Optimizer rules applied: source_filter_pushdown, count_pushdown

A count that is not directly behind the start step stays a separate step:

$ graphersal -e 'g.v().has("age", P.gt(30)).count().profile()'
Traversal Metrics
Step                                                         Call      In     Out       Time    % Dur
=====================================================================================================
v()                                                             1       0       6    1.875µs     4.97
has("age", P.gt(30))                                            1       6       2    5.375µs    14.25
count()                                                         1       2       1        0ns     0.00
                                                      TOTAL:             execute:   37.708µs    19.23
=====================================================================================================

Results

Unchanged: the fused step returns the same number as the scan and the count (4 for g.v().has_label("person").count(), with and without the rule).

In a child traversal that receives several traversers at once, every incoming traverser stands for its own pass over the graph, so the fused count is multiplied by the number of incoming traversers, as the unfused plan counts them: g.v().has_label("software").union(__.v().count()) returns 12 (2 x 6) either way.

Interaction with other rules

Runs after Source Filter Pushdown and Has ID Pushdown, whose folded labels and ids it keeps, and after Group Count Pushdown, which it leaves alone. Where Unnest and Filter Reorder indirectly widen its reach: v().where(__.has_label("person")).count() ends up as v(labels: ["person"]).count().