Skip to content

Commit fc08deb

Browse files
committed
Revise and improve the benchmarks
1 parent dd3d79c commit fc08deb

5 files changed

Lines changed: 101 additions & 23 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,18 @@ That's achieved by:
6969
never leak between engines, and the peak RSS reported per engine is attributable to that engine alone.
7070
- **Statistics.** Per query: a cold run (first execution after build) is reported separately; timed rounds
7171
run with the garbage collector disabled until both a minimum round count and a time budget are met; the
72-
report shows median latency with a 95% confidence interval, and the plot carries p25 to p75 whiskers.
72+
report shows median latency with a distribution-free 95% confidence interval for the median (the
73+
order-statistic method, not a normal approximation on the mean), and the plot carries p25 to p75 whiskers.
7374
- **Honest comparisons.** Engines are labeled by kind (embedded / in-memory / client-server) and ingestion
7475
method; load times are never ranked across kinds, the client-server network round-trip caveat is stated
7576
in every report, and Neo4j's server memory settings are captured from the live server into the results.
77+
- **Indexing differences.** Index models differ by engine and cannot be fully equalized: IssunDB
78+
auto-indexes every scalar property, Neo4j uses a uniqueness index on `id` plus an explicit range index
79+
on the filtered column, Ladybug indexes only its primary key, and lance-graph holds no index. The report
80+
spells this out so a filtered-query result is read as the engine's indexing model, not raw speed alone.
7681
- **Determinism.** The dataset is generated from a single seed, byte-for-byte reproducible, with edge rows
7782
shuffled so no engine gains a locality advantage from sorted insertion order. Hardware (CPU model, cores,
78-
and RAM) is recorded in every results file.
83+
and RAM) is recorded in every result file.
7984
- **Scaling.** `make sweep` benchmarks a series of dataset scales and plots median latency vs scale per
8085
query, so results are never a single-scale snapshot.
8186

graphbench/_worker.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,32 @@ def _percentile(ordered: list[float], pct: float) -> float:
3838
return ordered[rank]
3939

4040

41+
def _median_ci(ordered: list[float]) -> tuple[float, float]:
42+
"""Distribution-free 95% CI for the *median* via the binomial (order-statistic)
43+
method, normal-approximated.
44+
45+
The reported point estimate is the median, so its interval should be a median CI,
46+
not `1.96*std/sqrt(n)` (a CI for the *mean* that also assumes normality). Latency
47+
samples are right-skewed, so this nonparametric interval, read straight off the
48+
order statistics, is the honest choice. Returns absolute (lo, hi) bounds.
49+
"""
50+
n = len(ordered)
51+
if n < 2:
52+
return ordered[0], ordered[0]
53+
half = 1.96 * math.sqrt(n)
54+
lo_rank = math.floor((n - half) / 2.0) # 1-indexed lower order statistic
55+
hi_rank = math.ceil((n + half) / 2.0) + 1 # 1-indexed upper order statistic
56+
lo_idx = min(max(lo_rank - 1, 0), n - 1)
57+
hi_idx = min(max(hi_rank - 1, 0), n - 1)
58+
return ordered[lo_idx], ordered[hi_idx]
59+
60+
4161
def _stats(samples: list[float]) -> dict:
4262
ordered = sorted(samples)
4363
n = len(samples)
4464
mean = statistics.fmean(samples)
4565
std = statistics.stdev(samples) if n > 1 else 0.0
66+
ci_lo, ci_hi = _median_ci(ordered)
4667
return {
4768
"rounds": n,
4869
"min_ms": round(ordered[0], 4),
@@ -52,7 +73,9 @@ def _stats(samples: list[float]) -> dict:
5273
"p95_ms": round(_percentile(ordered, 95), 4),
5374
"mean_ms": round(mean, 4),
5475
"std_ms": round(std, 4),
55-
"ci95_ms": round(1.96 * std / math.sqrt(n), 4) if n > 1 else 0.0,
76+
# 95% CI for the median (order-statistic method), as absolute bounds.
77+
"ci_lo_ms": round(ci_lo, 4),
78+
"ci_hi_ms": round(ci_hi, 4),
5679
}
5780

5881

graphbench/engines/issundb_engine.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""IssunDB adapter.
22
3-
IssunDB is an embedded engine with no bulk COPY path, so nodes and edges are inserted
4-
one row at a time through `add_node`/`add_edge`. IssunDB allocates its own NodeId per
5-
node, so the build keeps a per-label map from the dataset id to the allocated NodeId
6-
and resolves edge endpoints through it. The dataset id is stored as a node property so
7-
queries can return it. The binding returns query results as JSON of the shape
3+
IssunDB is an embedded engine with a bulk `IMPORT DATABASE` path, so ingestion is one
4+
import call over a generated Parquet/JSONL bundle rather than per-row inserts. IssunDB
5+
allocates its own NodeId per node, so the build offsets each label's dataset id into a
6+
globally unique `_id` (the imported NodeId) while keeping the original `id` as a node
7+
property, and resolves edge endpoints through the same offsets. The `id` property is
8+
what the catalog's Cypher filters on; IssunDB auto-indexes every scalar node property,
9+
so those filters become index/range scans with no explicit index DDL (an explicit
10+
node `CREATE INDEX` would provision a full-text index instead, which the workload does
11+
not use). The binding returns query results as JSON of the shape
812
`{"columns": [...], "records": [{"values": [...]}]}`.
913
"""
1014

@@ -63,10 +67,12 @@ def build(self, data_dir: Path) -> BuildResult:
6367
current_offset += (max_id if max_id is not None else 0) + 1
6468

6569
# 3. Process and write node Parquet files with _id column
70+
n_node_rows = 0
6671
for label in self.schema.nodes:
6772
parquet_path = data_dir / "nodes" / f"{label.name}.parquet"
6873
dst_parquet = import_dir / f"{label.name}.parquet"
6974
df = pl.read_parquet(parquet_path)
75+
n_node_rows += df.height
7076
df = df.with_columns(
7177
(
7278
pl.col(self.schema.id_column).cast(pl.Int64) + offsets[label.name]
@@ -77,10 +83,12 @@ def build(self, data_dir: Path) -> BuildResult:
7783

7884
# 4. Convert and write edge tables with offset endpoints
7985
edges_start = time.perf_counter()
86+
n_edge_rows = 0
8087
for rel in self.schema.rels:
8188
parquet_path = data_dir / "edges" / f"{rel.name}.parquet"
8289
jsonl_path = import_dir / f"{rel.name}.jsonl"
8390
df = pl.read_parquet(parquet_path)
91+
n_edge_rows += df.height
8492
df = df.with_columns(
8593
[
8694
(
@@ -118,9 +126,15 @@ def build(self, data_dir: Path) -> BuildResult:
118126
# Clean up import temp files
119127
shutil.rmtree(import_dir)
120128

121-
# Split query time and attribute preparation to edges_seconds and nodes_seconds
122-
nodes_seconds = nodes_prep_time + query_time * 0.5
123-
edges_seconds = edges_prep_time + query_time * 0.5
129+
# `IMPORT DATABASE` is a single bulk call whose internal node and edge phases
130+
# are not separately measurable, so its time is apportioned across the two
131+
# reported phases by row volume (not an arbitrary 50/50 split). Each phase
132+
# also carries its own deterministic preparation cost (the offset rewrite and
133+
# the JSONL conversion), which is genuinely per-phase and measured directly.
134+
total_rows = n_node_rows + n_edge_rows
135+
node_frac = n_node_rows / total_rows if total_rows else 0.5
136+
nodes_seconds = nodes_prep_time + query_time * node_frac
137+
edges_seconds = edges_prep_time + query_time * (1.0 - node_frac)
124138

125139
return BuildResult(nodes_seconds, edges_seconds)
126140

graphbench/engines/neo4j_engine.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
environment: NEO4J_URI (default bolt://localhost:7687), NEO4J_USER (default neo4j),
66
and NEO4J_PASSWORD (default password).
77
8-
Ingestion wipes the database, creates a uniqueness constraint per label, then batches
9-
`UNWIND` + `CREATE` for nodes and edges (the dataset is pre-deduplicated and the
10-
database starts empty, so the slower MERGE is unnecessary). The dataset id is stored
11-
as the `id` property on every node, so the catalog's Cypher (which filters on `id`)
12-
is identical to the other engines and never touches Neo4j's internal element id.
8+
Ingestion wipes the database, creates a uniqueness constraint per label plus a range
9+
index on each non-id property the workload filters on, then batches `UNWIND` +
10+
`CREATE` for nodes and edges (the dataset is pre-deduplicated and the database starts
11+
empty, so the slower MERGE is unnecessary). The dataset id is stored as the `id`
12+
property on every node, so the catalog's Cypher (which filters on `id`) is identical
13+
to the other engines and never touches Neo4j's internal element id. IssunDB
14+
auto-indexes every scalar property, so indexing the filter columns here keeps the
15+
filtered-query comparison about execution rather than a missing index.
1316
1417
`server_info` reports the server's memory settings via SHOW SETTINGS so published
1518
results carry the Neo4j configuration they were measured against.
@@ -37,7 +40,16 @@ def _chunks(rows: list, size: int):
3740
class Neo4jEngine(Engine):
3841
name = "neo4j"
3942
kind = "server"
40-
build_method = "batched UNWIND+CREATE over Bolt, uniqueness constraint per label"
43+
build_method = (
44+
"batched UNWIND+CREATE over Bolt, uniqueness constraint per label, "
45+
"range index on filter columns"
46+
)
47+
# Range indexes on the non-id properties the catalog filters on, so Neo4j seeks
48+
# rather than scanning. IssunDB auto-indexes every scalar property, so without
49+
# these the filtered queries would measure a missing index, not execution speed.
50+
# Person.age (used by age_band_by_country) is the only selective filter at scale;
51+
# the name lookups hit tiny fixed-size tables, so they are left unindexed.
52+
_FILTER_INDEXES = (("Person", "age"),)
4153

4254
def __init__(self, schema: Schema, workdir: Path):
4355
super().__init__(schema, workdir)
@@ -67,6 +79,13 @@ def _reset(self) -> None:
6779
f"CREATE CONSTRAINT IF NOT EXISTS FOR (n:{label.name}) "
6880
f"REQUIRE n.{self.schema.id_column} IS UNIQUE"
6981
)
82+
# Created on the empty database so the index build folds into node load,
83+
# the same way the uniqueness constraints (and IssunDB's auto-index) do.
84+
for label, prop in self._FILTER_INDEXES:
85+
self._session.run(
86+
f"CREATE INDEX {label.lower()}_{prop} IF NOT EXISTS "
87+
f"FOR (n:{label}) ON (n.{prop})"
88+
)
7089

7190
def build(self, data_dir: Path) -> BuildResult:
7291
self._reset()

graphbench/report.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,9 @@ def to_markdown(results: dict, baseline: str | None = None) -> str:
153153
lines.append(f"| {query} | " + " | ".join(cells) + " |")
154154
lines.append("")
155155

156-
# Warm latency: median with 95% CI, speedup vs baseline.
156+
# Warm latency: median with a 95% CI for the median, speedup vs baseline.
157157
lines.append(
158-
"## Warm Latency, median ms +/- 95% CI"
158+
"## Warm Latency, median ms [95% CI of median]"
159159
+ (f", speedup vs {baseline}" if baseline else "")
160160
)
161161
lines.append("")
@@ -176,10 +176,11 @@ def to_markdown(results: dict, baseline: str | None = None) -> str:
176176
q = results["engines"][name].get("queries", {}).get(query)
177177
cells.append("ERR" if isinstance(q, dict) and "error" in q else "n/a")
178178
continue
179-
ci = _query_stat(results, name, query, "ci95_ms")
179+
ci_lo = _query_stat(results, name, query, "ci_lo_ms")
180+
ci_hi = _query_stat(results, name, query, "ci_hi_ms")
180181
text = f"{val:.2f}"
181-
if ci is not None:
182-
text += f" +/-{ci:.2f}"
182+
if ci_lo is not None and ci_hi is not None:
183+
text += f" [{ci_lo:.2f}, {ci_hi:.2f}]"
183184
if baseline and name != baseline and base_val and val > 0:
184185
text += f" ({base_val / val:.1f}x)"
185186
if min_lat is not None and val == min_lat:
@@ -218,7 +219,23 @@ def to_markdown(results: dict, baseline: str | None = None) -> str:
218219
"- Parameterized queries rotate their literal values across rounds to "
219220
"defeat plan/result caches. Whole-graph aggregations (top_followed, "
220221
"two_hop_paths, ...) have no parameters, so their statement is necessarily "
221-
"identical every round."
222+
"identical every round; the warmup rounds equalize plan/parse caches across "
223+
"engines, and none of the engines benchmarked here enable a result cache by "
224+
"default, so the identical statement is recomputed each round rather than "
225+
"served from a memoized result. These queries are not parameterized because a "
226+
"filter would change which physical operator runs (e.g. a filtered count no "
227+
"longer exercises the unfiltered path-count kernel), measuring a different "
228+
"query rather than defeating a cache."
229+
)
230+
lines.append(
231+
"- Indexing models differ by engine and are not equalized, because they "
232+
"cannot be: IssunDB auto-indexes every scalar node property, so its "
233+
"equality and range filters become index/range scans with no user action; "
234+
"Neo4j has a uniqueness index on `id` per label plus an explicit range index "
235+
"on the filter column (Person.age); Ladybug indexes only its primary key "
236+
"(`id`); lance-graph is an in-memory engine with no indexes. Filtered queries "
237+
"(point_lookup, age_band_by_country) therefore reflect each engine's indexing "
238+
"model, not raw execution speed alone."
222239
)
223240
for name in engines:
224241
server_info = results["engines"][name].get("server_info") or {}

0 commit comments

Comments
 (0)