ORDB storage backends and benchmarks
ORDB’s subgraph storage is pluggable: every subgraph carries a reference
to a storage backend (ordec.core.ordb.backend) that owns the
representation of its node store and combined index, the transaction
mechanics, the freeze/thaw/copy lifecycle and frozen-subgraph value
equality. The rest of ORDB (nodes, cursors, indices, queries, the updater)
is backend-independent.
Available backends
Name |
Storage model |
|---|---|
|
Persistent HAMT maps (pyrsistent), Patricia-trie integer-set buckets. The default. |
|
Same maps, sorted persistent-vector buckets (the pre-Patricia behavior; kept as a baseline). |
|
Plain dicts, full copies at every boundary including transaction begin. Strawman baseline. |
|
Plain dicts, O(1) snapshot sharing, transactions copy only what they touch (top-level dict once after a snapshot, buckets on first write). |
|
Delta chains ported from the Zig ORDB |
|
reimplementation ( |
Backend selection: the ORDEC_ORDB_BACKEND environment variable, or
programmatically ordb.use_backend(name) (context manager) /
MutableSubgraph(backend=...). Derived subgraphs (freeze/thaw/copy)
inherit their origin’s backend. The whole test suite is expected to pass
under every backend:
ORDEC_ORDB_BACKEND=delta pytest -m "not web"
Benchmark suite
The top-level benchmarks/ package (not shipped in the wheel) compares
the backends on synthetic workloads shaped like real ORDeC usage: many
small view builds, layout flatten/expand, read-only render scans,
simulation-hierarchy construction, freeze/thaw generation chains, and
index-bucket micros. ORDB benchmark workloads writes out what they
do, along with the PRNG, the checksum and the JSON output, so that a
pure-Zig ORDB can run the same workloads and be compared against (the
zigbridge FFI is deliberately not benchmarked).
Typical usage:
# list workloads and backends
python -m benchmarks.runner --list
# quick sanity run
python -m benchmarks.runner --tiny --workloads all --backends all
# full run with memory measurement and checksums (a few minutes)
python -m benchmarks.runner --workloads all --backends all \
--repeats 5 --warmup 1 --mem --checksum --out results/py.json
# the tier to actually draw conclusions from -- slow, run it deliberately
python -m benchmarks.runner --workloads all --backends all --scale large \
--repeats 5 --warmup 1 --checksum --time-limit 0 --out results/py.json
# compare (also merges results from other worlds/machines)
python -m benchmarks.report results/*.json --baseline pyrsistent-pvector
# HTML report
python -m benchmarks.report results/*.json --baseline pyrsistent-pvector \
--html results/report.html --no-tables
The default scale is sized so the full matrix stays in the minutes range;
--scale large is where asymptotic differences between backends actually
show up. Every workload/backend pair is capped by --time-limit (30 s by
default, 0 disables): once the budget is spent the runner stops starting
new repeats and says so, rather than silently reporting a truncated run as a
full one.
--html writes one self-contained page built around a workload x backend
matrix, which is the shape of the question the suite exists to answer. It
needs no dependencies and contains no JavaScript, so it is a ~25 KB file that
opens anywhere and prints to PDF from the browser.
The page is: a headline (which backend won, and on how many workloads), the
total-time matrix, the same matrix for peak and retained memory when the run
used --mem, and each multi-phase workload’s phases behind a <details>
so the page opens on the summary rather than on every number at once.
Cells are shaded on a diverging scale around the baseline – blue better, red
worse, neutral at parity – log-spaced, because the ratios span roughly 0.02×
to 90×. Every cell also carries its ratio and absolute value as text: colour
is a redundant channel, so the page survives printing, greyscale and
colour-vision deficiency. Change what everything is measured against with
--baseline.
total (a total row in the tables, the total-time matrix in the HTML
report) is the workload’s phases summed. It is derived by the report tool,
not stored in the JSON, and only appears for multi-phase workloads – for a
single-phase one the phase already is the total. It sums the phases within
each run and only then applies --stat: summing per-phase minima would
take each phase from whichever run suited it best and understate every
backend by a different margin. Untimed setup belongs to no phase, so
total is the measured work, not the wall time of the whole run.
Two checks keep a comparison honest:
python -m benchmarks.equivalencechecks that every workload produces an identical canonical checksum under every backend and runs a differential fuzz (random op sequence applied lockstep under candidate and reference backends, state compared after every operation, including transaction-isolation and abort checks).tests/test_benchmarks.pyruns the whole suite at the smallest scale in CI.
Transactions, index snapshots and immutability
While a SubgraphUpdater transaction is open, the
subgraph’s own nodes/index keep showing the pre-transaction state;
only the updater’s views expose uncommitted changes. Real code (e.g.
resolve_instances in ordec/schematic/helpers.py) queries the
subgraph mid-transaction and depends on this. Additionally, index[key]
returns an immutable snapshot of the bucket, so callers may iterate a
query result while removing exactly those nodes (the expand_rects
pattern). Backends have to get both right
(ordec.core.ordb.backend); the differential fuzz checks them.
The state mappings a subgraph hands out via .nodes/.index must
also reject in-place mutation through their public API: frozen subgraphs
cache their content hash, and backends share state objects across
freeze/thaw/fork, so a stray write would silently corrupt every subgraph
in the sharing group. pyrsistent gets this from pmap; the dict-based
backends use guarded dict subclasses whose public mutators raise
TypeError and whose index read paths ([], get, items,
values, copy) return bucket snapshots
(ordec/core/ordb/backend_fullcopy.py). Reads stay at native dict
speed, but note for cross-version comparisons that turning fullcopy’s
node store from an exact dict into a subclass costs roughly a third on
raw node reads (CPython specializes exact dicts only); cow’s node store
was a subclass all along. tests/test_ordb.py asserts the contract
under every backend.
Going forward
Patricia buckets removed the quadratic index maintenance, but the layout webdata path is still slow, and its profile no longer points at a single hotspot: what is left is the sheer volume of transactional work. Roughly in order of payoff:
webdata() mutates the graph in order to serialize it. expand_geom
opens a transaction per replaced rect — around 29k of them — and inserts
~147k nodes, paying index updates and constraint checks for a result that is
discarded once rendered. A read-only traversal prototype (walk instances
recursively with accumulated transforms, expand shapes inline, never touch the
graph) produced identical shape counts in 0.42 s where the current pipeline
takes roughly 100 s. That is the fix for the web path, and it also removes the
flatten() and mutable_copy() costs.
ordec.layout.helpers.compare() can check geometric equivalence against the
old pipeline.
pyrsistent runs as pure Python on 3.13 — its C extension does not build there,
and pmap.set dominates what remains. That costs roughly 2–3× across all
ORDB operations, not only this path. Worth pursuing upstream or vendoring the
patch; a startup warning when the extension is missing would at least make the
degradation visible rather than silent.
webdata() is not memoized. Views are frozen and hashable, so caching the
conversion per frozen view in the server would make reopening a panel free.
Further out, the schema itself amplifies: every polygon vertex is its own
node, so geometry-heavy subgraphs carry several times the node count and every
bulk operation pays for it. Running bulk transforms in a single transaction
rather than one per node is the cheap mitigation; storing vertex arrays as an
attribute (as SimArray does for simulation data)
would change the constraint-based layout workflow and is a separate
discussion.
On the benchmark side, a default-backend decision wants large-scale runs, and
runs with a working pyrsistent C extension, before it is made. The delta
backend’s weakness on micro_replace points at its index-delta merge at
commit. Candidates worth measuring: a cow backend with Patricia buckets,
and an unsorted-NID-bucket variant — ascending iteration is the only ordering
the API promises, so a hash set with sort-on-read is admissible.