diff --git a/bench/README.md b/bench/README.md index 4c8b54ae..bffc4eb6 100644 --- a/bench/README.md +++ b/bench/README.md @@ -318,6 +318,13 @@ green wake-path work described below): | rust | 135 | 7.4 m | 2.4 mb | | zig | 135 | 7.4 m | 2.7 mb | +read the rows that oversubscribe os threads (pith os-thread, rust, zig) +with the box in mind: eight threads on two cores, so they swing run to run. +across two suite runs a week apart rust moved 135 -> 94 ms and zig 135 -> 204 +with no code change on either side, and pith's os-thread row has been seen +anywhere from ~260 to ~940. the green and go rows are the stable ones and are +what the comparison rests on. + for most of this benchmark's life pith lost it outright — 580ms os-thread and 782ms green against go's ~70, roughly 8x behind. two fixes on 2026-07-26 changed that. the first was found with `perf`: rust's standard diff --git a/docs/limitations.md b/docs/limitations.md index e704696d..79ed86fa 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -107,3 +107,46 @@ correctness story: cross-module map reads, set codegen, negative float literals like `-1.0`) were re-checked and all pass; they are now pinned by regression tests (`tests/cases/test_xmod_float.pith` and friends). + +## the green backend, and what it would take to make it the default + +as of 2026-07-27 the green backend (`PITH_GREEN=1`) wins every shape this repo +measures: spawn is ~30x the os-thread backend at a seventeenth of the memory, +the channel fan-out benchmark runs 2.6x faster than os threads and ahead of rust +and zig, and the whole regression corpus — 260 cases, both worker counts — +produces byte-identical output to the os-thread backend (`make +verify-green-corpus`, run in ci). the reason it is still off by default is not +the scheduler; it is the list below. + +- **the numbers are from one 2-core box** — every comparison in + docs/performance.md and bench/README.md was measured on the same small + machine. "green wins everywhere" is true there and unverified anywhere else, + and the original decision to keep green opt-in explicitly wanted wider + hardware first. this is the gating question, and it is a judgement call rather + than a task. +- **preemption is opt-in at build time** — safe-points are only emitted under + `PITH_GREEN_PREEMPT=1`, so a default-on green backend would let a compute-only + task that never touches a channel or socket hold its worker. the cost of + turning them on was measured: ~0% on real work (the event-ledger and + std-pipeline benchmarks are within noise) and ~6% on a degenerate + 200-million-iteration arithmetic loop. that is cheap enough — but the flag + must flip *with* the green default, not before it, or os-thread builds pay for + a check that never fires. +- **dns still blocks a worker** — `green_connect` resolves with a synchronous + `getaddrinfo` before its non-blocking connect, so a dial that waits on dns + parks the whole worker. the tcp handshake and everything after it already + yield. offloading resolution to a small blocking pool is the fix. +- **placement is left to luck** — a task pins to the first worker that runs it, + so whether two tasks that talk to each other land together is chance. the + fan-out benchmark is bimodal because of it: ~60 ms when the pipeline happens + to share a worker, ~130-170 ms when it splits, against ~46 ms pinned to one + worker with `PITH_GREEN_WORKERS=1`. cross-worker wakes are the whole remaining + gap to go on coordination-heavy work, and colocating communicating tasks is + the open lever. + +two related ownership gaps, both bounded leaks rather than unsafety, are also +outstanding: passing a bare `T!` or `T?` local as a call argument leaks its +payload (the caller-side cascade does not yet treat a call argument as the +borrow it now provably is), and extracting the same optional local twice is a +rare use-after-free that needs a second-extraction check rather than the blanket +retain that was tried and reverted for regressing the common single case. diff --git a/docs/performance.md b/docs/performance.md index 0924be58..7eeddcaf 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -142,7 +142,16 @@ that same in-userspace scheduler, and this benchmark is the case it was built for. running the reader, writer, and worker tasks as coroutines on one worker (`PITH_GREEN_WORKERS=1`) turns every per-call handoff from a futex wake into a userspace switch. measured on the 2-core dev box, conc=8, medians of 5 runs, -per-call counts over warmup+calls (2026-07-20 rerun): +per-call counts over warmup+calls (2026-07-20 rerun). + +**these absolute numbers predate the wake-path work of 2026-07-26** (the +coroutine stack pool, the channel condvar fix, and the slab-free wake), so +they understate green as it stands. re-running the same shape on 2026-07-27 +at a smaller batch put pith at 5067 calls/sec os-thread, 8075 at one green +worker (+59%), and 7083 at two (+40%) — the same relationships the table +below records (+53% and +41%), so its conclusions hold. the table is left at +its original batch size rather than replaced with a smaller, non-comparable +run; re-measuring it at the documented batch is a tracked follow-up. | 16 B, conc=8 | calls/sec | ctx-switches/call | cpu | |---|---|---|---| @@ -247,6 +256,28 @@ still grows with the total (the closure release lands there too, but the slot does not); giving that slab the same reclamation is a tracked follow-up, and until then this bound is a green-only property. +spawn *speed* was a separate problem, fixed later: every green task +allocated a fresh 1 MiB coroutine stack (an mmap plus a guard-page +mprotect) and unmapped it on completion, costing a TLB shootdown across +every core. the kernel's address-space bookkeeping dominated spawn. +finished coroutines now donate their stacks to a pool and the next spawn +reuses one. spawning 20k tasks and awaiting them all, medians of 5, +interleaved with a go canary on the 2-core box (2026-07-26): + +| 20k spawn + join | elapsed | peak rss | +|---|---:|---:| +| os threads | ~1450 ms | 174 mb | +| green, before the pool | ~580 ms | 10 mb | +| green, after the pool | ~50 ms | 10 mb | +| go | ~27 ms | 11 mb | + +page faults over the run fell 21832 -> ~2500 and context switches 25748 -> +~3000. shrinking the stack from 1 MiB to 64 KiB changed nothing before the +pool, which is the tell: the cost was the *number* of mappings, not their +size. green is now within ~2x of go on this shape at the same memory, from +~29x, and roughly 30x faster than the os-thread backend, which pays a real +thread per task. + `bench/chan_fanout` — the same fan-out shape with cross-language comparators: four producer tasks push one million messages through a bounded channel (capacity 256) and four consumer tasks drain them, @@ -258,6 +289,13 @@ same checksum. medians of 9 on this 2-core box, 2026-07-26: | total | 438ms | 171ms | **75ms** | 135ms | 135ms | | peak rss | 3.0 mb | 3.0 mb | 2.0 mb | 2.4 mb | 2.7 mb | +read the rows that oversubscribe os threads (pith os-thread, rust, zig) +with the box in mind: eight threads on two cores, so they swing run to run. +across two suite runs a week apart rust moved 135 -> 94 ms and zig 135 -> 204 +with no code change on either side, and pith's os-thread row has been seen +anywhere from ~260 to ~940. the green and go rows are the stable ones and are +what the comparison rests on. + this table used to read 580 / 782 / 69 / 82 / 201 — pith last by ~8x, with the green backend slower than os threads on the shape it was built for. the story of closing it is worth keeping because each step was