Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
40 changes: 39 additions & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading