# Benchmarking
pgGraph has two benchmark layers:
- internal Rust benchmarks for engine-level changes;
- SQL-facing sandbox benchmarks for realistic PostgreSQL query timing.
This page focuses on the SQL-facing benchmark harness. These benchmarks are a
work in progress and are not intended as a comparison against other graph
databases, PostgreSQL extensions, or graph tools yet. The reported numbers do,
however, represent accurate expected pgGraph query times for the measured
dataset, workload, machine, Docker settings, PostgreSQL version, pgGraph commit,
and cache methodology captured in the report.
Do not cite the sandbox numbers as a universal performance claim. Treat them as
reproducible local evidence for the exact command, dataset, hardware, and report
JSON produced by the harness.
## Running The Sandbox Benchmark
From the repository root:
```bash
sandbox/run_benchmarks.sh panama
sandbox/run_benchmarks.sh ldbc
sandbox/run_benchmarks.sh all
```
Use `--yes` for non-interactive dataset download approval:
```bash
sandbox/run_benchmarks.sh all --yes
```
The harness reuses `sandbox/benchmark/.venv` without a package-manager command
when every active exact pin is already satisfied. If a dependency must be
installed or changed, the harness requires `sfw` and does not fall back to a
direct pip invocation.
The script prints the known download sizes before fetching data:
| Dataset | Source | Compressed | Uncompressed |
|---|---|---:|---:|
| Panama Papers / ICIJ Offshore Leaks | [pgGraph benchmark snapshot](https://github.com/Evokoa/pgGraph/releases/tag/benchmark-data-icij-2026-07-29) | 72 MB | 626 MB |
| LDBC SNB Interactive SF1 CsvBasic LongDateFormatter | `https://datasets.ldbcouncil.org/snb-interactive-v1/social_network-sf1-CsvBasic-LongDateFormatter.tar.zst` | 230 MB | unknown |
Generated archives, extracted files, normalized CSVs, virtualenvs, and benchmark
reports are ignored by git.
Clean generated artifacts with:
```bash
sandbox/cleanup.sh --dry-run
sandbox/cleanup.sh
```
## What Is Measured
The SQL-facing benchmark exercises pgGraph through PostgreSQL using the same SQL
functions an application would call. It builds or reuses the repository
PostgreSQL 17 Docker image, downloads real public datasets, normalizes the data
into SQL tables with primary keys and foreign keys, registers those tables with
pgGraph, builds the graph, and runs fixed query workloads.
The report separates:
- dataset download and normalization;
- SQL table load time;
- pgGraph registration;
- `graph.build()` time;
- cold query timing;
- hot query timing;
- machine, Docker, PostgreSQL, git, and dataset metadata.
Each run writes:
```text
sandbox/benchmark/results//report.json
```
Each benchmark result includes:
- dataset and query name;
- plain-language question being answered;
- SQL text and SQL hash;
- phase, iteration, and success state;
- PostgreSQL server execution time from `EXPLAIN (ANALYZE, FORMAT JSON)`;
- host wall time through a Python `psycopg` connection;
- row count;
- result checksum.
## Cache Methodology
Cold and hot runs are deliberately separate.
Cold query timing restarts the Docker container before each cold query. This
clears the PostgreSQL process, PostgreSQL shared buffers, and backend-local
pgGraph state. It does not explicitly drop the host OS page cache or Docker VM
page cache.
Hot query timing runs one unrecorded warmup pass, then measures repeated SQL in
the same persistent PostgreSQL backend. This matters because pgGraph has
backend-local graph state after load.
Cold query timing does not include `graph.build()`. Build time is reported
separately as `prepared.build_seconds`. If you want the full first-use cost,
read both build time and the cold query timings.
## Current Datasets
### Panama Papers
The Panama loader uses the real public ICIJ Offshore Leaks CSV export generated
on 2026-07-29. pgGraph mirrors the unchanged ZIP and the applicable ICIJ data
license notices in a tag-pinned benchmark release, then verifies the archive's
SHA-256 checksum before extraction. It loads all `nodes-*.csv` files and
`relationships.csv`, creates normalized `panama.nodes` and `panama.edges`
tables, enforces endpoint foreign keys, and registers the graph with dynamic
edge labels from the source relationship type.
ICIJ may repeat a node ID across category files when the same record has more
than one role. The normalizer uses a fixed category precedence, emits one row
when the projected values agree, and fails rather than silently choosing a row
when the values conflict.
The normalized table intentionally keeps a compact set of fields needed for
graph search and traversal: label, name, countries, country codes, source ID,
and validity metadata. It is suitable for graph benchmarking, but it is not a
complete relational mirror of every ICIJ source attribute.
The current workload asks:
- Is the Panama graph loaded in this backend, and how large is it?
- Which Panama entities mention Mossack in a registered searchable field?
- What is the two-hop neighborhood around a high-degree Panama node?
- Can pgGraph find the direct path between a high-degree seed and one adjacent target?
- How quickly can pgGraph traverse all 19 levels of a sparse investigation path
without hitting a work cap?
- How quickly can pgGraph recover a verified 45-hop path across a 91,168-node
connected component?
- What is the overhead of a one-hop `graph.gql()` scalar projection?
- What is the overhead of returning hydrated GQL node objects?
- What is the overhead of returning coordinate-only GQL node objects?
- How does the same one-hop seed compare through a direct PostgreSQL join and
the native `graph.traverse()` API?
- How many connected components does the Panama graph have?
- Which nodes are in the first page of the largest connected component?
### LDBC
The LDBC loader uses the official public SNB Interactive SF1 CSV archive. It
models the broader SNB schema, including people, forums, posts, comments,
places, organisations, tags, tag classes, and the main relationships between
them.
The loader prefixes IDs by entity type before loading into PostgreSQL. This
avoids collisions between source IDs from different LDBC tables and makes every
relationship endpoint explicit through foreign keys.
The current workload asks:
- Is the full modeled LDBC graph loaded in this backend, and how large is it?
- Which people have John in their first name?
- Who is directly connected to a high-degree person through the social graph?
- What is the overhead of one-hop `graph.gql()` knows queries with scalar,
hydrated-node, and coordinate-only outputs?
- How does the same knows seed compare through a direct PostgreSQL join?
- What people, posts, comments, forums, tags, places, and organisations are near a high-degree person within two hops?
- What members, posts, tags, and related nodes are near a busy forum?
- Can pgGraph find the path between a post and one of its tags?
- Can pgGraph connect a tag to its tag class?
- How many connected components does the full modeled LDBC graph have?
## Interpreting Results
The numbers are accurate for the run they came from, but they are not a
cross-tool benchmark. Before publishing or comparing results, include the
generated `report.json`, the exact command, the pgGraph commit, the dirty git
status if any, hardware information, Docker resource limits, and whether the
machine was otherwise idle.
Known limitations:
- host OS and Docker VM page cache are not explicitly dropped;
- Docker CPU and memory limits are not pinned by default;
- p95 from 10 hot iterations is useful signal, but not enough for a polished
p99 claim;
- `server_execution_ms` measures the checksum wrapper query used for
verification, not a stripped raw-function-only timing;
- the LDBC workload is broader than the original subset, but it is not the full
official LDBC Interactive query suite.
We plan to test larger datasets and tighten the methodology over time.
Community feedback is welcome, especially suggestions that make the benchmarks
more accurate, easier to reproduce, or more useful to users evaluating pgGraph.
To submit benchmark results or methodology feedback, email `team@evokoa`.
Include your GitHub username, the
generated `report.json`, the exact command you ran, machine and Docker details,
and any relevant notes about local load, Docker resource limits, storage, or
cache state.
## Caller-Scoped RLS Baseline
Use the dedicated heavy runner before changing visibility strategy or its
resource accounting:
```bash
cd graph
NODE_COUNT=1000000 RUN_PROFILE=compact \
./tests/heavy/rls_large_table_baseline.sh
```
The runner builds scalar-key, composite-key, and standalone relationship-row
sources, then executes correctness-checked no-RLS, node-only, relationship-only,
and combined RLS cases as a real login. `RUN_PROFILE=full` adds shallow/deep
traversal and shortest-path cases. Set `NODE_COUNT=10000000` only on a benchmark
host sized for the resulting source tables and backend-local projection.
If a case reaches `STATEMENT_TIMEOUT_MS`, the runner exits nonzero but retains
the earlier samples, a `censored_statement_timeout` attempt, the PostgreSQL
error log, metadata, and source-query plans. This is a censored lower bound, not
a latency sample. Exact SPI-call, scanned-key, scanned-byte, graph-only-time,
and peak-governed-byte metrics remain `NA` until coordinator instrumentation is
available.
Before changing relationship-type storage, compile or run the benchmark-only
width fixture:
```bash
cd graph
cargo bench --features "pg17 benchmarks" --bench edge_type_width_bench --no-run
cargo bench --features "pg17 benchmarks" --bench edge_type_width_bench
```
It compares one-, two-, and four-byte encoded type sections at the reserved-ID
boundaries without changing the production artifact format. The traversal
group is a synthetic encoded-section access microbenchmark across filter
selectivity, degree, direction, and depth; it is not production graph traversal
or RLS latency. The retained P6 evidence and predeclared acceptance budgets are
in `release/evidence/engine/2026-08-13-p6-edge-type-width/`.
The heavy RLS runner retains raw samples, percentile summaries, fixture
metadata, source policy plans, and the exact commit. Development builds expose
visibility time and hidden-identity counts. Exact nested SPI-call,
requested-key, and byte-scan counts are recorded as `NA` until dedicated
instrumentation exists; do not infer them from elapsed time. A cancelled or
timed-out eager run is censored evidence and must not be reported as a completed
latency sample. The P6 width checkpoint instead retains extracted Criterion
medians and confidence intervals, its synthetic matrix, host metadata, and
predeclared budgets.
The P9 production-path open-type benchmark is compiled separately:
```bash
cd graph
cargo bench --features "pg17 benchmarks" --bench open_type_query_bench --no-run
```
It measures Engine registry lookup and filter resolution plus CSR BFS across a
predeclared one-factor-at-a-time matrix. It is still an internal Rust benchmark;
PostgreSQL SQL latency and Linux multi-backend RSS/PSS are separate retained
evidence domains. The exact cases and acceptance budgets are frozen in
`release/evidence/engine/2026-08-13-p9-open-type-query/` before any result files
are created. Encoding labels are the current adaptive `u8`, `u16`, and `u32`
representations, not a synthetic v6 control. Selection cases state their exact
shape (`one_of_32`, `half_of_32`, and so on) instead of implying percentages
that the bounded fixture cannot represent exactly.
The retained-evidence workflow has three independent producers. Run
`tests/heavy/run_open_type_query_criterion.sh` for fresh internal Rust estimates,
`tests/heavy/open_type_query_latency.sh` for PostgreSQL 17 latency, and
`tests/heavy/run_open_type_query_resources_docker.sh` for Linux backend RSS/PSS.
The commands, warmups, sample counts, fixture dimensions, and reconciliation
rules are declared in the measurement directory README and
`measurement-protocol.json`. Do not edit budgets after observing a result.
## Internal Rust Benchmarks
The Criterion benchmark target lives in `graph/benches/bfs_bench.rs`.
```bash
cd graph
cargo bench --bench bfs_bench
```
These benchmarks call Rust engine types directly. They do not start PostgreSQL
and do not exercise pgrx, SPI, SQL wrappers, ACL checks, source-row hydration,
artifact loading, or client connection overhead. Use them when changing engine
data structures, traversal behavior, filter-index storage, or overlay traversal.
The `bfs_traverse` group measures frontier growth on deterministic power-law
graphs up to the two-million-node Panama scale. The `bfs_deep_chain` group
separately measures depth scaling at 16, 64, 256, and 1,024 levels while holding
the frontier width to one node.