Skip to content

shortestPath benchmark — 500K nodes / ~3.97M edges ​

Single-process traversal latency at scale. Generated by:

go test ./pkg/cypher/ -run TestLargeScaleShortestPath_HopBuckets \
    -timeout 30m -largescale -v

Hardware: Apple M3 Max. Storage chain: BadgerDB → AsyncEngine → NamespacedEngine → StorageExecutor (the same chain the HTTP server uses).

Setup ​

  • 500,000 Star nodes across 1,000 sectors (500 per sector)
  • 3,969,418 HYPERLANE edges (forward+reverse), avg ~16 per node
  • Direct BulkCreateNodes / BulkCreateEdges, property index built after bulk insert + count verify (mirrors a typical "load → index → query" workflow)
  • Reference BFS over 200 random sources to bucket pairs by exact hop distance
  • 30 random pairs benched per depth bucket

Seed timings ​

phasetime
build nodes in memory215ms
BulkCreateNodes (500K)234ms
build edges in memory1.25s
BulkCreateEdges (3.97M)1.54s
flush AsyncEngine → Badger3m28s
count verify (500K nodes, 3.97M edges)5µs
build property index (post-flush)157ms
build reference adjacency in test process1.39s
sample depth buckets (200 sources)39ms
total seed wall-clock3m32s

The 3m28s flush is the dominant seed cost — Badger's level-0 / level-1 compaction work for 4M edge writes. Once on disk, queries are read-only and benefit from the per-engine edge body cache and per-node adjacency cache.

Latency table (depths 1–60, 30 samples per bucket) ​

hopsminmedianp95max
117.5µs94.8µs319.5µs328.0µs
215.6µs106.6µs137.0µs342.8µs
319.7µs162.3µs225.5µs229.9µs
421.0µs342.7µs4.6ms6.3ms
522.1µs1.23ms4.97ms36.4ms
621.0µs1.26ms1.57ms1.96ms
718.9µs1.56ms4.98ms16.0ms
822.8µs2.39ms24.6ms39.6ms
919.9µs46.3ms51.7ms53.6ms
1027.1µs44.5ms51.4ms53.2ms
1122.9µs51.7ms57.3ms67.3ms
1221.5µs63.4ms79.6ms85.3ms
1325.5µs76.6ms84.2ms90.8ms
1425.8µs78.9ms89.1ms106.3ms
1523.6µs80.8ms93.1ms93.3ms
1624.0µs97.9ms112.7ms115.6ms
1724.5µs108.8ms113.3ms125.2ms
1830.0µs111.0ms118.7ms131.0ms
1924.3µs121.1ms135.3ms141.1ms
2023.5µs134.2ms146.0ms148.3ms
2125.3µs137.1ms150.0ms161.2ms
2225.0µs147.3ms167.0ms175.1ms
2329.5µs152.4ms162.5ms163.4ms
2423.3µs157.4ms171.3ms172.6ms
2525.5µs180.7ms280.1ms357.7ms
2625.6µs181.4ms207.3ms301.1ms
2723.7µs182.0ms192.6ms195.4ms
2827.1µs188.7ms202.5ms210.3ms
2925.8µs211.4ms232.3ms236.4ms
3024.0µs210.9ms223.5ms229.4ms
3123.4µs212.3ms234.7ms237.9ms
3222.5µs218.8ms239.4ms242.9ms
3323.5µs241.4ms260.9ms268.6ms
3424.0µs239.3ms254.6ms255.3ms
3526.6µs246.1ms259.4ms264.6ms
3624.0µs275.2ms290.6ms296.0ms
3724.9µs274.5ms289.1ms293.8ms
3824.4µs277.5ms292.0ms299.0ms
3926.1µs282.4ms297.4ms301.7ms
4028.8µs612.7ms640.3ms642.4ms
4126.8µs614.3ms644.0ms652.3ms
4224.5µs618.7ms648.0ms652.6ms
4325.0µs640.0ms665.9ms666.9ms
4427.7µs694.4ms781.8ms800.9ms
4526.0µs684.7ms725.2ms732.4ms
4625.5µs683.1ms704.1ms710.8ms
4725.8µs709.6ms738.5ms751.8ms
4826.7µs751.3ms867.6ms897.4ms
4924.6µs764.2ms940.3ms984.9ms
5025.3µs770.0ms794.9ms798.8ms
5125.5µs789.7ms835.4ms854.2ms
5228.7µs818.3ms864.7ms872.7ms
5325.4µs817.7ms845.3ms884.1ms
5425.4µs821.3ms856.9ms862.5ms
5526.4µs879.1ms1.02s1.03s
5625.5µs1.00s1.08s1.11s
5725.0µs976.8ms1.08s1.17s
5825.6µs972.6ms1.12s1.13s
5928.7µs1.01s1.06s1.08s
6025.4µs1.02s1.05s1.07s

Observations ​

Three distinct latency regimes show up clearly:

  1. Depths 1–4: sub-millisecond. Median 95µs–340µs. BFS visits a few hundred nodes; everything fits in the adjacency cache and the edge body cache.
  2. Depths 5–8: 1–3ms median, jumpy tail. Mid-range traversals where the working set just barely exceeds what was cached from earlier benches; tail latencies (p95/max) are noisy because individual queries hit cold subgraphs.
  3. Depths 9+: linear scaling with hop count, ~14ms per hop through depth 39, then a 2× discontinuity at depth 40. The median jumps from 282ms → 613ms.

The depth-40 discontinuity ​

The cliff is consistent with cache-budget exhaustion. The default nodeCacheMaxEntries is 50,000. At depth ~40 over a graph of avg degree 16, BFS visits roughly 40 × 5K avg breadth ≈ 200K nodes — well past the 50K node-cache budget. Beyond that point every neighbor visit pays a fresh Badger iterator hit + edge body decode rather than serving from cache.

Tunable via BadgerEngineOptions.NodeCacheMaxEntries if you want sub-100ms response at depth 60+; raise it past the working set size or split per-node and per-edge cache budgets so a long traversal doesn't evict the path it just walked.

The min column is misleading ​

The bench's first iteration in each bucket warms the executor's SmartQueryCache (60s TTL). Subsequent iterations with the same (cypher, params) shape — which several queries within a bucket happen to share by coincidence of the random pair sampler — short-circuit through that cache, hence the consistent ~25µs floor across all depths. The median is the honest signal; p95/max show how cache misses look at scale.

Practical takeaway ​

For a 500K-node, ~4M-edge graph, this delivers:

  • Sub-millisecond shortestPath at depths 1–4
  • Single-digit milliseconds at depths 5–8
  • Linear ~14ms-per-hop through depth ~40
  • ~1s for paths spanning the full sector chain (depth 60)

All of this is in-process; HTTP/Bolt overhead adds the usual 1–2ms on top.

Reproducing ​

sh
go test ./pkg/cypher/ -run TestLargeScaleShortestPath_HopBuckets \
    -timeout 30m -largescale -v

The -largescale flag is required; without it the test skips. Depth ceiling is LARGESCALE_MAX_DEPTH=60 by default — set it lower to spend less time on long-tail buckets while iterating.

Analytics privacy