Benchmarks¶
How Podspine measures itself against the v2 performance targets, and how to reproduce the numbers on your own hardware.
This is the measurement half of the Sprint 5 performance-validation work — it
checks Podspine against the four performance targets in the table below
(ingest time, feed render p95, audio time-to-first-byte, and idle memory).
The point is not to publish a leaderboard — it is to answer one question before
any further efficiency work is built: are the NFR targets already met, or is
there a real bottleneck to fix? (Opt-in transcoding — PODSPINE_TRANSCODE —
and saver mode's regenerate-on-demand chapter cache have since shipped; what
remains hypothetical is byte-range chapter serving with no split files at all.)
Run the harness on the box you actually deploy to and let the numbers decide.
Targets¶
| NFR | Metric | Target |
|---|---|---|
| P1 | Ingest / pre-split | ≤ 2 min per 10h book |
| P2 | Feed render latency (p95) | < 200 ms |
| P3 | Audio time-to-first-byte (LAN) | < 300 ms |
| P4 | Idle resident memory (RSS) | < 50 MB |
Running the harness¶
It needs only bash, ffmpeg, ffprobe, curl, awk, and sort — no extra
crates, and it touches nothing the server ships. It builds the release binary
(if absent), synthesizes a chapterised sine-tone .m4a, boots podspine against
a throwaway library and data dir on 127.0.0.1, drives it with curl, prints a
report, and tears everything down.
Knobs (all optional env vars):
| Var | Default | Meaning |
|---|---|---|
DURATION_SEC |
600 |
Synthetic book length in seconds |
CHAPTERS |
12 |
Number of chapters to split into |
N_FEED |
200 |
Feed requests sampled for the p95 |
N_AUDIO |
30 |
Audio requests sampled for TTFB |
PORT |
18080 |
Loopback port to bind |
KEEP |
unset | Keep the temp work dir for inspection |
How each number is measured¶
- Ingest (P1) — wall-clock from launching the process to the book appearing on the home grid (scanned, split, and indexed). This includes fixed startup overhead (process init + bind), so the extrapolation to a 10h book is conservative: startup does not scale with book length, but the linear extrapolation pretends it does. Treat the 10h figure as an upper bound.
- Feed render p95 (P2) —
curltime_totaloverN_FEEDrequests to/feed/{id}.xml, after one warm-up (the feed passes the self-check and renders fresh each time). Percentiles via nearest-rank. - Audio TTFB (P3) —
curltime_starttransferoverN_AUDIOranged (Range: bytes=0-65535) requests to/audio/{id}/1. This is loopback, so a real LAN client adds one network hop; budget accordingly against the 300 ms target. - Idle RSS (P4) —
VmRSSfrom/proc/<pid>/statusafter the run (Linux only; reported asn/aelsewhere).
Reference run¶
Illustrative only — captured 2026-08-19 on a Linux x86_64 host, loopback, with a synthetic 300s / 8-chapter book, after the parallel chapter split (v1.7.0). Your hardware will differ; re-run locally.
| NFR | Metric | Measured | Target | Result |
|---|---|---|---|---|
| P1 | Ingest (this book, 300s) | 0.24 s | — | — |
| P1 | Ingest → 10h (extrap.) | ~29 s | ≤ 120 s | PASS |
| P2 | Feed p50/p95/p99 | 1.3 / 2.3 / 2.5 ms | p95 < 200ms | PASS |
| P3 | Audio TTFB p50/p95/p99 | 0.7 / 0.8 / 0.9 ms | p95 < 300ms | PASS |
| P4 | Idle RSS | 9.5 MB | < 50 MB | PASS |
Reading the reference run¶
All four targets clear with wide margins — feed render and audio TTFB sit ~100×
under budget, and idle memory is ~5× under. P1 used to be the figure to
watch, and it improved substantially in v1.7.0: chapters are now split in
parallel, bounded by a CPU-sized ffmpeg gate (measured ~9× on a 20-core host for
a 40-chapter book), so many-chapter books scale far better than a linear
per-chapter model suggests. A library scan also runs whole books in parallel
through a pool of the same size. On a 20-core host, a 200-book by 8-chapter
library scanned in 8.7 s instead of 36.1 s (saver mode: 8.1 s instead of 35 s).
Pre-split ingest remains I/O-bound on slow storage.
This applies only to chaptered books — whole-file episodes (MP3-folder
tracks, chapterless singles) are served in place from the library and skip the
split entirely (Sprint 6.2), and saver mode trades the persistent split set
for a regenerate-on-demand cache. Because the extrapolation folds in fixed
startup cost it is pessimistic, but if a real 10h chaptered book on your disk
still lands near the 2-minute ceiling, that is the signal that on-the-fly
byte-range chapter serving (no split files at all) is worth the complexity —
otherwise premature.
First-scan profile (large library)¶
scripts/bench.sh times one book. scripts/bench-scan.sh profiles the first
scan of a LARGE library and shows how it scales.
The harness builds one synthetic chaptered .m4a. It copies that file into
BOOKS book folders. Distinct paths scan as distinct books, so the copies still
count as separate books. The harness then boots podspine with per-stage timing
on, waits for every book to appear on the grid, and prints a breakdown.
The per-stage numbers come from the scanner debug logs (target
podspine::scan_timing). The harness turns them on through RUST_LOG. These
logs are silent at the default level, so the run measures the real scan with no
behavior change.
Knobs (all optional env vars):
| Var | Default | Meaning |
|---|---|---|
BOOKS |
200 |
Number of books in the library |
CHAPTERS |
8 |
Chapters per book |
DURATION_SEC |
300 |
Per-book length in seconds |
STORAGE_MODE |
full |
full or saver |
TIMEOUT_SEC |
1800 |
Seconds to wait for every book to index |
PORT |
18081 |
Loopback port to bind |
KEEP |
unset | Keep the temp work dir for inspection |
Raise TIMEOUT_SEC for a large BOOKS run whose scan runs longer than the
default 30 minutes.
For each stage (probe, resolve, split, cover, index) the report prints the total seconds across all books, the call count, and the mean per book. It also prints three summary lines:
scan_total: the scanner's own wall-clock for the whole scan.book_total: the sum of every book's ingest time.- The ratio
book_total / scan_total: how much the books overlapped.
A ratio near 1.0 means the scan ran one book at a time. Above 1.0 the books
overlapped. Each book_total is wall-clock, so it also counts the time that
book waited for an ffmpeg permit. The ratio can therefore pass the core count
once the gate is the queue, and it is a shape, not a speedup. Read scan_total
for the real cost of a change. A large per-stage total, for example split or
probe, names the stage to target first.
The harness reuses target/release/podspine. To measure a code change, rebuild
first with cargo build --release, or delete the binary so the harness rebuilds
it.
The optional /metrics endpoint (Prometheus counters/histograms, enabled with
--metrics-bind on its own listener) is intentionally not part of this harness —
the harness measures the serving path, not the exporter.