live notes on where pith's time goes and what the sprint is doing about it.
the april 2026 audit of the old c-transpiler era lives in
docs/history/performance_audit_2026_04.md.
numbers below are medians (5-7 trials) on one machine, july 2026. rerun with
the helpers in bench/ before trusting them on different hardware.
the short version, all on the same 2-core machine, fully rerun 2026-07-29
after the july hardening pass (details in the summary below). the concurrency
rows are the green backend, which is the default on linux; rows marked
PITH_GREEN=0 are the os-thread opt-out, kept for contrast:
| coordination | pith | go | rust | zig |
|---|---|---|---|---|
| chan_fanout, 1m msgs | ~133 ms (bimodal, best 69) | ~75 ms | ~75 ms | ~240 ms |
| chan_fanout, pinned to 1 worker | ~46 ms | ~75 ms | — | — |
| 20k spawn + join (batches of 64) | ~56 ms / 3.2 mb | ~8 ms / 3.8 mb | — | — |
20k spawn, PITH_GREEN=0 |
~1017 ms / 3.5 mb | — | — | — |
| services and compute | pith | go | rust | zig |
|---|---|---|---|---|
| catalog workload, 200k requests | ~114 ms | ~436 ms | ~80 ms | — |
| grpc unary echo, conc=8, 16 B | 7476 calls/s | 14731 | 12005 | — |
| http server under wrk, 30 s | 8079 req/s, rss flat (+4 kb) | 28453 req/s (+6.1 mb) | — | — |
| event_ledger, 200k events | 618 ms (1.28x go) | 481 ms | 117 ms | 137 ms |
| std_pipeline, 50k records | 576 ms (1.63x go) | 352 ms | 177 ms | — |
reading it honestly: on channel coordination green matches rust and beats zig, runs ~1.8x behind go at the default worker count (placement is still decided by first-resume luck, hence the bimodal spread), and beats go pinned to one worker. raw spawn/await is where go's scheduler still clearly wins; what the green backend buys over pith's own os-thread backend there is ~18x the speed at flat memory. the service-shaped rows are the strongest: the catalog workload — lookups, filtered scans, json batch scoring — runs ~4x faster than go's version, because a flat struct decodes in one pass with no reflection, and the http server holds byte-flat rss across a sustained load where the go server grows. the compute rows keep their long-standing shape: modestly behind go, well behind rust and zig, with string building the remaining gap.
between 2026-07-26 and 2026-07-31 the green backend became the linux default, every blocking call got a yield point, an ownership sweep fixed seven leak/use-after-free defects in the emitter and runtime, and a concurrency audit of the shared state the flip exposed fixed six more. what that changed, each measured before and after on this machine:
| what | before | after |
|---|---|---|
| a co-tenant cpu task while another task waits on dns | blocked until the lookup ended | decoupled (~78 ms) |
| the same, while another task appends to a log file | 257-273 ms | 122-124 ms |
| the same, while another task waits on a child process | 1013-1018 ms | 8-9 ms |
the same, while two tasks run process.output |
2012 ms at 1 worker, 1007 at 2 | 8 ms |
| the same, while another task sleeps a second | 1018 ms | 7-23 ms |
the same, while a select waits on an idle channel |
3015 ms | 20 ms, and the send now arrives in time |
| twelve concurrent sleepers, 120 ms each | serialized behind their workers | all wake in ~201 ms |
| one bare tcp connect to a tls server | server dead, health checks green | survives; >512 junk handshakes hold no slots |
| concurrent https requests sharing the tls config registry | racing handle counter — a handshake could pick up another config's cert and key | serialized behind one lock |
time.delay with a negative duration |
hung (≈584 million years) | returns immediately |
| json parse per request, 20k docs on one task | 89 mb, degrading | 10 mb flat, ~30% faster |
| map/list eviction churn, 800k rounds | 38 mb | 10 mb flat |
container[expr()] index keys, 800k |
26-75 mb | flat |
xs.map(f) result lists, 800k rounds |
269 mb | flat |
| fn values named in a loop, 800k | 309 mb | flat |
| struct values stored in containers, 800k | 318 mb | flat |
three of those "leaks" were masking live use-after-free bugs (a lambda stored
in a List[fn] segfaulted the moment its leak was fixed; sitegen's tag pages
came out named after the output path when struct stores stopped over-counting),
which is the argument for fixing leaks even when the memory alone would be
tolerable. the sweep is pinned by make leak-check, a growth gate that runs
six churn shapes at two round counts in ci and fails on ~2 mb of drift — it
was proven by reverting each fix and watching it go red.
the cost side, stated plainly: single-task tight-loop file reads pay ~2x for the blocking-pool handoff that keeps file i/o from stalling a green worker, and event_ledger/std_pipeline drifted a few percent slower as hot paths gained the releases they had been skipping. flat memory was bought at list price.
all numbers from one 2-core machine, medians of 5 where quick enough to
repeat; the tables below were fully rerun 2026-07-15 (after the arc
reclamation and weak-reference work), the grpc table again on 2026-07-19
after the unary-response coalescing, and std_pipeline again on 2026-07-21
after the byte-level scanner work. the standout change in the latest
rerun: std_pipeline's transform phase fell from 347ms to 218ms once the
url and path scanners stopped minting a one-character string per byte,
taking the workload from 2.0x go to 1.5x. earlier work had already put
std_pipeline's peak rss at parity with go (239 vs 238 mb, was 1.7x), and
the cyclic-graph benchmark below shows weak references reclaiming
reference cycles refcounting alone can't. json struct decode stays faster
than go's reflection decode — a flat struct of required scalars decodes
in a single pass, filled straight into the struct.
bench/chan_fanout, the coordination benchmark, was the one pith lost
outright — ~580ms against go's ~69 for a million messages between eight
tasks. the green wake-path work on 2026-07-26 (details below) brought
the green backend to ~133ms on the 2026-07-29 rerun — level with rust,
ahead of zig, ~1.8x go — with ~46ms, faster than go on this box, when the
pipeline is pinned to one worker. the batch benchmarks measure compute and pith is competitive
there; coordination is now a genuine strength of the green backend
rather than the standing embarrassment it was.
the comparators drift a few percent between days; within a table they are comparable. go 1.24.4 (net/http, encoding/*); rust either a pinned crate set (std_pipeline) or a tiny hand-rolled scanner (catalog), read those generously.
bench/catalog_workload — service-shaped compute: lookups, filtered
searches, batch json; 200k iterations:
| go | rust | pith | |
|---|---|---|---|
| total | 386ms | 67ms | 133ms |
2.9x faster than go, within 2x of rust (2026-07-15 rerun). the batch json phase (~119ms) dominates. peak rss at 200k: go 10 mb, rust 10 mb, pith 43 mb — pith holds the whole catalog resident where the comparators stream it. this rose from ~111ms when the old six-field decode helper was retired for the general single-pass decoder — the specialized helper was a little quicker but never freed the strings it decoded; the general one attaches the struct destructor, so it is a touch slower and no longer leaks.
bench/grpc — unary echo calls over tls on loopback, the same grpc-go
server for all three clients so the numbers reflect the client. pith is
std.net.grpc over its own tls 1.3, http/2, hpack, and protobuf — no c,
no async runtime. 20,000 calls each, july 2026:
| calls/sec | go | rust (tonic) | pith |
|---|---|---|---|
| 16 B, sequential | 4011 | 2750 | 3353 |
| 1 KiB, sequential | 3862 | 2601 | 3220 |
| 16 B, 8 concurrent, one connection | 13845 | 11435 | 6832 |
| 1 KiB, 8 concurrent, one connection | 11242 | 9287 | 6084 |
sequentially pith is ~84% of grpc-go and a bit ahead of tonic — close, for a stack that is pith all the way down (its own tls 1.3, http/2, hpack, protobuf). concurrency over a single connection is the weaker spot: eight streams lift pith ~2x where go and rust scale ~3.5x. the ceiling there is not stream count but the per-call latency of the internal pipeline — every frame crosses worker → writer → socket → reader → worker, and each hop is a thread handoff (a futex wake plus a context switch). the single reader/writer is correct and required for hpack ordering. coalescing removed the redundant handoffs on that path (below); the ones that remain are structural — one context switch per pipeline thread per call — so the way to more parallelism past that is more connections, also below.
this benchmark paid for itself on the first run: client sockets had no
TCP_NODELAY, so nagle collided with the peer's delayed acks for a ~40ms
stall per round-trip — the pith client measured 22 calls/sec before the
fix and 2269 after. one socket option, and every request/response path
(http, grpc, the db drivers) benefits.
a later perf pass trimmed the client's per-call overhead: the four constant grpc request headers are cached, the http/2 writer coalesces queued frames into one write, data events share one empty header list, and — the largest win — a single-frame request body is now sent inline rather than on a spawned task, dropping one os thread per call. apples-to-apples best-of-7, sequential 16 B went from ~2550 to ~3120 calls/sec (+23%) and 8-concurrent from ~4860 to ~6260 (+29%); over the whole line that pass moved sequential pith from ~2100 to ~2850, roughly ~50% to ~72% of grpc-go. a sweep after the std thread-safety fixes and the freelist below put it at ~3175 (~77%). the box is too noisy to resolve the smaller items (cached headers, coalescing, shared event list) on wall-clock, so they stand on counted structural reductions.
a later pass inlined the whole request, not just its body. a client starts single-threaded: one in-flight call runs synchronously on the caller over the same lockstep codec the one-shot get() uses — no reader or writer task, no channel handoff — and promotes to the multiplexing pipeline once, the first time a second stream appears (the inline fast-path in std/net/http2/connection.pith). a controlled before/after put a sequential unary call at ~340 µs, down from ~368 (~8%); the table above is a fresh quiet-machine sweep after the change, 16 B sequential at ~3375 (~83% of grpc-go).
the first cut of this shipped a flow-control bug worth recording. at promotion it debited the connection send window by the total body bytes the inline phase had put on the wire — reasoning that the threaded sender would otherwise over-count the peer's receive window. but inline is strictly sequential, so by the time each response was read the peer had already consumed that request and re-granted the bytes; the debit double-counted. after a warmup of a few thousand 1 KiB calls it drove the window ~2 MB negative, and the first concurrent sends stalled waiting for it to climb back — 1 KiB/8-concurrent fell ~25% (to ~3850) while 16 B, whose debit stayed within the 64 KiB window, was untouched. the fix is to leave the window alone at promotion: sends and grants already net out. that restored 1 KiB/8-concurrent to ~5660, at or above where it began. the bug hid because the fast-path's own tests used a 20-byte stand-in for the inline total; it took a full grpc sweep on a quiet box to surface it.
the newest pass cuts handoffs on the concurrent path. a unary response is three
frames — response headers, the data message, trailer headers — and the reader
used to hand each to the waiting worker as its own event, waking it three times,
where each wake is a kernel context switch. now the reader marks a unary stream
when it opens, accumulates that stream's frames in a lock-free per-reader map,
and delivers one combined event at end-of-stream: one wake instead of three.
streaming rpcs are never marked, so their frames still arrive one at a time and a
server-streaming response stays incremental — it is never buffered to the end.
perf stat put context switches per call at 7.8 before and 5.0 after (-35%);
16 B/8-concurrent rose ~6025 to ~6832 (+13%) and 1 KiB ~5096 to ~6084 (+19%), at
equal or slightly lower cpu, taking single-connection scaling from ~1.7x to ~2x.
that was the last clearly-redundant handoff: the switches left over are one per
pipeline thread per call — the worker blocking for its reply, the reader on the
socket, the writer draining its queue — the floor for a reader/writer/worker
pipeline built on real os threads. go pays the same handoffs in userspace through
its m:n scheduler, which is most of why it does ~2x the calls at lower cpu;
closing that gap on one connection would need the same, and the cheaper lever is
more connections.
the green backend (see docs/concurrency.md) is 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).
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.
the cross-language shape was rerun in full on 2026-07-29: one local tls
server (bench/grpc), three prebuilt clients each timing 20000 unary
calls over a single connection after 2000 warmup calls, eight concurrent,
medians of three interleaved rounds. compile time is outside every lane —
the pith client is built once beforehand, the same as the go and rust
binaries.
| payload, conc=8 | pith | go | rust |
|---|---|---|---|
| 16 B, calls/sec | 7476 | 14731 | 12005 |
| 16 B, median / p99 | 954 µs / 2.9 ms | 471 µs / 2.0 ms | 591 µs / 2.3 ms |
| 1 KiB, calls/sec | 7598 | 12366 | 9545 |
| 1 KiB, median / p99 | 957 µs / 2.5 ms | 555 µs / 2.5 ms | 717 µs / 3.3 ms |
all three clients now report the same metrics: per-call median and p99 from the full sorted latency set, timed with each language's monotonic clock. pith sits at ~50-60% of go and ~60-80% of rust on throughput, with a per-call median about 2x go's — one connection means one reader task, and the two-core box splits it against the server and seven sibling callers. the earlier revision of this table printed a pith "average" computed as wall-clock over total calls, which under eight-way concurrency flattered pith by nearly an order of magnitude; the percentile reporting replaced it.
| 16 B, conc=8 | calls/sec | ctx-switches/call | cpu |
|---|---|---|---|
| os-thread | 6887 | 4.9 | 0.98 |
| green, 1 worker | 10570 | 0.55 | 0.69 |
| green, 2 workers | 9689 | 0.93 | 0.78 |
| 1 KiB, conc=8 | calls/sec | ctx-switches/call | cpu |
|---|---|---|---|
| os-thread | 6422 | 4.6 | 1.02 |
| green, 1 worker | 9090 | 0.57 | 0.70 |
| green, 2 workers | 7215 | 1.72 | 0.93 |
at one worker the mechanism does exactly what it was meant to: context switches per call fall from ~4.9 to ~0.55 (the pipeline stops touching the kernel to hand a frame between tasks), and that shows up in the wall clock — throughput up ~53% at 16 B and ~42% at 1 KiB while cpu drops below a core. that is close to the cpu grpc-go spends here, so green-at-one-worker gets pith to about three quarters of go's throughput at go's efficiency, from about half at 1.5x the cost on the os-thread backend.
the default worker count used to lose this: at two workers green was slower than the os-thread backend, because a pinned task's wake did a pool-wide notify that also roused the second worker, which then spun through its (empty) queues and contended on the shared task lock instead of parking — pure overhead, since a single connection's pipeline pins to one worker and the other has nothing it can run. giving each worker its own park spot (a pinned wake now nudges only the task's owner, and an idle worker no longer counts a peer's un-stealable pinned work as a reason to stay awake) removes that herd: at two workers green now beats the os-thread backend, ~41% at 16 B and ~12% at 1 KiB, at lower cpu (the 16 B margin is the noisier of the two — the two-worker path varies run to run).
two workers still trails one, though, and the reason is locality, not the herd. the eight request tasks are spawned from the main thread, so they scatter across both workers while the reader and writer sit on whichever worker promoted the connection; the roughly half of calls whose task lands away from the reader/writer pay a cross-worker wake per hop. one worker keeps the whole pipeline together and pays none, which is why it is fastest for a single connection. closing that last gap would need connection-aware placement — pinning a connection's request tasks to the worker that owns its reader/writer — which the scheduler can't infer from a plain spawn (a single connection wants its tasks packed onto one worker; an independent fan-out or a larger connection pool wants them spread), so it is a task-placement change above the scheduler, not a scheduler-locality one. on this two-core box a connection pool can't show the spread paying off anyway: the client already shares both cores with the go server it calls. more cores, or a server not fighting the client for them, is what a pool would need to scale. the table at the top of this section was measured on the os-thread backend, which was the default when it was taken.
past a single connection there is a connection pool: grpc.dial_pool (and
http2.open_pool) opens n independent connections and rotates calls across
them round-robin, the same subchannel trick real grpc clients use. each
connection is its own tls session and reader/writer pipeline, so calls on
different connections run on different cores. it took landing the std
thread-safety fixes first — a shared per-connection reader was racing global
state and segfaulting under true parallelism. on this 2-core dev box the pool
buys only ~11% at pool=2 and ~15% at pool=4, because eight concurrent streams
already saturate both cores (the client shares them with the go server); the
pool pays off on hardware where a single connection's ~one-core pipeline is
the real ceiling.
worth recording what did not help grpc: allocation. profiling flagged
~13% of cpu in malloc/free, and a sequential call does ~800 small struct
allocations (mostly the result box built for every T! return). but a
sequential call is ~760 µs, nearly all of it blocked on socket, tls, and
those thread handoffs — the allocation is cpu time that barely touches
wall-clock. a per-thread struct freelist (recycling small blocks instead of
round-tripping the allocator) moved grpc by ~0% and struct-alloc-bound
compute by up to ~29%; it is a compute win, kept because it is free and
safe, not a grpc one. a deeper swing at the same target — returning small
results in two registers instead of a heap box — was prototyped and shelved:
it needs boxing thunks wherever such a function is used as a value (every
higher-order call), which is a large, delicate change for a compute-only
gain the freelist already mostly captures.
a later thread-safety pass moved std.binary's reader off shared global
maps into its own struct fields (so two http/2 reader threads can't race
them). that also dropped a global-map access per frame parse: a
re-measurement put pith at ~3040 calls/sec sequential 16 B (now matching
tonic, ~74% of grpc-go) and ~6300 8-concurrent (~2x scaling, up from
~1.8x) — the concurrent path gains more because it parses more frames.
the point of that change was correctness, not speed: before it, running
several pooled connections in parallel raced the global maps and crashed;
the throughput bump was a free side effect.
bench/green_fanout — spawn short tasks in batches of 64, await each,
repeat, and read peak rss from /proc/self/status. this is the shape a
server takes when it fans work out per request, and green now bounds its
memory: a finished task hands its slab slot back for the next spawn to
reuse and releases the closure it was spawned with, so the working set is
the tasks alive at once, not the total ever spawned.
| green, batch 64 | 200k tasks | 500k tasks |
|---|---|---|
| before reclaim | 90 mb | 226 mb |
| after reclaim | 3.1 mb | 3.1 mb |
before, rss climbed ~460 bytes per task and never came back — the slab grew one entry per spawn and each task's closure was never freed. after, it is flat: 500k tasks or five million, the peak is the batch. the os-thread backend still keeps a record per task it has run, so its rss 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 (2026-07-26) | ~1450 ms | 174 mb |
| os threads (2026-07-29) | ~1017 ms | 3.5 mb |
| green, before the pool | ~580 ms | 10 mb |
| green (2026-07-29) | ~56 ms | 3.2 mb |
| go (batch twin, 2026-07-29) | ~8 ms | 3.8 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. the 2026-07-29 rerun, against a minimal go twin of the same batch shape, puts go clearly ahead on raw spawn/await (~7x) — go's scheduler reuses goroutine stacks with no fd or slab work at all — while green holds an ~18x lead over pith's own os-thread backend at flat memory. the os-thread rss collapse from 174 mb to 3.5 mb came free with the july ownership sweep: the task table's records now release their payloads on eviction like every other container.
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,
folding each into an order-independent sum. all four languages print the
same checksum. medians of 9 on this 2-core box, 2026-07-26:
pith PITH_GREEN=0 |
pith green | go | rust | zig | |
|---|---|---|---|---|---|
| total (2026-07-26) | 438ms | 171ms | 75ms | 135ms | 135ms |
| total (2026-07-29) | — | ~133ms | ~75ms | ~75ms | ~240ms |
| 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 measured and two plausible steps failed.
first, the history: splitting the channel's condvar by role and waking only the opposite role recovered ~19% in 2026-07; dropping the global handle-registry lock measured 2x worse (it had been accidentally spreading futex contention across two futexes); the lock-free mpmc ring (2026-07-26) fixed the queueing but not the elapsed time; and the two "obvious" scheduler fixes — spin-before-park and lock-free run queues — were prototyped or measured out: spinning was flat at three budgets on two cores, and per-worker queue contention measured two orders of magnitude below the slab lock's, so those queues were never the problem.
what actually closed it, found by counters rather than intuition: at one
worker — zero lock contention, one park, eight futex wakes — the run
still made ~2 million channel wake slow-paths. rust's std condvar is
futex-based and pays a futex(FUTEX_WAKE) syscall on every notify even
with zero waiters, and the channel notified on every wake; green waiters
suspend coroutines and never condvar-wait, so every message bought two
syscalls that woke nobody. counting os-thread waiters per role under the
channel lock and signalling only when one is parked removed ~70% of the
run. moving each task's scheduling state into one atomic word in a
chunked side arena then took the slab lock off the wake and resume paths
entirely (contended slab acquires on the wake path: 133 → 0), worth a
further ~12% on the cross-worker mode.
what remains is placement: the green median mixes ~60ms runs (the
pipeline happened to pin to one worker) with ~130-170ms runs (it split),
and PITH_GREEN_WORKERS=1 gives ~46ms — faster than go here. making the
scheduler colocate tasks that talk to each other, instead of leaving it
to luck, is the open lever. context switches tell the same story: green
went from 30.9k to 2.5k on this run — fewer than rust's std threads —
against go's 258.
result and optional locals — a T! or T? bound to a name lowers to a
three-slot heap value: a flag, the payload, and the error. releasing one
freed only those three slots, so the payload it owned was never dropped. a
loop that binds a fallible call a million times grew to ~277 mb; the same
loop written x := call()! stayed flat, because ! hands the payload's
count to the caller instead of leaving it in the tuple.
a local now releases that payload when every use of it is provably safe:
the flag reads (.is_ok, .is_err, == none) and the payload reads
(.ok, .err). a flag read only looks at slot 0. a payload read borrows,
and takes a fresh count where the value escapes the read. either way the
local still holds the count it was built with, so the cleanup is the only
thing that drops it.
| 1m iterations, ~260-byte payload | before | after |
|---|---|---|
optional local, probed with == none |
277 mb | 2.5 mb |
result local, probed with .is_err |
277 mb | 2.5 mb |
result local read through .ok |
277 mb | 2.5 mb |
anything else keeps the shell-only release and may still leak: a local
passed to a call, returned whole, consumed by catch or unwrap_or, bound
by if let, or mentioned inside a closure — a capture retains the shell
and not the payload, so cascading there would free memory the closure goes
on to read. a result parameter never cascades either; it is a borrow, and
the caller owns the payload.
.ok was held out when this first landed, because whitelisting it made an
http/2 valgrind case read freed memory. the cause turned out to be a
channel try_send handing its value to another task without taking a
count, which the leak had been covering up; with that fixed the wider
whitelist is clean.
bench/std_pipeline — 50k records: csv read/write, transform, json,
gzip:
| phase | go | rust | pith |
|---|---|---|---|
| csv read | 87 | 47 | 5 |
| csv write | 191 | 60 | 257 |
| transform | 48 | 27 | 218 |
| total | 324 | 136 | 482 |
1.5x go overall (4.1x when this document began), 2026-07-21 rerun.
transform — per-row field extraction, url and path scanning, and a
hash — used to be the whole gap at 347ms. it walked each url a character
at a time with s[i] == "/", and every one of those reads minted a
one-character heap string just to compare it. rewriting the url and path
scanners to compare raw bytes — and fixing the compiler to actually emit
the byte compare it had been silently skipping for character literals —
cut the phase to 218ms and dropped the run's cstring allocations from
7.1m to 2.9m. pith's csv read is still the fastest of the three. peak
rss at 200k records: go 238 mb, rust 266 mb, pith 239 mb — at parity
with go and below rust, down from 436 mb (1.7x go) earlier and 5.3x go
pre-reclaim. what remains of the gap is byte-buffer string assembly in
csv write, not per-character allocation.
bench/event_ledger — an ndjson event pipeline in four languages
(pith, go, rust, zig): decode json into structs, aggregate with maps
and a set, sign an hmac-sha256 summary. 200k events:
| phase | go | rust | zig | pith |
|---|---|---|---|---|
| gen | 127 | 31 | 19 | 322 |
| parse | 354 | 62 | 104 | 199 |
| analyze | 16 | 24 | 9 | 56 |
| total | 490 | 122 | 136 | 575 |
about 1.2x go on the total (2026-07-18 rerun), and parse — decoding
json into a struct —
is now faster than go's reflection decode: a flat scalar struct is
filled in a single pass straight into the struct, no intermediate map
and no per-field allocation. the remaining gap to go is gen, which is
string assembly. the aggregate checksum and the hmac digest come out
identical across all four languages, which is how the benchmark proves
they do the same work.
collection churn — a list and map built and dropped per iteration, 200k iterations:
| go | rust | pith | |
|---|---|---|---|
| peak rss | 8.0 mb | 2.0 mb | 2.6 mb |
| runtime | 140ms | 61ms | 187ms |
constant memory, 3x under go's gc, matching rust's shape. the url/path churn variant (heavy substring work) runs 712ms at the same constant 2.6 mb.
bench/cyclic_graph — struct nodes wired into reference cycles
(parent<->child) and dropped, 2m of them. refcounting alone cannot
reclaim a cycle, so the strong version leaks; marking one edge of each
cycle weak breaks it and the whole graph reclaims:
| strong (no weak) | weak edge | |
|---|---|---|
| peak rss | 708 mb | 2 mb |
this is the escape hatch for the one thing reference counting can't do
on its own. pith has no cycle collector by design (no gc pauses); a
weak field is a non-owning reference that reads back as none once
its target is freed. see docs/ownership.md.
bench/closure_error — the workload the collection benchmarks don't
reach: closures built, captured, called, and dropped every iteration,
and functions that fail with a heap error, propagate it up with !,
and get handled with catch and unwrap_or. 200k iterations, medians of
5 (checksums match across all three, so the work is equivalent):
| phase | go | rust | pith (before) | pith (now) |
|---|---|---|---|---|
| closures | 3 | 0 | 407 | 45 |
| errors | 54 | 33 | 137 | 133 |
| total | 57 | 33 | 543 | 178 |
the closure column is stage A of the plan below, now landed: closures moved onto a magic-tagged header and off the global handle registry, and the phase dropped from 407ms to 45ms — a 9x cut, checksum unchanged. that takes the total from ~9x go down to ~3x. the residual 45ms (vs go's 3ms) is the heap box each closure still allocates, which is separate, harder work — see the plan.
the error phase is untouched at ~133ms and reasonable: it allocates a three-slot result tuple and a heap error string per failure, work go and rust do too (2.5x go).
for the record, the slow version: a closure used to validate and
refcount through the global handle registry — a Mutex<HashSet>
locked on every new, retain, release, and validity check — while
strings and structs had already left that registry for a magic-tag
header (see the sprint below). the ~400k closures this benchmark
builds and drops took that lock several times each. the memory work
that prompted the rerun (reference-counting closures instead of
leaking them) had added the release lock, so removing the registry
paid that back too.
bench/http_server — a json api under wrk -t2 -c8 on /item?id=12345,
this 2-core machine:
| go | pith threaded | |
|---|---|---|
| req/s | ~31,600 | 16,800 |
| rss | flat ~13 mb | flat (~1 b/req) |
2026-07-15 rerun (20s, wrk -t2 -c8): the threaded server — one spawned
os thread per connection — sustains ~16,800 req/s on this 2-core machine,
a bit over half go's ~31,600. go's netpoller stays well ahead.
the per-request growth was long recorded here as ~0.8 kb, then remeasured
2026-07-23 at ~2.8 kb/request (twice, wrk -t2 -c8 for 15s), and now
sits flat — ~200 kb total over 190k+ requests, so about a byte each,
which is noise.
getting there took two goes, and the first was a wrong turn worth
recording. the growth was first blamed on a result-typed local:
serve_connection reads each request as req_result := read_... then
req := req_result.ok, and such a T! local was being released as a bare
three-slot shell that never dropped its payload. that is a real bug, fixed
(the reclamation entry above), but fixing it barely moved the server —
remeasured after it, the server still grew ~2.8 kb/request. the request
object was not the leak.
the actual cause was a for loop leaking its iterable on an early return.
serve_connection and the header helpers it calls do things like
for part in query.split("&"):
if ...:
return part # skips the loop's end-of-scope release
and a loop over a fresh iterable — a split result, a map's keys() list —
released that iterable only at the loop's normal end label. an early
return/fail/! from inside the body left the function without reaching
it, leaking the list and everything it held. two such sites on the request
path (the query split and a for key in headers lookup) accounted for
essentially the whole 2.8 kb. releasing open loops' iterables at the
function exit edges closes it, and the server holds flat under sustained
load.
build times: go cold 25.0s / warm 0.1s; pith compiles the benchmark in 2.1s every time, and the entire self-hosted compiler in under 7s.
every string derive (— fixed (single allocation now). the runtime perf counters (concat,substring,trim) copies twicePITH_PERF_STATS=1) show std_pipeline makes zeropith_string_*allocations (the copy-on-derive string type); the stdlib builds its string handling on byte buffers and list elements instead. the churn that was left hid in a different counter: indexing a string withs[i]mints a one-character heap cstring, so the url and path scanners allocated one per byte. moving them to byte-level scanning cut the run from 7.1m to 2.9m cstring allocations (the remaining 2.9m are csv field materialization), alongside 2.4m list pushes and 1.1m byte-buffer writes.- every list/map access takes a global mutex plus a hashset lookup to
validate the handle (
cranelift/runtime/src/handle_registry.rs). - lists and maps store non-int elements as one heap allocation per element
(
collections/list.rs,collections/map.rs); ints already have an unboxed fast path.
perf on std_pipeline shows ~23% of wall time inside the kernel zeroing
fresh pages (clear_page_rep plus fault handling). the reason: the native
path barely releases anything. the runtime perf counters show zero arc
allocations and zero arc releases in the whole run — bytes objects, c
strings, and most heap allocations are simply never freed, so the heap only
grows and every allocation touches brand-new zeroed pages.
measured at 200k records: pith peaks at 1.65 gb rss, go at 313 mb. the benchmarks finish because the process exits before the leak matters; a long-running server pays this as unbounded growth.
fixing this is the single biggest performance and correctness item in the backend — bigger than everything in the table below combined. it needs the compiler to emit releases (or a region strategy) for the native path, not just runtime tweaks.
strings now reclaim. heap cstrings carry a refcount header, and the compiler emits the ownership operations: retain on binding a borrowed value, release on reassignment and at every return, transfer on returns, retains at each escape point (struct fields, containers, tuples, closure captures). params are borrows — an unmodified string parameter costs no rc traffic at all — and concat/interpolation chains free their intermediates as they fold.
measured on a 300k-iteration concat/substring/trim loop:
| before | after | |
|---|---|---|
| peak rss | 85.5 mb | 15.2 mb |
| runtime | 131ms | 81ms |
the header bought a second, unplanned win: len() on a heap cstring now
reads the stored length instead of running strlen. profiling showed the
compiler spent ~80% of its own runtime in strlen — every
while i < s.len() loop over a large string was quadratic. with the
header length, compiling the whole self-hosted frontend went from ~45
seconds to under 2 seconds. make self-host is now a 1.7s operation.
lists, maps, and sets are refcounted shared handles now: the emitter retains on aliasing binds and escapes, releases on rebinding and at every return, and containers created with a known element type release their elements when the last count drops. removed and overwritten elements are deliberately NOT released — a borrow of one may still be live — so those leak until escape analysis can prove otherwise; only the free path cascades.
the payoff shows on churn-shaped work (the server case): a loop building a 50-element list and a small map per iteration, 200k iterations —
| before | after | |
|---|---|---|
| peak rss | 218 mb | 2.6 mb |
| runtime | 343ms | 179ms |
constant memory where growth was unbounded. std_pipeline's peak drops more modestly (1.65 gb to 1.45 gb at 200k records) because its memory is dominated by bytes objects and buffers, which still never free.
peak rss on the compiler itself barely moves: that memory is the ast and token structures held in globals. bytes and structs are the next reclamation targets, in that order.
the remaining string leak wasn't bytes: it was per-character temps.
s[i] on a string mints a fresh one-character string, and every loop
like while input[i] != ":" leaked one per comparison. those chars now
classify as owned — a bind takes the count, a comparison releases its
operands in the same block — and empty collection literals in return
position pick up their declared type, so containers like stringbuffer's
parts list own their elements properly.
a url/path parsing loop (200k iterations over std.net.url and std.os.path helpers):
| before | after | |
|---|---|---|
| peak rss | 439 mb | 40 mb |
| runtime | 1076ms | 1411ms |
eleven times less memory, at ~30% time cost in rc traffic on char-heavy paths — the tradeoff favors long-running processes. std_pipeline's peak drops 1.45 gb to 0.88 gb.
a later pass (2026-07-21) removed that tradeoff for the common case.
s[i] == "/" and ord(s[i]) in a scan now read a raw byte instead of
minting a char at all — so there is no allocation to reclaim and no rc
traffic to pay — once two long-dead emitter optimizations were fixed to
actually fire (a single-character literal's stored value carries its
quotes, and a call argument is wrapped in an arg node; both checks were
looking straight past that). the comparison-heavy url and path scanners
were rewritten onto it, which is what took std_pipeline's transform from
347ms to 218ms above.
the last string-leak class: owned temps in argument position.
parts.push(s.substring(start, i)) transferred the substring into the
container (which retains), but the temp's own creation count was never
released — the cstring counters showed container pushes and free-time
cascades perfectly balanced at 1.2m each, with exactly the creation
counts leaking. owned string arguments now release right after the
call they feed: callees borrow their params, and storing callees
(containers, struct fields, channels) add their own count.
with this, the url/path churn loop runs at 2.6 mb constant with alloc == free exactly — zero string leaks. std_pipeline's peak drops to 0.66 gb (from 1.65 gb pre-arc). the accumulated rc traffic now costs std_pipeline ~15% (961→1102ms); eliding provably-redundant retain/release pairs is the next perf item, ahead of bytes reclamation.
cranelift itself was generating unoptimized code until july 2026
(opt_level defaulted to "none"). turning it to "speed" bought only 2-3%
on these benchmarks, which confirms the hot path is the runtime above, not
the generated code.
| change | std_pipeline total | catalog total (200k) |
|---|---|---|
| baseline (july 2026) | 1273ms | 102ms |
| opt_level=speed | 1236ms | 100ms |
| drop per-access handle lock | 888ms | 93ms |
| single-allocation string derives | no change | no change |
| string arc + o(1) cstring length | 804ms | 99ms |
| collection arc | 904ms* | 103ms |
| string temp reclaim | 961ms | 104ms |
| argument temp reclaim | 1102ms | 104ms |
| inline collection elements |
*collection arc costs std_pipeline ~10% in rc traffic; the churn table above is what it buys. | arc object-list rework | | |
target: std_pipeline within ~1.5x of go (about 460ms). compile time is
tracked too: pith build bench/std_pipeline.pith went 4.24s to 4.34s with
opt_level=speed.
bench/closure_error put a number on the one workload the collection
benchmarks never reach: closures were ~135x slower than go (407ms vs
3ms for 200k iterations building and dropping ~400k closures). the
error phase in the same benchmark is fine (2.5x go), so this section
is about closures. stage A has since landed and cut the closure phase
to 45ms (9x); the write-up below is kept as the record of what changed
and what is left.
root cause, confirmed by reading the runtime. a list validates a
handle with list_magic_ok — read a magic tag from the object's own
header, no lock (collections/list.rs). a closure validates with
handle_registry::is_valid, which takes a global Mutex<HashSet>
(runtime_core.rs:354,361). and it takes that lock on every closure
operation: new registers, retain/release and every get_fn /
get_env / set_env check validity, release at zero unregisters.
the "drop per-access handle lock" row in the sprint table above did
this for collections and bought 888ms on std_pipeline. closures were
never converted. so the fix is not new design — it is finishing a
migration that already happened for every other heap type.
PithClosure got a magic tag at offset 0 (CLOSURE_MAGIC), the same
shape structs use (STRUCT_MAGIC / struct_base,
runtime_core.rs:1137,1146):
pith_closure_newwrites the tag instead of callingregister- a
closure_base(handle)helper null-checks, alignment-checks, and reads the tag; everyis_valid(.., Closure)call site uses it pith_closure_releaseat its last count scrubs the tag (writes 0) beforedealloc, instead of callingunregister— a use-after-free then reads a dead tag and returns the safe default, exactly as a freed struct doesHandleKind::Closureand its registry calls come out
nothing about the closure lifecycle changes — the ref count, the captured-slot release from #303, and the indirect-call abi all stay. only the validity mechanism moves off the lock.
risk. the magic read dereferences the handle, where the registry check did not. closures only ever reach these functions from the checker's closure-typed path (a real box) or as 0 (null-guarded), the same risk profile lists and structs already accept. the scrub-on-free turns a stale handle into a safe miss rather than a wild call.
verified. bench/closure_error closure_ms fell from 407 to 45
(median of 5), checksum unchanged. valgrind stayed clean on the churn
(0 errors, 0 definite leaks — the scrub is what makes a double-free or
use-after-free a safe miss, not just unlikely). full suite, fixed
point, and seed all passed, since the runtime .a relinks into every
program.
the residual, as predicted. the lock was the dominant cost, and removing it closed most of the gap. what stays is the 45ms vs go's 3ms: pith heap-allocates a box per closure where go and rust keep the environment on the stack or inline it. that is a separate, harder optimization (a closure arena or escape analysis) and only worth it if a real workload still shows the box allocation now that the lock is gone.
AtomicInt (added for thread-safe contexts) went onto the registry
too, mirroring Semaphore. contexts are not a hot loop today, so it is
not proven costly — but it is the identical one-line-per-callsite
conversion and worth doing in the same pass. Channel, Task,
Process, Mutex, Semaphore, WaitGroup also use the registry;
most are created rarely, but channel send/recv could be hot in
channel-heavy code and deserves a measurement before converting. the
end state is that nothing validates a live handle through a global
lock.
stage A landed first and alone — the measured win, its valgrind +
suite pass the gate for touching the others. that took the benchmark
total from 543ms to 178ms. stage B stays open and stays conditional:
convert a primitive only where a benchmark shows the lock (a channel
microbench for Channel, the context path for AtomicInt), not for
symmetry. the remaining gap to go's ~57ms total is now the error phase
and the per-closure box, not the lock.
./target/release/pith build bench/std_pipeline.pith
for i in 1 2 3 4 5; do ./bench/std_pipeline 50000; done # take medians
./target/release/pith build bench/catalog_workload.pith
for i in 1 2 3 4 5; do ./bench/catalog_workload 200000; done
./target/release/pith build bench/closure_error.pith # closures + error paths
for i in 1 2 3 4 5; do ./bench/closure_error 200000; done
./target/release/pith build bench/green_fanout.pith # per-task memory under fan-out
./bench/green_fanout 200000 # green (the linux default): flat rss
PITH_GREEN=0 ./bench/green_fanout 200000 # os threads: watch rss grow
./bench/green_fanout 500000 # peak rss should barely move
bench/chan_fanout_bench.sh 1000000 9 # channels, four languages, checksum-checked
go and rust counterparts build per bench/README.md; closure_error
builds with go build -o bench/closure_error_go bench/closure_error.go
and rustc -O bench/closure_error.rs -o bench/closure_error_rust.
chan_fanout_bench.sh builds its own four (pith, go, rustc, zig) and
refuses to print a table unless every run agrees on the checksum; it
measures pith twice, once per backend.