Blog / Engineering deep dives

Graph on one server: a 4-hop query 19 to 388 times faster than the incumbents

18 October 2026 · Semurg · 5 min read · Markdown

benchmarksgraphneo4jarangodbfalkordbarcadedbsurrealdbperformance

Graph databases are judged on traversals: start from one node, follow relationships out several hops, and return what you reach. We ran that question on a public social graph against six graph databases, on one bare-metal server, with every engine's answer checked against two independent reference implementations before anything was timed. This post has the results, the one place where it is close, a mistake in our own benchmark kit, and the one command that reruns the whole thing on your own machine.

What we ran

  • The data: SNAP's com-Orkut social graph: 3.07 million people and 117.2 million relationships, loaded as published[1].
  • The machine: one Vultr bare-metal server, AMD EPYC 4345P (8 cores, 16 threads), 64 GB of memory, two NVMe disks, no GPU[1].
  • The engines: Semurg, Neo4j, ArangoDB, FalkorDB, FalkorDBLite, ArcadeDB and SurrealDB, each in Docker on that server, one after another, each with every thread, both data disks and no memory cap, its own recommended settings, and its fastest query form.
  • Two shapes of use: inside your application (Semurg linked into the client; the other engine's embedded mode where it has one, otherwise its fastest connection) and as a server (each engine through its own fastest connection).
  • Three cycles: cold, warm, warm again; the figure is the median of the warm cycles.

One query, 4 hops

Starting from one person, every distinct person within 4 hops (455,604 of them)[1]. As a server, Semurg answered in 3.83 ms; ArcadeDB took 73.14 ms[1], FalkorDBLite 164.14 ms[2], FalkorDB 205.89 ms[3], Neo4j 281.40 ms[4] and SurrealDB 1,484.92 ms[5]. Across those five server rows that is between 19 and 388 times faster[1][5]. Inside the application the picture is the same: 3.84 ms for Semurg against 93.08 ms for ArcadeDB's embedded mode[6] and 1,360.65 ms for SurrealDB's[7].

Many queries at once

The same 4-hop question from 16 clients at once, each side driven by the same number of clients. As a server, Semurg answered 240.2 queries per second, ArcadeDB 28.0[8], FalkorDB 8.5[9] and Neo4j 2.6[10].

Walks to 131,072 hops

The ladder walks from the same person to every depth from 2 to 131,072 hops, checking each step against the reference. On this graph everything reachable is reached within a few dozen hops, so past that point every engine is timing one full walk of the reachable graph. As a server, Semurg's full walk took 95.22 ms; ArcadeDB's took 113.71 ms[11], FalkorDB's 412.67 ms[12] and Neo4j's 14,033.83 ms[13]. Inside the application Semurg's took 96.88 ms and ArcadeDB's embedded mode 459.74 ms[14].

The server comparison with ArcadeDB is the close one, and we care about it most. ArcadeDB answers its traversals from what it calls a Graph Analytical View: an index of the whole graph it builds in memory when the database opens (the build is counted in its load time). Semurg keeps no index in memory: it walks the graph's own layout on disk through its cache. The next release carries two changes to the work Semurg does per relationship followed: a lean path for the most common case, with the walk's fixed state read once rather than for every relationship, and the end of a shared counter every core wrote on every page read (the subject of our post about Linus Torvalds' page-cache series). In our own test build the lean path took the full walk from 96.4 ms to 81.1 ms on 8 threads (test tree, not a published release); the published number follows the release.

What did not run, and why

A result we could not judge is shown as held, never dropped. In this run:

  • ArangoDB answered the 4-hop question correctly, but it was stopped by the out-of-memory killer during the many-at-once test, and the walk that followed found it already stopped. Our benchmark kit read that as a failure of the walk itself and set aside all of ArangoDB's results, its correct ones included. That was our kit's mistake, not ArangoDB's: the kit now checks that an engine is running before a walk starts, and ArangoDB is re-run on the next release. Until then its cells on the benchmark page read results pending.
  • FalkorDBLite's many-at-once run had failed requests and SurrealDB's had a zero median, so neither has a throughput figure; load times are held for every engine until each load has its run-to-run band.

Bigger than memory

Every domain we publish runs four classes: in the application and as a server, each on a dataset that fits in memory and one that does not. This graph fits. The bigger-than-memory run uses SNAP's com-Friendster, a far larger social graph, with each engine held to a fixed memory budget; its reference answers are still being computed, and the results go on the benchmark page when they finish.

How Semurg does it, briefly

The graph lives on disk as fixed 64-byte records, with each node's relationships packed next to it for the walk. Reads go straight from the NVMe disks into Semurg's own cache (no kernel page cache), the next level's data is fetched along the graph's own edges while the current level is being computed, and every level fans out over every core.

Run it yourself

The kit that produced every number above is published. On a Linux machine with Docker, one command runs the graph benchmark for every engine: curl -fsSL https://one.semurg.io/bench | sudo bash -s -- --graph. On a machine like ours it needs about 22 GB of free disk, about 48 GB of memory for the largest engine, and about eight and a half hours (test tree, not a published release). Every figure, every held row and the reason for each is on the benchmark page.

Evidence

  1. Benchmark One query, 4 hops: Semurg 3.83, arcadedb 73.14 (r11.108) (bench:graph/t2/server/arcadedb@r11.108; the record as JSON)
  2. Benchmark One query, 4 hops: Semurg 3.83, falkordblite 164.141 (r11.108) (bench:graph/t2/server/falkordblite@r11.108; the record as JSON)
  3. Benchmark One query, 4 hops: Semurg 3.83, falkordb 205.885 (r11.108) (bench:graph/t2/server/falkordb@r11.108; the record as JSON)
  4. Benchmark One query, 4 hops: Semurg 3.83, neo4j 281.396 (r11.108) (bench:graph/t2/server/neo4j@r11.108; the record as JSON)
  5. Benchmark One query, 4 hops: Semurg 3.83, surrealdb 1484.92 (r11.108) (bench:graph/t2/server/surrealdb@r11.108; the record as JSON)
  6. Benchmark One query, 4 hops: Semurg 3.844, arcadedb 93.08 (r11.108) (bench:graph/t2/embedded/arcadedb@r11.108; the record as JSON)
  7. Benchmark One query, 4 hops: Semurg 3.844, surrealdb 1360.654 (r11.108) (bench:graph/t2/embedded/surrealdb@r11.108; the record as JSON)
  8. Benchmark 16 queries at once, 4 hops: Semurg 240.196, arcadedb 28.013 (r11.108) (bench:graph/t2_throughput/server/arcadedb@r11.108; the record as JSON)
  9. Benchmark 16 queries at once, 4 hops: Semurg 240.196, falkordb 8.513 (r11.108) (bench:graph/t2_throughput/server/falkordb@r11.108; the record as JSON)
  10. Benchmark 16 queries at once, 4 hops: Semurg 240.196, neo4j 2.627 (r11.108) (bench:graph/t2_throughput/server/neo4j@r11.108; the record as JSON)
  11. Benchmark Walk from 2 to 131,072 hops: 131072 hops: Semurg 95.22, arcadedb 113.71 (r11.108) (bench:graph/t4_h131072/server/arcadedb@r11.108; the record as JSON)
  12. Benchmark Walk from 2 to 131,072 hops: 131072 hops: Semurg 95.22, falkordb 412.67 (r11.108) (bench:graph/t4_h131072/server/falkordb@r11.108; the record as JSON)
  13. Benchmark Walk from 2 to 131,072 hops: 131072 hops: Semurg 95.22, neo4j 14033.828 (r11.108) (bench:graph/t4_h131072/server/neo4j@r11.108; the record as JSON)
  14. Benchmark Walk from 2 to 131,072 hops: 131072 hops: Semurg 96.884, arcadedb 459.735 (r11.108) (bench:graph/t4_h131072/embedded/arcadedb@r11.108; the record as JSON)

Related