Performance limits #

Measured headline numbers for the v0.2.0 data layer. Use them to size projects, to spot regressions, and to know when you're approaching a cliff.

All numbers below were measured on a developer-class WSL2 host in 2026‑06 using pytest tests/test_data/test_perf.py -m benchmark (pytest-benchmark, warmup on, min_rounds=30 for query suites, GC disabled for query suites). Mean reported unless noted. Reproduce locally with:

pytest tests/test_data/test_perf.py -m benchmark --benchmark-only \
    --benchmark-columns=min,mean,stddev,median,iqr,ops

The means below are a "what to expect" baseline, not a regression gate — a gate wants minimum-over-rounds sampling, which is more stable than the means here.

To measure these numbers on your own machine — and get a result file you can send in when you hit a performance problem — run litmus benchmark. It runs the same store workloads against a throwaway directory and reports durable throughput per store, parallel-writer scaling, and the run's RAM/CPU footprint.

EventStore #

WhatScaleMedianNotes
Emit 1k events (bulk)1k~3.4 s mean, 35 ms minBursty contention. The mean is unstable — single bursts under 50 ms when the daemon is warm; multi‑second outliers under load. The emit benchmark itself is noisy and should not be used as a gate as written.
Query by event_type10k events1.9 msPure DuckDB filter on the typed column.
Query scale 1001002.3 msConstant-factor floor.
Query scale 1k1k13.6 msLinear.
Query scale 10k10k107 msLinear; the parse cost stops being negligible here.
Query by multi-sessionmixed5.7 ms
Pushdown outcome=failed10k38 msTyped column.
Pushdown instrument_role=dmm10k26 msTyped column.
Query by JSON payload field10k108 ms2.8–4× slower than typed-column pushdown — the win from promoting common fields to typed payload columns.
Parse-only cost10k32 msLower bound — what you pay even when the daemon hands you a perfect result set.

Cliff: linear scan past ~10k matched events trends into 100 ms+ territory. Above that, repeated re-scans within a single render add perceptible lag. Use the typed-column filters when you can — they're the difference between "instant" and "perceptible lag."

ChannelStore #

WhatScaleMedianThroughputNotes
Write scalar (name, value)100 samples716 µs~140k samples/sPer-batch call.
Write scalar1k samples7.4 ms~135k samples/sLinear.
Write scalar10k samples76 ms~131k samples/sLinear.
Write array-channel (1k waveforms × 1k samples each)1 M samples24.3 ms / 1k-calln/a per-callArray shape; per-write call writes one 1k-sample waveform.
Query scalars 1k window1k186 µs5.4k queries/s
Query scalars 10k window10k1.9 ms510 queries/s
Query with LTTB decimationn/a4.8 ms210 queries/sAdd-cost of the decimation step on top of the raw query.
channels.stream(...) context-managed100 samples1.05 ms~92k samples/sSame payload as the direct write row above. Context-manager overhead costs roughly 30 % of raw store.write throughput.
channels.stream(...) context-managed1k samples10.7 ms~93k samples/s
channels.stream(...) context-managed10k samples104 ms~96k samples/s

Cliff: ChannelStore writes scale linearly through 10k samples. At ~130k samples/s the bottleneck is the per-write append + flush batching. If you need higher sustained rates — > 50 kHz acquisition, many concurrent channels — the data path holds up but the subscribe side starts to lag the producer. Stay under ~50 kHz per channel on this release.

FileStore #

One-shot writes (files.write) #

PayloadSizeMedianThroughputNotes
Bytes blob1 KB94 µs8.9k ops/s, ~9 MB/sThe lower bound. Most of this is the sidecar atomic-rename pair, not the I/O.
Bytes blob100 KB172 µs4.7k ops/s, ~470 MB/s
Bytes blob1 MB808 µs1.1k ops/s, ~1.1 GB/sDisk cache; the OS hasn't fsynced yet.
Bytes blob10 MB11 ms median, 23 ms mean~430 MB/s amortizedStddev climbs sharply — disk pressure dominates above ~5 MB per artifact.
ndarray1 KB119 µs6.9k ops/sWrites a .npy file + sidecar.
ndarray100 KB210 µs4.1k ops/s
ndarray1 MB793 µs1.1k ops/s
Waveform (10k samples, .npz)~80 KB432 µs2.1k ops/sThe shape used by examples/08-waveform-evidence.

Reads #

PayloadMedianThroughputNotes
resolve_uri(...) only (warm, same-day partition)9 µs104k ops/sPure dir-walk lookup.
Read 1 KB bytes28 µs33k ops/sResolve + open + read.
Read 100 KB bytes31 µs29k ops/sOS page cache.
Read 1 MB bytes75 µs12k ops/sOS page cache.

Cliff: the cold-disk case is not measured (every benchmark hits page cache). Production retention design should assume reads from cold storage are 100×–1000× slower for large artifacts. The resolve_uri walk is fast today because every test session lands in the current date partition; the worst case (cross-month historical reads) scales with the number of days spanned. Per-store indexing to flatten that is on the roadmap.

Streaming sinks (files.stream(format=...)) #

The "mean per call" column below is per burst — each benchmark invocation opens a sink, writes n chunks, then closes. Divide bytes by the mean to get sustained throughput.

FormatBurst shapeMedian per burstImplied sustained
raw64 × 1 KB149 µs~430 MB/s
raw64 × 64 KB1.8 ms median, 10 ms mean~420 MB/s median, dominated by outliers
raw64 × 1 MB163 ms median, 166 ms mean~395 MB/s
jsonl32 × 10 rows121 µs7.6k bursts/s
jsonl32 × 100 rows437 µs2.2k bursts/s
jsonl32 × 1000 rows3.5 ms280 bursts/s
tdms16 × 10k float641.0 ms median, 3.9 ms mean~325 MB/s with high variance
h516 × 10k float643.3 ms~350 MB/s

Cliff: raw streaming hits a sustained ~400 MB/s on this hardware, limited by the per-chunk fsync-rename atomic. The cliff is latency variance, not bandwidth — single chunks routinely take 10× the median under contention. If you need predictable per-chunk latency, keep chunks ≤ 64 KB; if you need throughput, batch up to 1 MB per chunk and accept the variance.

jsonl is the cheapest format for event-style accumulation but scales linearly with row count per chunk because every row gets its own json.dumps. For dense numeric data, use tdms or h5.

When to worry about a regression #

Roughly, a 1.5× slowdown on any of the above numbers is regression- worth-investigating territory. The release-prep gates in tests/test_data/test_perf_daemon.py are the formal contract; they use min-over-rounds rather than means so transient spikes don't fail the build. The means in this doc are not gates — they're the "what users should expect" baseline.

Three benchmarks are known noisy:

BenchmarkWhy it's noisy
test_emit_1kBackground daemon contention; mean varies 50× between runs. Use the median, ignore the mean.
test_write_bytes[10240] (10 MB)Disk pressure. The stddev is bigger than the mean.
test_stream_raw[64] and [1024]Per-chunk variance dominates. Median is meaningful, mean is not.

Cold-storage read benchmarks and a "many concurrent producers" sustained-rate test for ChannelStore are not measured in this release.

Concurrency — multi-process scaling #

All four stores hold up under N concurrent writers. Numbers below measured at N = 1, 2, 4 spawned subprocesses (the multiprocessing spawn start method, see the fork-deadlock pitfall below). Each worker writes its own session / channel / artifact bucket so the benchmarks model the multi-UUT pytest case where N workers run concurrently against the canonical data dir.

StoreN=1 wallN=2 wallN=4 wallN=2 efficiencyN=4 efficiency
EventStore (500 events / worker)2.89 s5.21 s10.30 s89 %89 %
ChannelStore (500 samples / worker, distinct channels)296 ms304 ms370 ms97 %80 %
FileStore (100 × 10 KB / worker)328 ms333 ms426 ms98 %77 %

(Efficiency = ideal-parallel-wall / observed-wall × 100. 100 % = perfect linear scaling; 50 % at N=4 = pretending 4 workers run as 2.)

Reading these:

  • No singleton catastrophe. The EventStore daemon serves N writers without going serial; total throughput climbs ~3.9× from N=1 to N=4.
  • Constant per-process cost on EventStore (~89 % efficiency at all N ≥ 2) is fixed per-process overhead. Adding more workers gives you more total work done; it doesn't speed up any one worker.
  • ChannelStore + FileStore lose ~20 % efficiency at N=4 — filesystem contention on the atomic rename pair dominates. Stddev climbs sharply at N=4 on both. Below N=4 it's free.
  • RunStore barely parallelizes (~1.3× from N=1 to N=4): finalizing a run writes a parquet file and materializes it, which is heavy per-op and serializes more than the other stores' writes. Concurrent run finalization is the weakest scaling of the four stores — size accordingly if many stations finalize runs at once.

To reproduce all of this on your own machine, run litmus benchmark --full: it runs the same 1/2/4 writer sweep per store and reports the speedup, plus the single-writer throughput and the run's RAM/CPU footprint.

⚠ Fork-deadlock pitfall — must use spawn, not fork #

The EventStore runs background threads. Python's default multiprocessing.Pool() uses fork() on Linux, which doesn't carry those threads into the child, so the children deadlock on the first store write.

Symptom: workers spawn, then hang indefinitely; the parent never sees output from the children.

Fix: use multiprocessing.get_context("spawn").Pool(...) instead of Pool(...). Slower spawn (each child re-imports Python + libs) but no shared mutex state. The concurrency benchmarks above use spawn for this reason.

Production implication: any code that calls os.fork() or multiprocessing.Process() after the EventStore singleton is alive in the parent will deadlock the children. The slot-runner mode is safe because each pytest worker is spawned by pytest itself before the parent imports Litmus heavily; ad-hoc helpers in test code that fork their own subprocesses after emitting any events must opt in to spawn explicitly.

This is the single most important concurrency caveat in v0.2.0. It is not a Litmus bug — it is the standard Python fork + threads hazard — but the v0.2.0 docs surface it here because Litmus's singleton stores make it easy to hit.

Hardware envelope used to produce these numbers #

WSL2 on Windows, ext4 on the WSL VHDX. Single producer per benchmark. No real network — Flight runs on loopback. Numbers will vary by ±2× across SSDs, ±3× across CPU generations, and dramatically under sustained multi-process load. Treat them as a sanity check, not a contract.