How does a triplestore hold up when provenance is modelled as one named graph per document subsection, with the graph URI itself described in the default graph? That pattern is expressive but multiplies named graphs alarmingly β one per subsection rather than one per document β and it is not obvious what that costs in memory or query time until you measure it.
This generates a synthetic corpus in that shape at controlled sizes, loads it at increasing scales, and records memory and query time at each point.
Python 3.10+ standard library only β the generator, the measurement harness and
the HTML report have no dependencies. validate_dataset.py optionally uses
rdflib.
| File | Store-specific? | |
|---|---|---|
generate_dataset.py |
builds the synthetic corpus | no |
measure.py |
loads it at increasing sizes, records memory and query time | yes β drives RDFox |
report.py |
renders results.json as a self-contained HTML report |
no |
validate_dataset.py |
checks a dataset before spending benchmark time on it | no |
queries/ |
the ten SPARQL queries, as used for the published results | no |
Portability. The corpus is plain TriG (or N-Quads) and the ten queries are plain SPARQL 1.1, so the dataset and query set load into any store with named graph support β nothing in either is RDFox-specific. The harness is the part that is: it drives the RDFox shell and parses its output. Pointing this at another triplestore means writing a new driver against the same contract, which is small β see Testing another store.
So far only RDFox 7.6 has been measured; every number below is from it.
500,000 named graphs / 14,525,277 quads, 15 size points, both storage types.
- Memory grows sub-linearly (exponent 0.83, RΒ² 0.98) and settles near 95 bytes per quad β the shared dictionary amortises as entities recur across graphs.
- 4 of 10 queries are constant-time, unchanged across a 51,876Γ increase in data, including the point lookup the whole pattern exists to serve (13.9 Β΅s at 14.5M quads). The other six grow, but all sub-linearly.
quad-table-sg-piused 12% less memory than-fiand was never slower β 1.9Γ faster on the graph-keyed fan-out query.
One store, one machine, one seed, one dataset shape. Details and caveats below.
GitHub renders a committed .html as source, not as a page, so
results/report.html will not display in the repo view β which is why the
summary above is a committed PNG. For the full interactive report (13 charts,
per-query panels, every datapoint), clone and open it:
git clone git@github.com:Ortecha/triplestore-prov-test.git
open triplestore-prov-test/results/report.htmlIt is a single self-contained file with no external requests, so it works straight off disk.
report.py regenerates the summary image on every run, in three forms:
| File | |
|---|---|
results/summary-dark.png |
transparent background β the one embedded above, so it reads on GitHub's light and dark themes |
results/summary-white.png |
white background and darker ink, for slides, docs and anywhere transparency is unwelcome |
results/summary.svg |
vector source, if you want to rescale or restyle it |
Rasterising needs rsvg-convert (librsvg) or ImageMagick on the path; without
one, report.py still writes the SVG and says so rather than failing.
Each leaf subsection of a document becomes a named graph holding the triples extracted from it. The graph URI is a subject in the default graph, where it is linked to its original text, section number and parent section:
# default graph β provenance
<β¦/sec/12/3.2.7> a pv:Subsection ;
pv:text "full text of the subsection β¦" ;
pv:sectionNumber "3.2.7" ;
pv:parentSection <β¦/sec/12/3.2> ;
pv:document <β¦/doc/12> ;
pv:title "β¦" ;
pv:ordinal 7 ;
pv:tripleCount 17 .
# named graph β extraction
GRAPH <β¦/sec/12/3.2.7> {
<β¦/e/8123> a <β¦/c/4> ;
rdfs:label "Vosen Ridal" ;
<β¦/p/3> <β¦/e/91> ;
<β¦/p/19> "1974-03-02"^^xsd:date .
}
Sections above the leaves (pv:Section) and documents (pv:Document) are
described in the default graph too, so pv:parentSection can be walked all the
way up. Roughly 8 provenance triples per named graph, plus 6 per internal
section and 3 per document.
python3 generate_dataset.py --graphs 100000 --workers 8Writes to out/:
| Path | Contents |
|---|---|
data/part-*.trig.gz |
gzipped TriG, one shard per graph-index range |
queries/q01β¦q10.rq |
SPARQL, pre-bound to constants that really occur |
manifest.json |
parameters, per-shard and cumulative counts, checkpoints |
Check the projected size before committing to a big run:
python3 generate_dataset.py --graphs 10000000 --estimateThroughput is about 1.7M quads/s on 8 workers (~5.8M quads in 3.4s), so even a 100M-quad corpus generates in about a minute.
This part is store-agnostic β it is a property of how the corpus is cut, not of any particular engine.
Shards are cut exactly on log-spaced checkpoints (10, 20, 50, 100, β¦) and
graph i depends only on (seed, i) β never on the total. So a dataset of any
size is a byte-exact prefix of a larger one with the same parameters, and you
can measure every point on the curve inside one RDFox process:
create the data store with the storage type under test
for k in manifest.checkpoints:
import shards up to k["shards_to_load"] # cumulative, no reload
record RDFox's memory figures
run each query in queries/, record time
Measuring in one process is the point: separate processes per size mostly
measure allocator behaviour and page-cache state, which will swamp the effect
you are looking for. The manifest gives you the x-axis directly β each
checkpoint row carries graphs, quads, provenance_quads and
content_quads.
--checkpoints 1000,10000,100000 overrides the default spacing.
Because the generator emits no duplicate quads (verified), RDFox's quad
count after loading should equal manifest.totals.quads exactly. If it does
not, that is a real finding, not a generator artefact.
Everything from here to Testing another store is
RDFox-specific. Verified against RDFox 7.6 (RDFox-macOS-arm64-7.6). Two things
are worth knowing before you start, because both are easy to get wrong:
quad-table-sg-fi is not a data store type. It is the value of the
separate quad-table-type parameter. The type parameter takes
parallel-nn / parallel-nw / parallel-ww, and passing the quad table value
there fails outright:
Data store type 'quad-table-sg-fi' is invalid; available data store types are
'parallel-nn', 'parallel-nw', and 'parallel-ww'.
The correct form, confirmed by dstore show:
dstore create bench quad-table-type quad-table-sg-fi
The full set of values is quad-table-{sg,lg}[-fi|-pi]. type is independent
and optional β set it too if you also want to vary the parallel storage layout.
The data must be TriG, not N-Quads β which is why this generator emits
.trig.gz. RDFox's shell import picks its parser from the file extension and
recognises only .ttl, .trig and .dlog. An .nq file is parsed as Turtle,
so every quad fails on its fourth term (';' or '.' expected) β with a
per-line error, not a single obvious one. N-Quads is supported, but only as
the application/n-quads media type over the REST endpoint, which is what
--format nquads is there for. Gzip is handled transparently for both.
The REST path is verified working β against a daemon on port 12110:
curl -u "$RDFOX_ROLE:$RDFOX_PASSWORD" -X POST "http://localhost:12110/datastores/bench?quad-table-type=quad-table-sg-fi"
curl -u "$RDFOX_ROLE:$RDFOX_PASSWORD" -X POST "http://localhost:12110/datastores/bench/content" \
-H "Content-Type: application/n-quads" --data-binary @part-00000.nqNote the storage type is a query parameter on store creation there. This is the one route that takes N-Quads, and it sidesteps the sandbox-directory restriction entirely since the data goes over the wire rather than being read from a path.
A run against a live instance:
cd /path/to/RDFox-macOS-arm64-7.6
./RDFox sandbox . \
"dstore create bench quad-table-type quad-table-sg-fi" \
"active bench" \
"set output null" \
"import /path/to/out/data/part-00000.trig.gz" \
"info" \
"answer /path/to/out/queries/q03_graphs_mentioning_hub_entity.rq"importaccepts*globs, so a checkpoint isimport .../part-0000{0,1,2}.trig.gzor an explicit list of the first k shards from the manifest.inforeportsAggregate memory consumed (bytes)andAggregate number of explicit factsβ the two numbers the study needs.maxmemorywith no argument prints server-wide usage.answer <file>runs a.rqfile and printsTotal statement evaluation time.set output nulldiscards result rows so you time evaluation rather than formatting.
sandbox is throwaway β it sets no server directory, so nothing survives the
process, and it also disables filesystem sandboxing, which is why it imports
from any absolute path without further setup. For a scaling run that is usually
what you want: each measurement should start from a cold, empty store anyway.
shell and daemon use a server directory and can persist, but two things
bite:
- Filesystem sandboxing is on.
importof a path outside the sandbox fails withPath '...' is not within the sandbox path.Pass-sandbox-directory <dir>covering your data (or""to disable). -persistence file-sequencemay not be covered by your licence βThe active license does not support the 'file-sequence-persistence' feature.-persistence fileworked on the licence used here.
Initialising a persistent server (first role is created at init). This is the
form already used for the instance at ~/.RDFox:
./RDFox -persistence file -role <role> -password <password> -channel unsecure initIts server.params ends up holding persistence file and endpoint.params
channel unsecure. Note that sandbox-directory is not recorded there, so it
has to be passed on each invocation:
./RDFox -sandbox-directory /path/to/data shell .RDFox dlopens libssl.3.dylib / libcrypto.3.dylib at runtime rather than
linking them, so otool -L shows nothing and a missing OpenSSL only surfaces
when something actually needs it. The usual fix is:
export DYLD_LIBRARY_PATH="/opt/homebrew/opt/openssl@3/lib:$DYLD_LIBRARY_PATH"That works, but it redirects dylib resolution for the whole process, not just OpenSSL β it can quietly shadow other libraries the benchmark depends on (zlib for the gzipped import, the allocator), which is an uncontrolled variable in exactly the measurements being collected. RDFox reads two targeted variables instead, which is the better choice for a benchmark:
export RDFOX_LIBSSL_PATH=/opt/homebrew/opt/openssl@3/lib/libssl.3.dylib
export RDFOX_LIBCRYPTO_PATH=/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylibVerified: a full persistent-server run with these set and DYLD_LIBRARY_PATH
unset reports the same 151,994,368 bytes as every other fresh import.
RDFox also reads RDFOX_ROLE and RDFOX_PASSWORD, which keeps credentials out
of the command line β worth using, since a password passed as -password is
visible to any ps on the machine.
If that server already holds other data stores, a daemon loads all of them
into RAM at startup.
info is per-data-store so its figures stay clean, but anything server-wide
(maxmemory, RSS) would include them, and a benchmark store created there
persists to disk alongside real work. Use a throwaway
-server-directory, or plain sandbox mode.
There is no persistent parameter on dstore create β persistence comes from
the server-level setting, and dstore show then reports Persistent: yes.
Persistence does not change what you are measuring. Same 1.45M quads,
quad-table-sg-fi, memory reported by info:
| Aggregate memory | Note | |
|---|---|---|
Fresh import, sandbox |
151,994,368 | |
| Fresh import, persistent server | 151,994,368 | byte-identical |
| After restart, restored from disk | 150,765,568 | β0.8% |
After dstore load from a .bin |
150,274,048 | β1.1% |
RDFox is RAM-resident either way; persistence adds a disk write, not a different in-memory layout. But note the bottom two rows: a restored store measures about 1% smaller than a freshly imported one, so keep the harness consistent β measure every point after a fresh import, or every point after a restore, never a mix. A 1% artefact is small, but so are some of the differences you are looking for.
If you want to avoid reloading a large corpus repeatedly, dstore save <name> <file> / dstore load <name> <file> works without the persistence licence,
in plain sandbox mode, and preserves quad-table-type. For the 1.45M-quad set
that was a 92 MB binary loading in 0.74 s against 1.14 s to re-import the TriG.
Verified end to end on 50,000 graphs / 1,453,641 quads β RDFox reported exactly the manifest's quad count under both storage types, and all ten queries returned rows:
quad-table-sg-fi |
quad-table-sg-pi |
|
|---|---|---|
| Aggregate memory | 151.9 MB | 135.2 MB |
| Import time | 1.29 s | 0.97 s |
| Explicit facts | 1,453,641 | 1,453,641 |
That single point is only a smoke test, not a result β it is one size, one seed, and a cold cache. It does confirm the pipeline measures what it should.
One caveat about this machine: RDFox prints a startup warning that only ~0.5 GB appears available. On macOS that counts free pages only; ~24 GB was actually reclaimable (inactive + purgeable). Don't size the experiment off that warning, but do close the browser before a large run β RDFox is RAM-resident and a real shortfall would distort exactly the numbers you are collecting.
This is the one store-specific component. What it does is generic; how it talks to the store is not.
python3 generate_dataset.py --graphs 500000 --workers 8 --out data500
python3 measure.py --data data500 --rdfox /path/to/RDFox-macOS-arm64-7.6Writes results/results.json (every raw reading) and results/report.html (a
self-contained report β inline SVG charts, no assets, no CDN, no build step).
Re-render without re-measuring:
python3 report.py results/results.jsonFor each storage type it starts one RDFox process in sandbox mode, imports
shards cumulatively, and stops at every manifest checkpoint to record info and
time all ten queries. Useful flags: --types (which quad-table-type values to
compare), --target-block (seconds of repeated work per measurement β higher is
more precise and slower), --min-graphs (skip the smallest checkpoints),
--verbose.
A 500k-graph / 14.5M-quad sweep across both storage types takes about 55 s.
Three things the harness does deliberately, because each one changes the answer:
-
Subtracts the empty-store baseline. RDFox holds ~3.7 MB before a single quad is loaded. Left in, that fixed cost drags the fitted exponent toward zero on any small corpus β the gross figure fits 0.55 (RΒ² 0.88) where the net figure fits 0.84 (RΒ² 0.98). The report shows both.
-
Refuses to quote an exponent it cannot support. A power law through scattered points always returns some number. Fits below RΒ² 0.75 are reported as "no reliable trend" rather than as a value.
-
Times queries itself rather than trusting RDFox's printout. RDFox reports
Total statement evaluation timeto the millisecond, which reads0.000 sfor the point-lookup queries at every corpus size β they simply cannot be measured that way. Instead each query is repeated withexec Nand the block is timed from outside the process (RDFox flushes stdout per command), then divided by N, with the shell's per-statement cost (~30 Β΅s, measured at each size with a trivial control query) subtracted. N is chosen per query per size to target a fixed amount of work, so a 14 Β΅s lookup is averaged over thousands of executions and a 290 ms scan over a few.This changed the conclusions, not just the charts. Under millisecond timing the scan queries appeared to scale linearly (1.07β1.31) because their small-size readings were pinned at the resolution floor and excluded from the fit. Measured properly they are sub-linear (0.65β0.95).
-
Distinguishes "constant" from "no trend". A near-zero exponent always comes with a low RΒ², because there is no trend to explain β that is a result, not a failed fit. The report separates the two by spread: small exponent plus little variation is reported as constant, scattered points as no reliable trend.
500,000 graphs / 14,525,277 quads, 15 size points:
quad-table-sg-fi |
quad-table-sg-pi |
|
|---|---|---|
| Memory (net of baseline) | 1.29 GiB | 1.13 GiB |
| Bytes per quad | 95.4 | 83.9 |
| Memory scaling exponent | 0.83 (RΒ² 0.981) | 0.81 (RΒ² 0.980) |
- Memory grows sub-linearly (exponent ~0.84). Cost per quad falls from ~800 bytes at the smallest sizes and settles near 95 β the resource dictionary amortises as entities and predicates recur across graphs, which is exactly the behaviour this pattern needs to be viable.
-piwas smaller and never slower. 12% less memory, and identical times on every query except one.- 4 of 10 queries are constant-time β flat across a 51,876Γ increase in
data, all under 40 Β΅s at full size. That includes
q01, the "given a triple, recover its subsection" lookup the whole pattern exists to serve: 13.9 Β΅s at 14.5M quads, statistically unchanged from 24 Β΅s at 280 quads. Provenance retrieval does not degrade as the corpus grows, which is the central result. - Scan-shaped queries scale sub-linearly:
q080.65,q090.77,q040.79,q070.93,q030.95. Even the full-scan aggregates beat proportional growth. q03is 1.9Γ faster on-pithan on-fi(154 ms vs 290 ms). The gap widens with scale β 1.0Γ at 581k quads, 1.9Γ at 14.5M β and reproduced within 0.3% when the storage types were run in the opposite order, while the other queries stayed at 1.00Γ.q03is the graph-keyed fan-out from a bound subject, which is worth knowing if that access path matters to you.
Caveats: one store, one machine, one seed, one dataset shape, cold cache, and
sandbox mode throughout, so every figure is on the "freshly imported" baseline. Before
drawing conclusions, vary --seed and re-run β and treat the single-run numbers
above as a starting point rather than a result.
Everything except measure.py is store-agnostic. To point this at a different
triplestore, replace the driver and keep the contract:
- Create an empty store in the configuration under test, and record its memory before loading anything. Subtracting that baseline is what makes the scaling exponent describe the data rather than the engine's fixed overhead β on a small corpus the gross figure fits an exponent near zero purely because the baseline dominates.
- Import shards cumulatively in
manifest.shardsorder, stopping at eachmanifest.checkpointsentry (shards_to_loadsays how many files in). Do not reload between checkpoints, and stay in one process β separate processes per size mostly measure allocator state and page cache. - Record memory and the stored quad count at each checkpoint. The count
should equal that checkpoint's
quadsexactly, since the generator emits no duplicates; a mismatch is a finding worth chasing. - Time each query in
queries/. Whatever timer the store exposes is probably too coarse for the fast ones β RDFox's reads0.000 sfor the point lookups at every size. Repeat each query enough times to make a block measurable, time the block externally, divide, and subtract the per-statement overhead measured with a trivial control query. - Emit
results.json.report.pyreads exactly five top-level keys:runs,fits,dataset_config,dataset_totalsandtarget_block_seconds. A run is{storage_type, baseline_bytes, points: [...]}, and a point is{graphs, facts, memory_bytes, memory_net_bytes, import_seconds, queries: {name: {seconds, answers, repeats}}}. Copydataset_configanddataset_totalsstraight out of the dataset'smanifest.json, and buildfitswithmeasure.py:analyse(runs)β that function andfit_power_laware plain Python over the structure above and contain nothing store-specific.
Then python3 report.py results/results.json gives you the same charts, and
adding the new store's storage_type values to a single run makes the report
compare them side by side on one axis.
Two things that are easy to get wrong and are not specific to RDFox: check which
serialisations the store's loader actually accepts (RDFox picks its parser from
the file extension and silently misparses .nq as Turtle), and check whether
its "memory used" figure is the store's own accounting or the process RSS β they
are not the same number and only the first is comparable across engines.
| Flag | Default | Effect on the experiment |
|---|---|---|
--graphs |
10000 | The main scaling variable: named graphs = leaf subsections. |
--triples-per-graph |
20 | Quads per graph. Sweeping this against --graphs at constant total quads separates number of graphs from number of triples β the key confound in this pattern. |
--text-chars |
600 | Mean length of pv:text. 0 omits the property entirely; running with and without isolates how much memory the long provenance literals cost. |
--p-global / --p-recent |
0.25 / 0.45 | Entity reuse across graphs. Higher --p-global means the same triple recurs in more graphs, which is exactly what a quad table must store once per graph. |
--head-entities |
10000 | Size of the shared hub pool; with --head-skew, controls how hot the hottest entity is (drives q03/q06). |
--local-per-graph |
4 | New entities per graph, so it sets how fast the resource dictionary grows. |
--fanout |
8,6 |
Section tree shape, e.g. 5,4,3 for three levels. Sets provenance-hierarchy depth and hence the cost of pv:parentSection+ walks. |
--triples-sigma |
0.6 | Lognormal spread of graph sizes; 0 makes every graph identical. |
--format |
trig |
trig for RDFox's shell import; nquads for the REST endpoint and other tooling. |
Everything is deterministic given --seed, and the full configuration is
recorded in manifest.json.
Two runs worth doing early, since they bound the result: --text-chars 0
(provenance without text) and --triples-per-graph 1 (extreme graph-to-triple
ratio, the worst case for per-graph overhead).
The ten queries live in queries/. That committed copy is the exact
set used for the published results above β regenerated from the same seed and
verified to reproduce the same constants β so you can read the SPARQL without
running anything.
They are not hand-written per store: generate_dataset.py emits a fresh set
alongside every dataset, bound to constants sampled from that data, because a
query on a constant that does not occur returns instantly and measures nothing.
Use the ones your own run produces (<out>/queries/); the committed copy is a
reference, and its constants only exist in a 500,000-graph dataset built with
the default seed.
| Query | What it stresses |
|---|---|
q01_provenance_of_one_triple |
The core access path: one triple β its subsection, text, section number, parent. Point lookup. |
q02_contents_of_one_graph |
Bound graph, unbound triple pattern β cheapest graph-keyed access. |
q03_graphs_mentioning_hub_entity |
Fan-out from a hot entity across many named graphs, with provenance. |
q04_quads_per_document |
Full scan plus GROUP BY over the graphβdocument link. |
q05_ancestor_chain |
pv:parentSection+ walk up the hierarchy. |
q06_cross_graph_two_hop |
A join that deliberately crosses a graph boundary. |
q07_text_search |
CONTAINS over every pv:text literal. |
q08_class_with_provenance |
Large join between named graphs and the default graph. |
q09_total_quads |
Baseline scan speed. |
q10_subtree_extraction |
Provenance-driven retrieval: everything under one section. The realistic read pattern. |
q02, q03 and q10 are the graph-keyed access paths β the ones that exercise
whatever a store does with named graphs, and so the ones to watch when comparing
storage configurations. On RDFox that meant quad-table-sg-fi against
quad-table-sg-pi. q04 and q09 bound the scan cost.
The query set adapts to the configuration: --text-chars 0 drops q07 and the
pv:text join in q01, since the property no longer exists. One caveat worth
knowing before you read a timing: at --triples-per-graph 1 every graph holds a
single rdf:type triple, so no two-hop path exists anywhere in the corpus and
q06 is legitimately empty β time it on that config and you are measuring the
cost of finding nothing. validate_dataset.py reports it as a failure, which is
the signal you want: don't include that number in the comparison.
pip install rdflib
python3 validate_dataset.py out/Checks N-Quads syntax, that manifest counts match the bytes on disk, that no
quad is emitted twice, that every named graph URI is fully described and every
pv:parentSection resolves, that all ten queries parse/execute/return rows,
and that a separately generated smaller dataset really is a byte-identical
prefix. It loads everything into memory, so run it on a few thousand graphs β
that exercises every code path.
| Prefix | URI |
|---|---|
pv: |
https://rdfox-stress.example.org/prov/ |
| entities | https://rdfox-stress.example.org/e/{id} |
| predicates | https://rdfox-stress.example.org/p/{k} |
| classes | https://rdfox-stress.example.org/c/{k} |
| sections / graphs | https://rdfox-stress.example.org/sec/{doc}/{1.2.3} |
| documents | https://rdfox-stress.example.org/doc/{n} |
Change the namespace with --base. The default still carries the name of the
first store measured; it is only a namespace, but --base https://example.org/
gives you a neutral one if that matters for your run.
