Files
smarm/benches/README.md
T

155 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Benches
Two families live here: **comparison benches** (smarm vs tokio, predating
v0.5) and the **run-queue shootout** (v0.5 phase 4). All are plain binaries
(`harness = false` in `Cargo.toml`), so `cargo bench` just builds in release
and runs `main()` — no criterion, no magic.
```
cargo bench --bench <name> # one bench
cargo bench # all of them (slow; rarely what you want)
```
## Catalog
| file | what it measures |
|---|---|
| `primes.rs` | Compute fan-out/fan-in: counts primes across W workers. Pure compute throughput + spawn/join/channel cost. |
| `multi_scheduler.rs` | The original cross-runtime matrix: smarm (1 thread / N threads) vs tokio (current_thread / multi_thread) on compute, ping-pong, and spawn throughput. |
| `general.rs` | Workloads where neither runtime has a structural edge. Large gaps here mean real per-task/per-yield overhead differences — watch these for regressions. |
| `smarm_favored.rs` | Workloads the stackful green-thread model is built for. Single-thread numbers isolate per-switch cost from contention. |
| `tokio_favored.rs` | Workloads tokio's model is built for. Expect to lose; the value is knowing *by how much* and catching the gap widening. |
| `rq_micro.rs` | Run-queue **structures** in isolation (no runtime, no actors): push/pop throughput sweeping thread count × producer:consumer ratio. Covers all three queue types in one binary — the types compile in every build; only the runtime's alias is feature-selected. |
| `rq_runtime.rs` | The **whole scheduler** with the compile-time-selected queue: yield-storm (pure queue churn), ping-pong-pairs (park/unpark latency), spawn-storm (slab + free list + queue churn), sweeping scheduler count. Comparing variants requires rebuilding per `rq-*` feature. |
| `spin_sweep.rs` | RFC 004 spinning workers: wake **latency vs idle-CPU** over `spin_budget_cycles`. Four workloads (remote-wake, fork-join, half-load, pure-idle) × budget × `max_spinners` × scheduler count; latency percentiles plus cores-busy from `getrusage`. The data that picks the budget default and on/off posture. |
## The run-queue shootout
One command; it rebuilds `rq_runtime` once per queue variant, runs `rq_micro`
once, and aggregates:
```
./scripts/bench_rq.sh
# on a big box:
SMARM_BENCH_THREADS="1 2 4 8 16 20" ./scripts/bench_rq.sh
```
Outputs land in `bench_results/` (gitignored): one full log per run, plus
`summary.csv` assembled from the machine-readable `RQCSV,...` lines every
config prints alongside the human table.
Manual single-variant runs need the feature dance (features are additive, so
the default `rq-mutex` must be switched off):
```
cargo bench --bench rq_runtime --no-default-features --features rq-striped
```
### Knobs (env vars, all optional)
| var | default | used by |
|---|---|---|
| `SMARM_BENCH_THREADS` | `"1 2 4"` | both — space-separated sweep |
| `SMARM_BENCH_RUNS` | `5` | both — repetitions; the **median** is reported |
| `SMARM_BENCH_ITEMS` | `200000` | `rq_micro` — items per measurement |
| `SMARM_BENCH_YIELD_ACTORS` / `_YIELDS` | `200` / `500` | `rq_runtime` yield-storm |
| `SMARM_BENCH_PAIRS` / `_ROUNDTRIPS` | `32` / `1000` | `rq_runtime` ping-pong |
| `SMARM_BENCH_SPAWNS` | `5000` | `rq_runtime` spawn-storm |
## The spin sweep (RFC 004)
Spinning workers trade idle CPU for wake latency: an idle worker polls
`queue_len` for up to `spin_budget_cycles` (at most `max_spinners` at once)
before falling back to a futex park, so work that lands during the spin is
claimed with no syscall. `spin_budget_cycles = 0` is the feature fully off —
the historical `thread::sleep` park, exactly preserved. `spin_sweep` measures
both sides of that trade across the budget so the curve can pick the default.
```
cargo bench --bench spin_sweep
# on a big box (scale the work up from the sub-second sandbox defaults):
SMARM_BENCH_THREADS="1 2 4 8 16" SMARM_BENCH_RUNS=9 \
SMARM_SPIN_BUDGET="0 1000 5000 20000 100000 500000 2000000" \
SMARM_SPIN_ROUNDS=2000 SMARM_SPIN_BURSTS=500 SMARM_SPIN_ITEMS=5000 \
cargo bench --bench spin_sweep | tee bench_results/spin.txt
grep '^SPINCSV' bench_results/spin.txt > bench_results/spin.csv
```
Two axes per config: **latency** (p50/p90/p99/min/max, pooled across runs) and
**cost** (cores-busy = process CPU-seconds via `getrusage(RUSAGE_SELF)` over
wall-seconds; `1.0` = one core pegged, up to `max_spinners` when every spinner
is hot). The four workloads:
- **remote-wake** — the latency target. A consumer parks on `recv`; a producer
on another scheduler timestamps just before `send`. Latency is send→receipt.
- **fork-join** — fan-out wake latency: root forks `FANOUT` actors, joins them,
gap, repeat.
- **half-load** — the cost target. Work dispatched every `PERIOD_US`, so workers
idle a fraction of each period; cores-busy climbs with budget as spin fills
the gaps. Spin cost scales with **park frequency** × budget, so this (not
pure-idle) is where the cost curve lives.
- **pure-idle** — a floor check: one actor sleeps, nothing else runs. Confirms
budget 0 stays parked and a large budget doesn't leak *continuous* CPU (a
spinner spends its budget once per park, so a truly idle window barely moves).
The crossover the sweep reveals: spinning only wins when work arrives while a
worker is still inside its budget. With a fixed inter-arrival gap, budgets whose
spin-time (`budget_cycles / TSC_freq`) exceeds the gap start catching work
before the park — so `SMARM_SPIN_GAP_US` sets the knee; tune it against your
box's TSC frequency.
### Knobs (env vars, all optional)
| var | default | used by |
|---|---|---|
| `SMARM_BENCH_THREADS` | `"1 2"` | scheduler-count sweep |
| `SMARM_BENCH_RUNS` | `3` | runs per config (latency pooled, cores median) |
| `SMARM_SPIN_BUDGET` | `"0 1000 10000 100000 1000000"` | `spin_budget_cycles` sweep |
| `SMARM_MAX_SPINNERS` | `"-"` | `max_spinners` sweep; `-` = runtime default (N/2) |
| `SMARM_SPIN_GAP_US` | `150` | idle gap between rounds/bursts (sets the latency knee) |
| `SMARM_SPIN_ROUNDS` | `100` | remote-wake rounds |
| `SMARM_SPIN_FANOUT` / `_BURSTS` | `64` / `30` | fork-join |
| `SMARM_SPIN_ITEMS` / `_PERIOD_US` | `150` / `300` | half-load |
| `SMARM_SPIN_WINDOW_MS` | `25` | pure-idle window |
Output is the house table plus a greppable `SPINCSV,...` line per config.
**On 1 core spinning is pathological** — a hot spinner starves the very thread
that would enqueue work, so the optimal budget there is 0 and you only ever see
the cost, never the benefit (the sweep will show remote-wake latency getting
*worse* with budget on one core). One core validates the harness; the real curve
comes from the many-core box.
## Reading the numbers honestly
- **Core count is the experiment.** On a 1-core machine (CI, sandboxes) the
sweep only validates the harness and catches gross pathologies —
oversubscribed schedulers measure context-switch noise, not contention.
Variant decisions come from a many-core box.
- The striped queue *should lose* at low thread counts (ticket overhead with
no contention to amortize) — that's expected, not a bug.
- Medians over `SMARM_BENCH_RUNS` absorb scheduling noise but not thermal /
turbo drift; for publishable numbers, pin the CPU governor and run a warmup
pass first.
- `spawn-storm` batches joins (1024 at a time) to stay well under the slab
cap; if you raise `SMARM_BENCH_SPAWNS` massively, that batching is why it
still works.
## Adding a bench
1. `benches/<name>.rs` with a plain `main()`; print the house table (see any
existing bench) and, if it belongs to a sweep, a greppable CSV line with a
distinctive prefix (`RQCSV,` for the shootout family).
2. Register it in `Cargo.toml`:
```toml
[[bench]]
name = "<name>"
harness = false
```
3. Take parameters from `SMARM_BENCH_*` env vars with modest defaults — the
defaults must finish in seconds on one core, the env scales them up on
real hardware.
4. Report **medians**, and keep one measurement = one fresh runtime
(`init(Config::exact(t))` inside the measured closure constructor, the
`run()` inside the timed region) so runs don't contaminate each other.