Skip to content

Capacity and performance

Fallen-8 keeps the whole graph resident, so two questions come before any other: how much memory does my graph need, and how fast can I write to it. This page answers both, and it answers them the only way a capacity question can honestly be answered: with a measurement, from a named machine, that you can repeat on yours.

The numbers below are not hand-written. They are produced by fallen-8-bench, a console tool in the repository that measures memory, write throughput, checkpoint stalls and traversal speed, then writes a result file:

# quick is sized for CI; full uses the larger graphs this page's guidance is really about
dotnet run --project fallen-8-bench -c Release -- --profile full --runner-label "my box"

That writes fallen-8-bench/results/capacity-report.json, conforming to capacity-report.schema.json. The schema is the contract: it carries the metrics and the environment that produced them, because a capacity figure with no hardware attached is a fact about somebody’s laptop, not about Fallen-8. Rendering a report into this page is one command, and a GitHub Action does exactly this:

node scripts/update-capacity-doc.mjs fallen-8-bench/results/capacity-report.json

Run it against your own hardware and compare. If your numbers differ from these by a lot, the machine description below is the first place to look, not the engine.

The numbers on this page come from one recorded run of that tool. They describe that machine:

Machine RYZEN AI MAX
CPU AMD RYZEN AI MAX+ PRO 395 w/ Radeon 8060S, 32 logical processors
Memory 98,074 MB available to the runtime
OS Microsoft Windows 10.0.26200 (X64)
Runtime .NET 10.0.10, server GC on
Engine 0.3.0.0, commit 01416961f1
Profile full
Measured 2026-08-05 20:00 UTC

Measured as retained managed heap attributable to the graph, after a forced blocking compacting collection, with the engine’s own fixed cost excluded. Process RSS is higher: it also carries the runtime, the GC’s free space and, in the service, ASP.NET.

Graph Retained Per vertex Per edge (adjacency included)
2,000,000 vertices, 4,000,000 edges (avg degree 2) 823.1 MB 88.0 B 171.8 B
10,000,000 vertices, 100,000,000 edges (avg degree 10) 12407.7 MB 88.0 B 121.3 B
1,000,000 vertices, 20,000,000 edges (avg degree 20) 2261.1 MB 88.0 B 114.1 B

Two things to read from this, which hold regardless of the machine:

  • A bare vertex has a flat cost. Properties are on top and dominate quickly: a handful of string properties per element outweighs the structural cost.
  • Per-edge cost falls as degree rises. Each edge-property group is one contiguous EdgeModel[], and a vertex with a single group carries no dictionary at all, so the fixed part of the adjacency amortises over more edges as vertices get busier.

For rough planning, take the per-edge figure at your expected degree, add the per-vertex figure, and add your properties. A vertex whose out-degree changes constantly can transiently hold spare capacity in its group array (bounded at roughly twice the group), so a heavy-churn graph sits slightly above the table.

Vector indexes are the one component with a formula rather than a measurement. Vectors are held in one flat float[], so the cost is roughly 4 x dimensions bytes per indexed element plus about 64 bytes of bookkeeping: bge-m3 at 1024 dimensions costs about 4.1 KB per indexed element, an order of magnitude more than the vertex it hangs off. Index only what you will search.

Writes: throughput and the shape of a commit

Section titled “Writes: throughput and the shape of a commit”

Mutations are serialised through one writer thread, and with the write-ahead log on (the service default) each commit group is fsync’d before the call returns. Two consequences dominate everything else.

Batch, and concurrency pays. A commit group amortises one fsync across everything drained into it, so a stream of single-element transactions is the worst case and a batch transaction is the best. The measurement below is deliberately the worst case, single-element writes with the WAL on:

Producers Throughput Writes committed
serial (1 producer) 1,263 writes/s 37,888
32 concurrent producers 25,193 writes/s 200,000

That is roughly 19.9x from group commit alone, on single-element writes with the WAL on, and the serial latency floor is unchanged: a group of one still fsyncs immediately.

If you control the shape of your writes, prefer CreateVerticesTransaction and CreateEdgesTransaction over per-element calls, or use bulk import, which batches for you.

A batch is all or nothing. Ten thousand vertices in one transaction either all commit or none do, so batching costs you nothing in atomicity.

PUT /save runs on the same single writer thread and holds it for the entire save, serialize plus disk I/O. Every mutation enqueued during a save waits:

Graph size Save duration (writer held)
1,002,000 elements 110.8 ms
4,002,000 elements 352.1 ms
20,001,000 elements 1046.3 ms

This is a known, measured, deliberate trade-off: moving the save off the writer needs a consistent point-in-time view of mutable element objects. The practical guidance follows from it. The WAL already makes every commit durable, so checkpoints do not have to be frequent: save on a schedule that suits your restore-point needs rather than out of fear of data loss, and avoid saving a very large graph in the middle of a write burst. Reads are unaffected, because they never touch the writer.

Operation Cost
Element by id, degree, adjacency walk O(1), lock-free against a published snapshot
Index point lookup O(1) for a dictionary index
Range index scan O(log n + k) against a cached ascending key array, rebuilt lazily after a key-set change
Fulltext index scan Index-bounded, with scores
Vector index scan Exact SIMD brute force over every indexed element: linear in indexed elements, memory-bandwidth bound at roughly 4 x dimensions bytes per candidate
POST /scan/graph/property/{id} and the all-property scan O(n) full scan, no index, and deliberately sequential: the per-element predicate is too cheap to pay for partition and merge
Analytics Whole-graph, time-budgeted (default 30 s, max 300 s, one run at a time)
Path finding Frontier-bounded, and dominated by your filter fragments

Readers never block writers and writers never block readers: the graph is published copy-on-write, so a reader holds a consistent snapshot for the whole operation.

Raw out-edge traversal, through the same engine primitive GET /benchmark uses, measured at several graph sizes on the machine described above:

Graph Passes Out-edge traversal
500,000 vertices, 5,000,000 edges 5 807,258,872 edges/s
2,000,000 vertices, 20,000,000 edges 5 710,287,809 edges/s
10,000,000 vertices, 100,000,000 edges 5 655,166,743 edges/s

Read the sizes, not just the fastest row. Traversal depends more on the graph than on the engine, because following an edge is a chain of dependent memory loads: the adjacency slot, then the edge object, then the neighbour. While the working set fits in cache those loads are nearly free; once it does not, each is a memory round trip. So the rate falls as the graph grows, and a figure measured on a small graph is not one the same machine sustains on a large one. It also scales with cores and memory bandwidth, which makes it the number that moves most between machines.

The adjacency walk itself is already the cheap part: each edge-property group is one contiguous array, the sweep runs in parallel across vertex ranges, and it allocates nothing per vertex. What remains is the neighbour dereference, which is inherent to traversing a graph whose edges are first-class objects. Making that materially cheaper would mean maintaining a parallel array of neighbour ids per group, which is the CSR-style overlay this project assessed and rejected (below).

The Benchmark screen runs the same sweep against whatever graph you have loaded, so you can compare your own data against these shapes.

Knob Effect
Batch your transactions The single biggest write-throughput lever, worth the multiple measured above
Fallen8:Durability:Volatile=true No WAL, no checkpoints, no boot load: the fastest possible writes, and a restart loses everything
Fallen8:Durability:SaveOnShutdown false skips the final checkpoint and relies on WAL replay, trading a longer boot for a faster stop
Save frequency Each save costs the stall measured above; the WAL is what makes rare saves safe
Server GC On by default in the engine package, the service and the benchmark tool, and the right choice for a resident graph
Fallen8:Analytics:MaxConcurrentRuns Defaults to 1, so a heavy analytics run cannot be stacked on itself
Fallen8:BulkIO:ImportBatchSize Defaults to 10,000 elements per committed batch on import

Configuration keys and their environment-variable forms are in Running Fallen-8.

Honest limits, so you can plan around them rather than discover them:

  • The save stalls the writer (above). Revisit territory is tens of millions of elements saved frequently.
  • Property scans are linear. They are a discovery tool. Anything on a hot path wants an index.
  • There is no compressed adjacency structure. A CSR-style representation was assessed and deliberately rejected: edges here are first-class objects with their own ids, properties and index membership, and the graph is continuously mutated, so CSR would add a second structure to maintain without removing the objects that dominate the footprint.
  • Graph traversal is CPU-only. GPU acceleration in Fallen-8 reaches only the model sidecars, never the graph itself.
  • Benchmark: measure traversal throughput against a loaded graph from Studio
  • Save games: the WAL, checkpoints, and what survives a crash
  • Use as a library: in-process consumption, where you control GC and batching
  • Observability: the metrics that show these costs on a live instance
  • Bulk import/export: the batched path for loading large datasets