Skip to content

VLE: cache edge/vertex classification and prune PATHS_BETWEEN via reverse BFS - #2515

Open
lkozeev wants to merge 9 commits into
apache:masterfrom
lkozeev:master
Open

VLE: cache edge/vertex classification and prune PATHS_BETWEEN via reverse BFS#2515
lkozeev wants to merge 9 commits into
apache:masterfrom
lkozeev:master

Conversation

@lkozeev

@lkozeev lkozeev commented Aug 13, 2026

Copy link
Copy Markdown

VLE: cache edge/vertex classification and prune PATHS_BETWEEN via reverse BFS

Problem

dfs_find_a_path_between() (PATHS_BETWEEN / shortestPath-style [:REL*min..max],
both endpoints known) did this on every visit to a vertex, regardless of prior visits:

  • Adjacency re-scanned across every edge label on every visit, not just the pattern's
    — the graph context carries no label-based pre-filtering. For each edge in that array,
    two hashtable probes (edge_table, edge_state_hashtable) ran on every visit, even
    though is_an_edge_match()'s verdict was already memoized per edge. Cost:
    O(k · deg_total(v)) instead of O(deg_total(v) + k · deg_match(v)).
  • Edge endpoint re-resolved via hashtable lookup on every traversal step, though
    start_vertex_id/end_vertex_id are fixed at edge-creation time.
  • No lower bound on remaining distance to target — a branch that can't reach it within
    the hop budget was walked in full (O(d^r)) before backtracking, instead of rejected in
    O(1).

No memory bound on any of the above — a pathological query could grow them unbounded for
the duration of the call.

Changes

  1. Parser: pass a previously-bound target vertex through (was hardcoded NULL) — required
    for PATHS_BETWEEN classification to ever trigger.
  2. Cache edge endpoints in edge_state_entry — resolved once, not per traversal step.
  3. Cache per-vertex relevant edges (vertex_edge_cache) — built once per vertex, skips
    non-matching-label edges on every later visit.
  4. Reverse-BFS distance-to-target pruning for PATHS_BETWEEN — reject an unreachable
    branch in O(1).
    5–6. edge_state_entry: remove struct padding; bitpack 3 bools into one uint8.
  5. GUC-bounded caches + clock eviction (age.vle_*); fails open under pressure (never
    wrong, only less pruning).
  6. Bug fixes: pointer-vs-size Assert, wrong function names in error messages.

No change in visible behavior is expected.

Results

Env: AMD Ryzen 5 5500U (6c/12t), 16 GiB RAM, boost off, governor=performance. PG18
736d880 vs. vanilla PG18+AGE. SNB graph via generate_graph.sql (gsmall=SF1,
gmid=SF10).

ShortestPath_hardMATCH path=(p1)-[:KNOWS*1..5]-(p2) ... min(length(path))

Cluster C/W TPS before→after (×) Latency before→after (×) Peak mem Δ
gsmall 1/1 0.2→10.6 (53.2x) 4,737→93.9ms (50.5x) 83→89MB (+7%)
gsmall 6/6 1.22→62.8 (51.6x) 4,752→94.8ms (50.1x) 312→340MB (+9%)
gsmall 6/12 1.43→78.4 (54.7x) 7,643→152.3ms (50.2x) 558→650MB (+16%)
gsmall 8/16 1.45→77.4 (53.4x) 9,964→204.6ms (48.7x) 694→872MB (+26%)
gmid 1/1 0.2→21.1 (105.4x) 4,749→47.2ms (100.7x) 519→422MB (−19%)
gmid 6/6 1.08→105.3 (97.2x) 5,227→56.8ms (92.1x) 2,049→1,473MB (−28%)
gmid 6/12 1.33→128.0 (96.0x) 8,264→93.1ms (88.7x) 3,749→2,705MB (−28%)
gmid 8/16 1.3→126.2 (97.0x) 11,004→121.4ms (87.7x) 4,649→3,509MB (−24%)

Path — same pattern, RETURN path LIMIT 1 (first match, no min aggregation)

Cluster C/W TPS before→after (×) Latency before→after (×) Peak mem Δ
gsmall 1/1 0.25→47.2 (188.7x) 3,861→20.8ms (185.7x) 84→103MB (+23%)
gsmall 6/6 1.23→246.3 (199.7x) 4,613→24.1ms (191.5x) 313→411MB (+31%)
gsmall 6/12 1.63→329.0 (201.4x) 6,809→36.3ms (187.5x) 565→721MB (+28%)
gsmall 8/16 1.63→314.7 (192.7x) 9,003→49.9ms (180.3x) 708→904MB (+28%)
gmid 1/1 0.22→23.6 (108.9x) 4,545→42.1ms (107.9x) 515→423MB (−18%)
gmid 6/6 1.08→111.5 (103.0x) 5,181→53.6ms (96.7x) 2,042→1,473MB (−28%)
gmid 6/12 1.3→133.5 (102.7x) 8,627→89.3ms (96.7x) 3,742→2,699MB (−28%)
gmid 8/16 1.15→129.4 (112.5x) 12,028→121.4ms (99.1x) 4,536→3,509MB (−23%)
image

Reproduce

generate_graph.sql
shortest_path.sql
path.sql

psql -d postgres -f generate_graph.sql -v sf=1
pgbench -d postgres -f shortest_path.sql -c 12 -j 6 -T 60 -s 1 -n -P 1
pgbench -d postgres -f path.sql          -c 12 -j 6 -T 60 -s 1 -n -P 1

New GUCs

GUC Responsibility
age.vle_edge_state_htab_initial_size Initial bucket count for the vle_edge_state cache, default 16384
age.vle_vertex_edge_htab_initial_size Initial bucket count for the vertex_edge_cache, default 1024
age.vle_edge_state_max_entries Soft cap on live entries in the vle_edge_state cache before background eviction of unreferenced entries kicks in, default 2000000
age.vle_vertex_edge_cache_max_entries Soft cap on live entries in the vertex_edge_cache before background eviction of unreferenced entries kicks in, default 200000
age.vle_vertex_edge_cache_max_kb Soft cap, in kilobytes, on the memory vertex_edge_cache adjacency arrays, default 65536
age.vle_reverse_dist_max_entries Cap on the reverse-BFS distance table used to prune PATHS_BETWEEN VLE queries, default 500000
age.vle_max_cached_contexts Maximum number of VLE_local_context objects, default 5
age.vle_edge_state_eviction_enabled Enable clock-style eviction of unreferenced entries in the vle_edge_state cache, default true

AI assistance

Used throughout; no clean split of responsibility is possible to state. All code comments
were AI-generated.

lkozeev added 9 commits July 31, 2026 11:50
… NULL

Bypasses dead code that always forced NULL, allowing VLE execution to extract the ID and optimize path finding.
The VLE DFS previously called get_edge_entry_with_hash() and
get_next_vertex() for every edge it accepted.  The edge's start
and end vertices are stable, so this lookup is unnecessary once
the edge has been examined.

Add start_vertex_id and end_vertex_id to edge_state_entry and
populate them when an edge is classified for the first time.
Add get_next_vertex_from_state() as a cache-based counterpart to
get_next_vertex(), and use it in dfs_find_a_path_between() and
dfs_find_a_path_from().

For CYPHER_REL_DIR_NONE the next vertex depends on the endpoint
currently on the DFS stack.  Preserve the existing vertex-stack
logic, but read the endpoints from edge_state_entry instead of
fetching the edge_entry again.  Zero the new fields on a fresh
edge_state_entry and add assertions for the undirected traversal
stack invariant.

No path-order or result change is intended.
add_valid_vertex_edges() previously re-walked a vertex's adjacency
arrays and re-looked up edge entries on every visit.  The result
of is_an_edge_match() is static for a VLE_local_context, while only
used_in_path and is_edge_in_path depend on the current DFS path.

Introduce a vertex_edge_cache hash table mapping a vertex id to the
list of adjacent edges that pass the static edge match.  Build the
list on the first visit to the vertex and reuse it on later visits.
The dynamic checks are still performed for every visit, but only
for edges that are already known to be statically valid.

Allocate the cached edge arrays in a dedicated child memory context
and free that context in free_VLE_local_context().  Keep the batched
lookup pipeline and the original out/in/self processing order so the
DFS stack order and generated paths are unchanged.

This can reduce repeated adjacency scans and edge-table lookups,
especially when a VLE_local_context is reused across many SRF calls.
PATHS_BETWEEN is the only VLE mode with a fixed target vertex, so
DFS branches can be pruned when they cannot reach that target within
the remaining hop budget.

Add a lazy reverse-BFS from the target vertex.  It computes shortest
distances to the target over matched edges, ignoring edges already
used by the current DFS path.  These distances are therefore safe
lower bounds on the remaining path length.

In add_valid_vertex_edges(), resolve the candidate next vertex from
the cached edge endpoints and look up its reverse distance.  Skip
the candidate if it is unreachable or if the current depth plus one
plus the reverse distance exceeds the upper bound.

The reverse-distance table and BFS queue are target-specific and are
kept in a dedicated memory context.  The queue is a power-of-two
circular buffer that grows on demand and shrinks when later targets
need a much smaller frontier.

No user-visible behavior change is intended other than performance.
has_been_matched stored, matched in a single uint8 field.
These replace three separate booleans to save space in the hash table.
The VLE edge-state, vertex-edge, and reverse-distance caches grew
unboundedly with the edges and vertices touched during traversal,
which could exhaust backend memory on dense, hub-heavy graphs with
low-selectivity predicates.

Add age.vle_* GUCs capping each cache and reclaiming entries with a
clock sweep once the cap is exceeded.  edge_state_hashtable now holds
only matched edges and tracks a per-entry pin_count (live occurrences
on the DFS edge stack) so an in-use entry is never evicted; evicted
entries are rebuilt transparently on next access.  The vertex-edge
cache is also bounded by allocated bytes to cover high-degree hubs.
The reverse-BFS distance table, when capped, degrades to fail-open
(skip pruning) rather than claiming unreachability, so a memory limit
can never discard valid paths.
and argument types in age_match_vle_edge_to_id_qual() error messages.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant