From c79d472ada297f665f592bd789a862289310afea Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Mon, 27 Jul 2026 04:43:39 +0000 Subject: [PATCH 1/4] make the green backend the default on linux green is faster on every shape this repo measures and the last blocker (dns holding a worker) landed in #598, so a spawned task now runs as a coroutine on the worker pool unless you say otherwise. the default stays os threads everywhere else. the reactor is epoll and eventfd, so macos and the bsds compile netpoll_fallback, which has no reactor at all: a green task waiting on a socket there would hold its worker for the whole wait. green is still reachable with PITH_GREEN=1 on those platforms, just not the default. PITH_GREEN now reads in both directions. 1/on/true force green, 0/off/false force os threads, and anything else takes the platform default. the off side is new and is the escape hatch for anyone the flip hurts, so it is trimmed and matched case-insensitively. an unrecognized value falls to the platform default rather than to os threads, so the only values that move the backend are the ones spelled out in the match and a typo behaves exactly like an unset variable. parsing moved into a pure backend_from_env so it can be tested without fighting the OnceLock that backend() caches into. --- cranelift/codegen/src/ir_consumer.rs | 18 +-- cranelift/runtime/Cargo.toml | 5 +- cranelift/runtime/src/concurrency/green.rs | 2 +- .../runtime/src/concurrency/scheduler.rs | 115 ++++++++++++++---- cranelift/runtime/src/netpoll_fallback.rs | 6 +- 5 files changed, 109 insertions(+), 37 deletions(-) diff --git a/cranelift/codegen/src/ir_consumer.rs b/cranelift/codegen/src/ir_consumer.rs index 913cfbfa..3ac8088c 100644 --- a/cranelift/codegen/src/ir_consumer.rs +++ b/cranelift/codegen/src/ir_consumer.rs @@ -314,11 +314,11 @@ fn runtime_func_ref( .or_insert_with(|| codegen.module.declare_func_in_func(fid, builder.func))) } -/// Whether this build should emit green-preemption safe-points. Off by default — -/// the default binary is run os-thread and a flat loop pays a large relative cost -/// for the per-iteration check — so it is opt-in via `PITH_GREEN_PREEMPT`. Set it -/// at build time on the same binary you intend to run under `PITH_GREEN=1` to make -/// compute-bound green tasks preemptible. +/// Whether this build should emit green-preemption safe-points. Off by default, +/// because a flat loop pays a large relative cost for the per-iteration check and +/// most programs never have a compute-only task that needs descheduling. Set +/// `PITH_GREEN_PREEMPT` at build time to make compute-bound green tasks +/// preemptible. fn green_preempt_enabled() -> bool { matches!( std::env::var("PITH_GREEN_PREEMPT").as_deref(), @@ -468,10 +468,10 @@ pub fn compile_from_ir( // green preemption safe-points are opt-in at compile time. a flat arithmetic // loop pays a large relative cost for the inline flag check (its body is a // couple of instructions, so the extra load+test+branch per iteration is a - // big fraction), and the default binary is almost always run os-thread. so - // the default codegen emits no check at all — exactly zero cost — and only a - // build that asks for preemption (`PITH_GREEN_PREEMPT=1`, the same build you - // then run under `PITH_GREEN=1`) inserts the safe-points. `None` here means + // big fraction), and most programs have no compute-only task that would ever + // need descheduling. so the default codegen emits no check at all — exactly + // zero cost — and only a build that asks for preemption + // (`PITH_GREEN_PREEMPT=1`) inserts the safe-points. `None` here means // "emit nothing"; `Some(flag)` carries the imported flag symbol. let preempt_flag = if green_preempt_enabled() { Some( diff --git a/cranelift/runtime/Cargo.toml b/cranelift/runtime/Cargo.toml index 44c6b8e4..91ed6608 100644 --- a/cranelift/runtime/Cargo.toml +++ b/cranelift/runtime/Cargo.toml @@ -11,8 +11,9 @@ parking_lot = { workspace = true } threadpool = { workspace = true } libc = { workspace = true } ring = "0.17.14" -# stackful coroutines for the experimental green-thread backend (PITH_GREEN). -# phase 0 validated 0.2 handles cranelift-generated code on an alternate stack. +# stackful coroutines for the green-thread backend (the default on linux; see +# PITH_GREEN). 0.2 is validated against cranelift-generated code on an alternate +# stack. corosensei = "0.2" [lib] diff --git a/cranelift/runtime/src/concurrency/green.rs b/cranelift/runtime/src/concurrency/green.rs index 6942f66b..2a4a7a9e 100644 --- a/cranelift/runtime/src/concurrency/green.rs +++ b/cranelift/runtime/src/concurrency/green.rs @@ -1,4 +1,4 @@ -//! green-thread backend (experimental, behind `PITH_GREEN`) +//! green-thread backend (the default on linux; `PITH_GREEN` overrides) //! //! an M:N scheduler: many pith tasks run as stackful coroutines on a small //! fixed pool of worker OS threads. spawning a task is a userspace enqueue, not diff --git a/cranelift/runtime/src/concurrency/scheduler.rs b/cranelift/runtime/src/concurrency/scheduler.rs index f888e124..d2f4b508 100644 --- a/cranelift/runtime/src/concurrency/scheduler.rs +++ b/cranelift/runtime/src/concurrency/scheduler.rs @@ -1,15 +1,15 @@ //! task backend selection (the green-thread seam) //! -//! spawn and await funnel through here so a future M:N green-thread backend -//! can slot in behind a flag without touching the codegen-facing FFI. today -//! there is exactly one backend — os threads, unchanged — and this module is -//! inert: every path lands in the same `os_thread_*` code that has always run. +//! spawn and await funnel through here so the backend choice is made in exactly +//! one place and the codegen-facing FFI never has to know which one it got. +//! there are two: os threads, one kernel thread per task, and green, M:N +//! coroutines on a worker pool. //! -//! the point of landing the seam first, empty, is that turning `PITH_GREEN` -//! on right now must be a no-op. that gives the real scheduler a safe place to -//! grow into: when run queues + stackful coroutines arrive, only the `Green` -//! arms below change, and the default (flag off) path stays byte-for-byte the -//! os-thread behavior. +//! green is the default on linux, because that is where the epoll reactor lives +//! and where it is faster on every shape this repo measures. everywhere else the +//! default is os threads: `netpoll_fallback` has no reactor, so a green task +//! waiting on a socket would hold its worker for the whole wait. `PITH_GREEN` +//! overrides the choice in either direction on any platform. use super::{green, task}; use std::sync::OnceLock; @@ -19,26 +19,55 @@ use std::sync::OnceLock; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Backend { /// One OS thread per task (`std::thread::spawn`), joined on await. The - /// default and, in phase 1, the only backend with real behavior. + /// default on every platform but linux, and the opt-out on linux via + /// `PITH_GREEN=0`. OsThread, /// M:N green threads on a worker pool: tasks run as stackful coroutines on - /// a fixed set of workers. Selected by `PITH_GREEN`. Experimental — see - /// `green` for the P1a scope and the independent-tasks-only constraint. + /// a fixed set of workers. The default on linux; available elsewhere with + /// `PITH_GREEN=1`, but without a reactor to yield socket waits to. Green, } +/// The backend used when `PITH_GREEN` says nothing. +/// +/// Linux gets green: the epoll reactor in `netpoll` yields socket waits, and +/// dns resolution runs on a blocking pool, so the two things that used to hold a +/// worker for an unbounded time no longer do. Other platforms compile +/// `netpoll_fallback`, which has no reactor at all, so green there would turn +/// every socket wait into a stalled worker. +const fn platform_default() -> Backend { + if cfg!(target_os = "linux") { + Backend::Green + } else { + Backend::OsThread + } +} + +/// Map a `PITH_GREEN` value to a backend. `None` is the variable being unset (or +/// unreadable), which means the caller expressed no preference. +/// +/// Unrecognized values fall to the platform default rather than to os threads, +/// so the only values that move the backend are the ones spelled out here. A +/// typo like `PITH_GREEN=yes` then behaves exactly like not setting the variable, +/// which is easier to reason about than a rule where some unknown values mean +/// "off" and the absent one means "default". Spellings are matched +/// case-insensitively and after trimming, because the off switch is the escape +/// hatch for anyone the linux default hurts and it should not be possible to miss +/// it by a capital letter. +fn backend_from_env(value: Option<&str>) -> Backend { + match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("1") | Some("on") | Some("true") => Backend::Green, + Some("0") | Some("off") | Some("false") => Backend::OsThread, + _ => platform_default(), + } +} + /// Read `PITH_GREEN` once and cache the choice. Follows the same env-flag /// pattern as the struct freelist toggle in `runtime_core`: parse on first /// use, store in a `OnceLock`, never look again. -/// -/// Off / unset / anything unrecognized => `OsThread`, so the default path is -/// exactly today's behavior with zero overhead beyond one cached read. pub(crate) fn backend() -> Backend { static BACKEND: OnceLock = OnceLock::new(); - *BACKEND.get_or_init(|| match std::env::var("PITH_GREEN").as_deref() { - Ok("1") | Ok("on") | Ok("true") => Backend::Green, - _ => Backend::OsThread, - }) + *BACKEND.get_or_init(|| backend_from_env(std::env::var("PITH_GREEN").ok().as_deref())) } /// Spawn a task running `closure_handle`, returning its task handle. @@ -87,10 +116,50 @@ pub(crate) fn task_detach(task_handle: i64) { mod tests { use super::*; + // `backend()` caches in a `OnceLock` and reads the ambient process env, so + // it cannot be exercised more than once per test binary. The parsing lives + // in `backend_from_env`, which is pure, and that is what these cover. + + #[test] + fn unset_falls_to_the_platform_default() { + assert_eq!(backend_from_env(None), platform_default()); + } + + #[test] + fn platform_default_is_green_only_on_linux() { + if cfg!(target_os = "linux") { + assert_eq!(platform_default(), Backend::Green); + } else { + assert_eq!(platform_default(), Backend::OsThread); + } + } + + #[test] + fn on_spellings_force_green() { + for value in ["1", "on", "true", "ON", "True", " on "] { + assert_eq!(backend_from_env(Some(value)), Backend::Green, "{value:?}"); + } + } + + #[test] + fn off_spellings_force_os_threads() { + for value in ["0", "off", "false", "OFF", "False", " off "] { + assert_eq!(backend_from_env(Some(value)), Backend::OsThread, "{value:?}"); + } + } + + #[test] + fn unrecognized_values_behave_like_unset() { + for value in ["", "yes", "no", "2", "green", "osthread"] { + assert_eq!(backend_from_env(Some(value)), platform_default(), "{value:?}"); + } + } + #[test] - fn backend_defaults_to_os_threads_when_flag_absent() { - // The cached backend reflects the ambient env at first read. In the - // test harness `PITH_GREEN` is unset, so the default must hold. - assert_eq!(backend(), Backend::OsThread); + fn the_cached_backend_agrees_with_the_ambient_env() { + // Whatever the harness was launched with, the cached choice has to be + // the one the parser would make for it. + let expected = backend_from_env(std::env::var("PITH_GREEN").ok().as_deref()); + assert_eq!(backend(), expected); } } diff --git a/cranelift/runtime/src/netpoll_fallback.rs b/cranelift/runtime/src/netpoll_fallback.rs index 87e1727a..ca889394 100644 --- a/cranelift/runtime/src/netpoll_fallback.rs +++ b/cranelift/runtime/src/netpoll_fallback.rs @@ -12,8 +12,10 @@ //! stall the rest of them while one waits on i/o. //! //! that makes this good enough for local development on a mac and wrong for -//! serving load. the fix is a kqueue reactor behind these same two functions; -//! until then, ship green-thread workloads on linux. +//! serving load, and it is why green is only the *default* backend on linux +//! (see `concurrency::scheduler`). `PITH_GREEN=1` still turns it on here for +//! anyone who wants it. the fix is a kqueue reactor behind these same two +//! functions; until then, ship green-thread workloads on linux. use std::os::unix::io::RawFd; From 26dd15f156157dff1abeba2937029da2f856d8ca Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Mon, 27 Jul 2026 04:43:52 +0000 Subject: [PATCH 2/4] pin both backends explicitly in the differential tests with green as the linux default, a comparison whose reference side just says `pith run` is green against green and passes for free. every green-* target now runs its os-thread side under PITH_GREEN=0. the same reasoning applies to coverage. `make test` on a linux box now exercises only green, so the PITH_GREEN=0 path across the corpus had no gate at all. verify-osthread-corpus mirrors verify-green-corpus for the opt-out and ci runs both. memcheck grew a small second list for the cases whose subject is the os-thread task machinery itself, so the flip does not quietly stop testing the code they were written for. --- .github/workflows/ci.yml | 6 ++ Makefile | 109 +++++++++++++++++++++++++++---------- bench/chan_fanout_bench.sh | 9 +-- tests/green/smoke.pith | 8 +-- 4 files changed, 94 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 937ed5b1..e70eda8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,12 @@ jobs: - name: regression corpus under green run: make verify-green-corpus-only + # the runner is linux, so every other step above already runs green by + # default. this is the only step that exercises the PITH_GREEN=0 opt-out + # across the corpus. + - name: regression corpus under os threads + run: make verify-osthread-corpus-only + - name: build self-hosted compiler run: make self-host diff --git a/Makefile b/Makefile index 122e1098..b016231b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build self-host self-host-ir-driver bootstrap bootstrap-verify bootstrap-ir-checks bootstrap-ir-checks-only bootstrap-ir-fixed-point bootstrap-ir-fixed-point-only bootstrap-ir-invariants bootstrap-ir-invariants-only run-examples run-examples-self run-examples-self-only run-regressions run-regressions-only run-regressions-self run-regressions-self-only run-live-websocket-tests run-live-websocket-tests-self-only db-live-tests parity-examples parity-examples-only check-parse-invalid check-parse-invalid-only check-parse-invalid-self-host check-parse-invalid-self-host-only check-invalid check-invalid-only check-invalid-self-host check-invalid-self-host-only cli-regressions cli-regressions-only cli-regressions-self cli-regressions-self-only ir-contract-regressions ir-contract-regressions-only test-std-self test-std-self-only test-self-host-only test-fast-self status-audit check-no-panics safety-check fuzz-check fuzz green-smoke green-threadlocal green-pingpong green-producer-consumer green-waitgroup green-mutex green-semaphore green-barrier green-await-fanin green-echo green-starvation green-tests docsite docsite-check memcheck test clean +.PHONY: build self-host self-host-ir-driver bootstrap bootstrap-verify bootstrap-ir-checks bootstrap-ir-checks-only bootstrap-ir-fixed-point bootstrap-ir-fixed-point-only bootstrap-ir-invariants bootstrap-ir-invariants-only run-examples run-examples-self run-examples-self-only run-regressions run-regressions-only run-regressions-self run-regressions-self-only run-live-websocket-tests run-live-websocket-tests-self-only db-live-tests parity-examples parity-examples-only check-parse-invalid check-parse-invalid-only check-parse-invalid-self-host check-parse-invalid-self-host-only check-invalid check-invalid-only check-invalid-self-host check-invalid-self-host-only cli-regressions cli-regressions-only cli-regressions-self cli-regressions-self-only ir-contract-regressions ir-contract-regressions-only test-std-self test-std-self-only test-self-host-only test-fast-self status-audit check-no-panics safety-check fuzz-check fuzz green-smoke green-threadlocal green-pingpong green-producer-consumer green-waitgroup green-mutex green-semaphore green-barrier green-await-fanin green-echo green-starvation green-pinned-fairness green-tests verify-green-corpus verify-green-corpus-only verify-osthread-corpus verify-osthread-corpus-only docsite docsite-check memcheck test clean NONDETERMINISTIC_EXAMPLES := net_basics net_echo redis_client EXPECTED_EXAMPLES := $(filter-out $(addprefix examples/expected/,$(addsuffix .txt,$(NONDETERMINISTIC_EXAMPLES))),$(wildcard examples/expected/*.txt)) @@ -752,15 +752,17 @@ fuzz: build @./tools/fuzz/fuzz --count 300 --build-every 5 # --- green-thread smoke test --- -# the experimental green backend (PITH_GREEN=1) must produce byte-identical -# output to the default os-thread backend on independent, non-coordinating -# tasks. this builds the fan-out/join smoke program once and compares the two -# runs. it does NOT run the wider suite green: many programs coordinate via -# channels and would deadlock under the P1a "block the worker" await — that is -# expected and is what a later phase fixes. +# the green backend (PITH_GREEN=1) must produce byte-identical output to the +# os-thread backend (PITH_GREEN=0) on independent, non-coordinating tasks. this +# builds the fan-out/join smoke program once and compares the two runs. +# +# every green-* target below pins BOTH sides of the comparison explicitly. green +# is the default on linux, so a run that just says `pith run` is now the green +# side, and a differential that relied on the default for its reference would be +# comparing green against green and passing for free. green-smoke: build @echo "--- green-thread smoke (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/smoke.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/smoke.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 ./target/release/pith run tests/green/smoke.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -779,7 +781,7 @@ green-smoke: build # before P1b this failed green (tasks saw a sibling's leftover value). green-threadlocal: build @echo "--- green-thread threadlocal isolation (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/threadlocal.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/threadlocal.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 ./target/release/pith run tests/green/threadlocal.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -799,7 +801,7 @@ green-threadlocal: build # tasks to completion. output must stay byte-identical to the os-thread backend. green-pingpong: build @echo "--- green-thread channel ping-pong (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/pingpong.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/pingpong.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/pingpong.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -812,7 +814,7 @@ green-pingpong: build green-producer-consumer: build @echo "--- green-thread producer/consumer (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/producer_consumer.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/producer_consumer.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/producer_consumer.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -835,7 +837,7 @@ green-producer-consumer: build # program completes with output byte-identical to the os-thread backend. green-waitgroup: build @echo "--- green-thread waitgroup fan-out (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/waitgroup.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/waitgroup.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/waitgroup.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -848,7 +850,7 @@ green-waitgroup: build green-mutex: build @echo "--- green-thread mutex shared-counter (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/mutex.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/mutex.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/mutex.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -866,7 +868,7 @@ green-mutex: build # yield-and-wake path; the completion total must match the os-thread backend. green-semaphore: build @echo "--- green-thread semaphore contention (byte-identical off vs on) ---" - @off=$$(./target/release/pith run tests/green/semaphore.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/semaphore.pith 2>/dev/null); \ on=$$(PITH_GREEN=1 ./target/release/pith run tests/green/semaphore.pith 2>/dev/null); \ if [ "$$off" = "$$on" ]; then \ echo "ok identical output: $$on"; \ @@ -887,7 +889,7 @@ green-semaphore: build # only be caught at one worker. green-barrier: build @echo "--- green-thread barrier drain/release (byte-identical off vs on, 1 and default workers) ---" - @off=$$(./target/release/pith run tests/green/barrier.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/barrier.pith 2>/dev/null); \ on1=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/barrier.pith 2>/dev/null); \ onN=$$(PITH_GREEN=1 ./target/release/pith run tests/green/barrier.pith 2>/dev/null); \ if [ "$$off" = "$$on1" ] && [ "$$off" = "$$onN" ]; then \ @@ -910,7 +912,7 @@ green-barrier: build # the default count — the one-worker hang can only be caught at one worker. green-await-fanin: build @echo "--- green-thread await fan-in (byte-identical off vs on, 1 and default workers) ---" - @off=$$(./target/release/pith run tests/green/await_fanin.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/await_fanin.pith 2>/dev/null); \ on1=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/await_fanin.pith 2>/dev/null); \ onN=$$(PITH_GREEN=1 ./target/release/pith run tests/green/await_fanin.pith 2>/dev/null); \ if [ "$$off" = "$$on1" ] && [ "$$off" = "$$onN" ]; then \ @@ -935,7 +937,7 @@ green-await-fanin: build # os-thread backend. green-echo: build @echo "--- green-thread tcp echo (byte-identical off vs on, 1 and default workers) ---" - @off=$$(./target/release/pith run tests/green/echo.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/echo.pith 2>/dev/null); \ on1=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/echo.pith 2>/dev/null); \ onN=$$(PITH_GREEN=1 ./target/release/pith run tests/green/echo.pith 2>/dev/null); \ if [ "$$off" = "$$on1" ] && [ "$$off" = "$$onN" ]; then \ @@ -962,7 +964,7 @@ green-echo: build # caught at one worker. green-starvation: build @echo "--- green-thread cooperative preemption (byte-identical off vs on, 1 and default workers) ---" - @off=$$(./target/release/pith run tests/green/starvation.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/starvation.pith 2>/dev/null); \ on1=$$(PITH_GREEN_PREEMPT=1 PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/starvation.pith 2>/dev/null); \ onN=$$(PITH_GREEN_PREEMPT=1 PITH_GREEN=1 ./target/release/pith run tests/green/starvation.pith 2>/dev/null); \ if [ "$$off" = "$$on1" ] && [ "$$off" = "$$onN" ]; then \ @@ -984,7 +986,7 @@ green-starvation: build # each half of the pair re-parks long before it overruns its quantum. green-pinned-fairness: build @echo "--- green-thread wake affinity fairness (byte-identical off vs on, 1 and default workers) ---" - @off=$$(./target/release/pith run tests/green/pinned_fairness.pith 2>/dev/null); \ + @off=$$(PITH_GREEN=0 ./target/release/pith run tests/green/pinned_fairness.pith 2>/dev/null); \ on1=$$(PITH_GREEN=1 PITH_GREEN_WORKERS=1 ./target/release/pith run tests/green/pinned_fairness.pith 2>/dev/null); \ onN=$$(PITH_GREEN=1 ./target/release/pith run tests/green/pinned_fairness.pith 2>/dev/null); \ if [ "$$off" = "$$on1" ] && [ "$$off" = "$$onN" ]; then \ @@ -999,22 +1001,29 @@ green-pinned-fairness: build # --- green-thread test suite --- # the deterministic, bounded green-thread tests, gathered so ci can run them as -# one step. each compares the default os-thread backend against PITH_GREEN=1 and -# must match byte-for-byte. green-echo is intentionally left out: it binds a +# one step. each compares PITH_GREEN=0 against PITH_GREEN=1 and must match +# byte-for-byte. green-echo is intentionally left out: it binds a # fixed loopback port and races a sleep to let the server start, which is too # flaky for a shared runner. green-tests: green-smoke green-threadlocal green-pingpong green-producer-consumer green-waitgroup green-mutex green-semaphore green-barrier green-await-fanin green-starvation green-pinned-fairness @echo "all green-thread tests passed" -# --- full corpus under the green backend --- -# the dedicated green-tests above prove the coordination primitives; this proves -# the whole deterministic regression corpus produces the same output under the -# green backend as under os threads. it is the safety net for making green the -# default: every case with an expected file is run under PITH_GREEN=1 at the -# default worker count and again pinned to one worker, and compared to the same -# expected output the os-thread run is held to. a green run whose output drifts -# from its expected file is a green correctness bug, not a timing artifact, -# since the expected file is the fixed os-thread answer. +# --- full corpus under each backend --- +# the dedicated green-tests above prove the coordination primitives; these two +# targets prove the whole deterministic regression corpus produces the same +# output on either backend. the expected files are the fixed os-thread answers, +# so a run whose output drifts from its expected file is a correctness bug in +# that backend, not a timing artifact. +# +# both targets name their backend explicitly rather than leaning on the default. +# green is the default on linux and `make test` therefore already covers it at +# the default worker count, but that is a property of the host, not of the +# target: verify-green-corpus has to hold on a mac too, and the os-thread pass +# is the only coverage the PITH_GREEN=0 opt-out gets on a linux runner. +# +# green runs twice, at the default worker count and pinned to one worker, since +# a single-worker deadlock (the shape that turned up a read deadline bug) can +# only be caught at one worker. os threads have no such knob and run once. # # a case that legitimately differs under green (nondeterministic scheduling # order that its expected output happens to encode) belongs in @@ -1046,6 +1055,27 @@ verify-green-corpus-only: if [ $$fail -gt 0 ]; then exit 1; fi; \ echo "green corpus matches os-thread output" +verify-osthread-corpus: build verify-osthread-corpus-only + +verify-osthread-corpus-only: + @echo "--- regression corpus under the os-thread backend (PITH_GREEN=0) ---" + @pass=0; fail=0; skip=0; \ + for f in $(GREEN_CORPUS_EXPECTED); do \ + name=$$(basename "$$f" .txt); \ + src="tests/cases/$$name.pith"; \ + [ -f "$$src" ] || { skip=$$((skip+1)); continue; }; \ + expected=$$(cat "$$f"); \ + actual=$$(timeout 60 env PITH_GREEN=0 ./target/release/pith run "$$src" 2>/dev/null); \ + if [ "$$actual" = "$$expected" ]; then \ + pass=$$((pass+1)); \ + else \ + echo "FAIL $$name (os threads)"; fail=$$((fail+1)); \ + fi; \ + done; \ + echo "$$pass passed, $$fail failed, $$skip without a source"; \ + if [ $$fail -gt 0 ]; then exit 1; fi; \ + echo "os-thread corpus matches its expected output" + # --- memcheck --- # run a curated set of memory-management-heavy programs under valgrind # with an error exit code, so a use-after-free or an out-of-bounds read @@ -1053,6 +1083,17 @@ verify-green-corpus-only: # enum-payload overread did exactly that until it was caught here). the # full example + regression corpus is valgrind-clean; this subset keeps # the gate fast. +# +# MEMCHECK_CASES runs on whatever backend is the default here, which on linux is +# green. MEMCHECK_OSTHREAD_CASES is the handful whose whole subject is the +# os-thread task machinery (its slab reclaim, its join state, the ownership of +# values handed across a real thread), so those run again with PITH_GREEN=0 — +# otherwise the flip would quietly stop testing the code they were written for. +MEMCHECK_OSTHREAD_CASES := \ + tests/cases/test_os_thread_spawn_reclaim tests/cases/test_await_ownership \ + tests/cases/test_channel_fanout_ownership \ + tests/cases/test_channel_try_send_ownership + MEMCHECK_CASES := \ tests/cases/test_match_payload tests/cases/test_combo_enums_deep \ tests/cases/test_fn_value_positions tests/cases/test_global_fn_value \ @@ -1101,6 +1142,14 @@ memcheck: build echo "FAIL $$base (valgrind)"; head -6 /tmp/pith-memcheck.txt; fail=1; \ fi; \ done; \ + for base in $(MEMCHECK_OSTHREAD_CASES); do \ + ./target/release/pith build "$$base.pith" > /dev/null 2>&1 || { echo "FAIL build $$base"; fail=1; continue; }; \ + if PITH_GREEN=0 PITH_STRUCT_FREELIST=0 valgrind --error-exitcode=99 --leak-check=no --errors-for-leak-kinds=none -q "$$base" > /dev/null 2>/tmp/pith-memcheck.txt; then \ + echo "ok $$base (os threads)"; \ + else \ + echo "FAIL $$base (valgrind, os threads)"; head -6 /tmp/pith-memcheck.txt; fail=1; \ + fi; \ + done; \ if [ $$fail -ne 0 ]; then exit 1; fi; \ echo "all memcheck cases clean" diff --git a/bench/chan_fanout_bench.sh b/bench/chan_fanout_bench.sh index ba5bcab1..67e576b5 100755 --- a/bench/chan_fanout_bench.sh +++ b/bench/chan_fanout_bench.sh @@ -5,9 +5,10 @@ # # bench/chan_fanout_bench.sh [messages] [trials] # -# defaults: 1000000 messages, 5 trials. pith is measured twice, once on -# the default os-thread backend and once with PITH_GREEN=1. skips any -# language whose toolchain is not installed. +# defaults: 1000000 messages, 5 trials. pith is measured twice, once with +# PITH_GREEN=0 (os threads) and once with PITH_GREEN=1 (green). both are set +# explicitly, so the table means the same thing whichever backend the host +# defaults to. skips any language whose toolchain is not installed. set -euo pipefail cd "$(dirname "$0")/.." @@ -47,7 +48,7 @@ printf '\nchan fanout — %s messages, median of %s trials\n\n' "$MESSAGES" "$TR printf '%-12s %8s %14s %12s\n' lang ms msgs_per_sec peak_rss_kb ref_checksum="" -for entry in "pith:$PITH:0" "pith-green:$PITH:1" "go:$GO:0" "rust:$RUST:0" "zig:$ZIG:0"; do +for entry in "pith-threads:$PITH:0" "pith-green:$PITH:1" "go:$GO:0" "rust:$RUST:0" "zig:$ZIG:0"; do name=${entry%%:*} rest=${entry#*:} bin=${rest%%:*} diff --git a/tests/green/smoke.pith b/tests/green/smoke.pith index 51364b78..21391d3f 100644 --- a/tests/green/smoke.pith +++ b/tests/green/smoke.pith @@ -2,10 +2,10 @@ # # spawns many tasks that each do a chunk of pure arithmetic and return a value, # with NO coordination between them (no channels, no shared mutex). that keeps -# it valid under both backends: the os-thread backend and the experimental -# green backend (PITH_GREEN=1). in green mode a task that blocks parks its -# worker, so only independent tasks like these are safe today — see the green -# scheduler docs. the output must be byte-identical with the flag on and off. +# it valid under both backends, and it is the oldest of the green tests: it +# predates the yield-on-block work, so it holds even under a backend where a +# blocked task parks its worker. the output must be byte-identical under +# PITH_GREEN=0 and PITH_GREEN=1. # a deliberately loopy pure computation so the task actually spends CPU: this is # what makes the context-switch difference between backends observable, and it From 67f7a8d6777e7e7e05576c2aa023bf9290a02e56 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Mon, 27 Jul 2026 04:44:06 +0000 Subject: [PATCH 3/4] describe green as the default rather than the experiment docs/concurrency.md drops the "off by default and still experimental" framing. the backend-choice section now says green on linux, os threads elsewhere, and PITH_GREEN=0 as the opt-out, and it keeps the caveats that came with the flip rather than burying them: file i/o has no yield point so a file read holds its worker and everything pinned to it; preemption is a build-time opt-in where the kernel gave os threads preemption for free; and task placement is first-resume luck. docs/limitations.md had a list of what it would take to make green the default. most of that is history now, so it reads as the caveats of the current default instead. the performance scoreboard labelled its concurrency rows "(green)"; the os-thread rows are the ones that need a label now, so they carry PITH_GREEN=0. the same for the bench README table and the examples and module headers that showed PITH_GREEN=1 as the way to opt in. --- bench/README.md | 6 +- bench/green_fanout.pith | 4 +- docs/concurrency.md | 148 ++++++++++++++++++++++---------------- docs/limitations.md | 90 ++++++++++++----------- docs/performance.md | 38 +++++----- docs/web.md | 3 +- examples/worker_pool.pith | 4 +- std/web.pith | 4 +- 8 files changed, 164 insertions(+), 133 deletions(-) diff --git a/bench/README.md b/bench/README.md index bffc4eb6..50f8f583 100644 --- a/bench/README.md +++ b/bench/README.md @@ -299,7 +299,7 @@ directly: ``` pith build bench/chan_fanout.pith && ./bench/chan_fanout 1000000 -PITH_GREEN=1 ./bench/chan_fanout 1000000 +PITH_GREEN=0 ./bench/chan_fanout 1000000 go build -o bench/chan_fanout_go bench/chan_fanout.go rustc -O -o bench/chan_fanout_rust bench/chan_fanout.rs zig build-exe -O ReleaseFast -femit-bin=bench/chan_fanout_zig bench/chan_fanout.zig @@ -312,8 +312,8 @@ green wake-path work described below): | lang | ms | messages/sec | peak rss | |---|---:|---:|---:| -| pith (os threads) | 438 | 2.3 m | 3.0 mb | -| pith (`PITH_GREEN=1`) | 171 | 5.8 m | 3.0 mb | +| pith (`PITH_GREEN=0`, os threads) | 438 | 2.3 m | 3.0 mb | +| pith (green, the linux default) | 171 | 5.8 m | 3.0 mb | | go | 75 | 13.3 m | 2.0 mb | | rust | 135 | 7.4 m | 2.4 mb | | zig | 135 | 7.4 m | 2.7 mb | diff --git a/bench/green_fanout.pith b/bench/green_fanout.pith index fa914ff4..e9cd842a 100644 --- a/bench/green_fanout.pith +++ b/bench/green_fanout.pith @@ -8,8 +8,8 @@ # counts and the peak rss should barely move — that flat line is the point. # # pith build bench/green_fanout.pith -# ./bench/green_fanout 1000000 # tasks (default 500000) -# PITH_GREEN=1 ./bench/green_fanout 1000000 +# ./bench/green_fanout 1000000 # tasks (default 500000), green on linux +# PITH_GREEN=0 ./bench/green_fanout 1000000 # the os-thread backend, for contrast # # batch size is fixed small so the working set is a handful of tasks; the # leak, if it were still there, would show as rss climbing with the total. diff --git a/docs/concurrency.md b/docs/concurrency.md index 042a5ee0..4e432cfa 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -131,11 +131,12 @@ the current concurrency story is strong enough for: ## sharing between tasks -`spawn` runs on a real os thread. reference counts are atomic, so -handing a value to another task and letting both hold a count is safe. -what is *not* safe is two tasks mutating the same collection at once — -a list or map is a plain buffer behind a handle, and concurrent -mutation races on that buffer. +a spawned task runs for real alongside its parent, whether the backend +gives it an os thread of its own or a slice of a green worker. reference +counts are atomic, so handing a value to another task and letting both +hold a count is safe. what is *not* safe is two tasks mutating the same +collection at once — a list or map is a plain buffer behind a handle, and +concurrent mutation races on that buffer. pass data between tasks through a channel rather than sharing a mutable collection. a channel hands the value over instead of aliasing @@ -146,8 +147,9 @@ checker. ## per-thread globals -a module global marked `threadlocal` gets a separate copy per os thread, -created lazily the first time a thread reads it. each task mutates its +a module global marked `threadlocal` gets a separate copy per task, +created lazily the first time that task reads it — one per os thread on the +os-thread backend, one per green task on the green one. each task mutates its own copy with no lock and no race, which is exactly what you want for per-task scratch state — a parser's cursor, a buffer, a request-scoped counter: @@ -158,7 +160,7 @@ threadlocal mut arena: Map[Int, Node] := {} ``` it reads and writes like any other global; only the storage is -per-thread. a copy is not reclaimed when its thread exits, so keep the +per-task. what a copy holds is not released when its owner exits, so keep the set of `threadlocal` globals small and the values scratch-sized. use it for state that is genuinely per-task; a value shared *across* tasks still belongs in a struct or behind a channel. @@ -176,23 +178,25 @@ things that are still intentionally explicit or still growing: - sharing a mutable collection across tasks is a data race (above); use channels -## the green backend (experimental) - -by default each `spawn` runs on its own os thread, so every channel send, -mutex, or await that has to wait hands off through the kernel — a futex wake -and a context switch. that is fine when tasks mostly compute, but a program -built out of many small tasks that pass values to each other pays a kernel -switch on every hop. - -`PITH_GREEN=1` switches on a second backend. tasks become coroutines that run -on a small pool of worker threads (`available_parallelism()` by default, -`PITH_GREEN_WORKERS=n` to pin the count). channel, mutex, waitgroup, -semaphore, and await operations that would block yield to the scheduler -instead of parking their worker, so a handoff between two tasks on the same -worker is a userspace switch with no kernel involved. socket i/o goes through -an epoll reactor: a read or write that would block parks the task on the -reactor and frees the worker to run something else, so the whole net stack — -raw tcp, tls, http/2, grpc — yields the same way without any code of its own. +## the green backend + +on os threads each `spawn` runs on its own kernel thread, so every channel +send, mutex, or await that has to wait hands off through the kernel — a futex +wake and a context switch. that is fine when tasks mostly compute, but a +program built out of many small tasks that pass values to each other pays a +kernel switch on every hop. + +the green backend is the answer to that, and on linux it is what you get +unless you ask otherwise. tasks become coroutines that run on a small pool of +worker threads (`available_parallelism()` by default, `PITH_GREEN_WORKERS=n` +to pin the count). channel, mutex, waitgroup, semaphore, and await operations +that would block yield to the scheduler instead of parking their worker, so a +handoff between two tasks on the same worker is a userspace switch with no +kernel involved. socket i/o goes through an epoll reactor: a read or write +that would block parks the task on the reactor and frees the worker to run +something else, so the whole net stack — raw tcp, tls, http/2, grpc — yields +the same way without any code of its own. that reactor is also why the default +is linux-only; see "which backend to use". name resolution gets there by a different route. `getaddrinfo` is synchronous and there is nothing to poll, so instead of yielding to the reactor the lookup @@ -203,12 +207,15 @@ its non-blocking one. nothing in your program changes. the same `spawn`, `Channel`, `Mutex`, and `await` run on either backend, and a correct program prints the same thing -both ways. the two green examples in this repo run identically with the flag -on or off: +both ways. `PITH_GREEN` picks one explicitly: `1`, `on`, or `true` forces +green, `0`, `off`, or `false` forces os threads, and anything else — including +leaving it unset — takes the platform default, which is green on linux and os +threads everywhere else. the two green examples in this repo run identically +whichever way you set it: ``` pith run examples/worker_pool.pith -PITH_GREEN=1 pith run examples/worker_pool.pith +PITH_GREEN=0 pith run examples/worker_pool.pith ``` it helps most when tasks coordinate a lot and compute little — a fan-out of @@ -239,50 +246,67 @@ backend reclaims its slot and releases the closure it was spawned with, so a server that spawns one task per request holds only the tasks running at once, not one record for every request it has ever served. a fan-out of 500k short tasks that used to climb to ~226 mb now holds around 3 mb, flat no matter how -many run in total — see the green fan-out row in `docs/performance.md`. (the default +many run in total — see the green fan-out row in `docs/performance.md`. (the os-thread backend still keeps a record per task it has run; teaching it the same reclamation is the next step.) ### which backend to use -green is faster on every shape this repo measures, so the short answer is to -use it on linux for anything that coordinates a lot of tasks. the longer -answer is why the os-thread backend is still here, because it is not just -waiting to be deleted. - -a blocking call on a green worker stalls the worker, not only the task making -it. the epoll reactor covers sockets and the resolver pool covers dns, so a -client dial yields the whole way through, but nothing else does: file reads and -writes and any slow native call hold the thread they run on, and every task -pinned to that worker waits behind them. on os threads that same call costs one -task, because the task is a thread and the kernel just runs someone else. - -the reactor is also linux-only. it is epoll and eventfd; macos and the bsds -compile a stand-in with no reactor at all, so a green task waiting on a socket -there blocks its worker for as long as the wait lasts. green is still correct -on those platforms, but an i/o-heavy server on macos wants os threads. and -preemption, which the kernel gives os threads for free, is a build-time opt-in -under green (see `PITH_GREEN_PREEMPT` below). - -there is one more reason, which matters to this repo rather than to your -program: os threads are the reference green gets checked against. -`make verify-green-corpus` runs the whole regression corpus under green and -diffs it against what the os-thread backend produces, which is how a -single-worker deadline bug turned up. two implementations that have to agree -is worth keeping around past the point where one of them is faster. - -it is off by default and still experimental. the known rough edges: +on linux, the one you already have. green is faster on every shape this repo +measures, so it is the default there and you only reach for `PITH_GREEN=0` if +one of the caveats below is your workload. everywhere else the default is os +threads, and `PITH_GREEN=1` opts in. + +the reason for that split is the reactor. it is epoll and eventfd, so it is +linux-only; macos and the bsds compile a stand-in with no reactor at all, and a +green task waiting on a socket there blocks its worker for as long as the wait +lasts. green is still correct on those platforms and you can turn it on, but an +i/o-heavy server on macos wants os threads until there is a kqueue sibling. + +the caveats that come with the linux default all come from one fact: a green +worker runs many tasks, so anything that holds the worker holds all of them, +where an os thread would only cost the one task making the call. + +file i/o is where you are most likely to meet that. `read`, `write`, and every +other file operation goes straight to the syscall with no yield point, so a task +doing a large or slow file read holds its worker for the whole call and every +task pinned to that worker waits behind it. sockets do not have this problem, +because they go through the reactor, and neither does dns, which runs on a pool +of blocking threads while the caller parks. file i/o is the gap, and a program +that reads files from inside a task can feel it purely by moving to the green +default. `PITH_GREEN=0` is the answer if it bites you. + +preemption is the other thing the kernel used to hand you. os threads get it for +free; under green a compute-only task that never touches a channel, await, or +socket holds its worker until it finishes, unless the binary was built with +safe-points (`PITH_GREEN_PREEMPT=1`, below). code that coordinates already yields +on its own and never needs it. + +and placement is luck. a task pins to the first worker that runs it, so whether +two tasks that talk to each other end up sharing one is chance. on the channel +fan-out benchmark that is the difference between ~46ms and ~130-170ms for the +same program. `PITH_GREEN_WORKERS=1` takes the choice away and is often the +fastest setting for a single pipeline. + +there is one more reason the os-thread backend stays, which matters to this +repo rather than to your program: it is the reference green gets checked +against. `make verify-green-corpus` runs the whole regression corpus under +green and diffs it against the fixed os-thread answers, which is how a +single-worker deadline bug turned up. two implementations that have to agree is +worth keeping around past the point where one of them is faster. + +the rest of the rough edges: - a compute-bound task that loops without ever touching a channel, await, or - socket can now be preempted, but you opt in at build time. compile with + socket can be preempted, but you opt in at build time. compile with `PITH_GREEN_PREEMPT=1` and the backend puts a safe-point at every loop - back-edge; run that binary under `PITH_GREEN=1` and a monitor thread makes a + back-edge; run that binary under green and a monitor thread makes a task that has held its worker past its time slice yield, so its peers on the same worker get to run. it is opt-in because that safe-point costs a bit on - every loop iteration (nothing you would notice on real work, but real on a - tight arithmetic loop), and the default build is almost always run os-thread, - so it plants no check and pays nothing. code that uses channels and sockets - already yields on its own and never needs this. one gap in this first version: + every loop iteration — nothing measurable on real work, about 6% on a + degenerate arithmetic loop — and a build that never runs green would pay for + a check that can never fire. code that uses channels and sockets already + yields on its own and never needs this. one gap in this first version: a task sitting inside a long native runtime call (a large file read, say) is not preempted until it returns to pith code and hits the next back-edge - fewer workers means more locality: a task pins to the first worker that runs diff --git a/docs/limitations.md b/docs/limitations.md index 72803228..2060fc5d 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -108,49 +108,53 @@ correctness story: 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. -- **the reactor is linux-only** — it is built on epoll and eventfd. macos and - the bsds compile a stand-in with no reactor, where a green task waiting on a - socket blocks its worker outright. so "green by default" is a linux-server - claim, not a universal one; on other platforms the default would have to stay - os threads or the reactor would need a kqueue sibling. -- **blocking calls stall a worker, not just their task** — file i/o and native - calls have no yield point, so they hold the worker and everything pinned to - it. dns was the worst case here and is not any more: `getaddrinfo` runs on a - small pool of blocking threads while the calling task parks. on os threads a - blocking call costs only the task making it. this is the structural difference - that keeps the os-thread backend worth having regardless of the benchmark - numbers. -- **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. +## the green backend, now the default on linux + +as of 2026-07-27 the green backend is what a spawned task runs on when you +build for linux; `PITH_GREEN=0` switches back to one os thread per task, and on +macos and the bsds os threads are still the default with `PITH_GREEN=1` as the +opt-in. green wins every shape this repo measures: spawn is ~30x the os-thread +backend at a seventeenth of the memory, and the channel fan-out benchmark runs +2.6x faster than os threads and ahead of rust and zig. the whole regression +corpus, 260 cases at both worker counts, produces byte-identical output to the +fixed os-thread answers (`make verify-green-corpus`, run in ci). what follows is +what the new default still costs you, not a list of things blocking it. + +the structural cost is that a green worker runs many tasks, so a call with no +yield point holds all of them rather than only the task making it. file i/o is +where that actually bites: `host_fs` goes straight to the syscall, so a task +doing a large or slow file read or write holds its worker for the whole call and +everything pinned to that worker waits behind it. sockets go through the epoll +reactor and dns runs on a pool of blocking threads, so those two no longer stall +anyone, but file i/o has no equivalent and a program can hit this purely by +upgrading. the honest workaround today is `PITH_GREEN=0`; giving `host_fs` a +yield point is the open work. + +preemption is a build-time opt-in for the same reason it always was. safe-points +are only emitted under `PITH_GREEN_PREEMPT=1`, so a compute-only task that never +touches a channel or socket holds its worker until it finishes, where the kernel +gives os threads that for free. turning safe-points on costs ~0% on real work +(the event-ledger and std-pipeline benchmarks are within noise) and ~6% on a +degenerate 200-million-iteration arithmetic loop, so the flag is cheap, but a +build that will never run green should not pay for a check that cannot fire. + +the reactor being linux-only is why the default is linux-only. it is epoll and +eventfd; elsewhere the fallback has no reactor and a green task waiting on a +socket blocks its worker outright. green stays available on those platforms and +stays correct, but it would be a regression as a default until there is a kqueue +sibling. + +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, and the +fan-out benchmark is bimodal because of it: ~46 ms pinned to one worker with +`PITH_GREEN_WORKERS=1`, ~60 ms when the pipeline happens to share a worker +anyway, ~130-170 ms when it splits. cross-worker wakes are the whole remaining +gap to go on coordination-heavy work, and colocating communicating tasks is the +open lever. + +one caveat on the numbers themselves: every comparison in docs/performance.md +and bench/README.md was measured on the same 2-core box. "green wins everywhere" +is true there and unverified on wider hardware. 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 diff --git a/docs/performance.md b/docs/performance.md index 045190e6..d83f1c4c 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -9,25 +9,25 @@ the helpers in `bench/` before trusting them on different hardware. ## where pith stands -the short version, all on the same 2-core machine. the concurrency rows use -the green backend (`PITH_GREEN=1`) where marked, since that is where the -2026-07-26 scheduler work landed: +the short version, all on the same 2-core machine. the concurrency rows are the +green backend, which is the default on linux; the rows marked `PITH_GREEN=0` +are the os-thread opt-out, kept for contrast: | coordination | pith | go | rust | zig | |---|---:|---:|---:|---:| -| chan_fanout, 1m msgs | **171 ms** (green) | 69 ms | 94-135 ms | 135-204 ms | -| chan_fanout, pinned to 1 worker | **~46 ms** (green) | 69 ms | — | — | -| chan_fanout, os threads | ~438 ms | 69 ms | — | — | -| context switches over the run | 2.5k (green) | 258 | 4.8k | 60k | -| 20k spawn + join | **~50 ms** (green) | ~27 ms | — | — | -| 20k spawn, peak rss | 10 mb (green) | 11 mb | — | — | -| 20k spawn, os threads | ~1450 ms / 174 mb | — | — | — | +| chan_fanout, 1m msgs | **171 ms** | 69 ms | 94-135 ms | 135-204 ms | +| chan_fanout, pinned to 1 worker | **~46 ms** | 69 ms | — | — | +| chan_fanout, `PITH_GREEN=0` | ~438 ms | 69 ms | — | — | +| context switches over the run | 2.5k | 258 | 4.8k | 60k | +| 20k spawn + join | **~50 ms** | ~27 ms | — | — | +| 20k spawn, peak rss | 10 mb | 11 mb | — | — | +| 20k spawn, `PITH_GREEN=0` | ~1450 ms / 174 mb | — | — | — | | compute | pith | go | rust | zig | |---|---:|---:|---:|---:| | event_ledger, 200k events | 955 ms (1.16x go) | 820 ms | 178 ms | 199 ms | | std_pipeline, 50k records | 476 ms (1.54x go) | 310 ms | 135 ms | — | -| grpc unary echo, conc=8 | 8075 calls/s (green, 1w) | 13417 | 11012 | — | +| grpc unary echo, conc=8 | 8075 calls/s (1 worker) | 13417 | 11012 | — | on coordination the green backend beats rust and zig outright, sits ~2.3x behind go at the default worker count, and beats go pinned to one worker. on @@ -167,8 +167,8 @@ 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 experimental green backend (`PITH_GREEN=1`, see `docs/concurrency.md`) is -that same in-userspace scheduler, and this benchmark is the case it was built +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, @@ -227,8 +227,9 @@ 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. green is off by -default, so the table at the top of this section is the os-thread backend. +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 @@ -280,7 +281,7 @@ the tasks alive at once, not the total ever spawned. 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 default +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 @@ -802,8 +803,9 @@ 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 -PITH_GREEN=1 ./bench/green_fanout 200000 # green: flat rss; drop the flag to see os-thread grow -PITH_GREEN=1 ./bench/green_fanout 500000 # peak rss should barely move +./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 ``` diff --git a/docs/web.md b/docs/web.md index cedf9b0c..56543756 100644 --- a/docs/web.md +++ b/docs/web.md @@ -250,7 +250,8 @@ default `/healthz` route: no observability middleware and no `/metrics`. run on os threads or on the green runtime: ``` -PITH_GREEN=1 ./your_server +./your_server # green on linux, os threads elsewhere +PITH_GREEN=0 ./your_server # one os thread per connection, anywhere ``` on the green runtime those per-connection tasks are green threads, so a server can diff --git a/examples/worker_pool.pith b/examples/worker_pool.pith index c3e38947..8dd295ec 100644 --- a/examples/worker_pool.pith +++ b/examples/worker_pool.pith @@ -2,8 +2,8 @@ # back in over another. this is the shape the green backend is built for — many # small tasks handing values to each other — and it runs the same either way: # -# pith run examples/worker_pool.pith # one os thread per task -# PITH_GREEN=1 pith run examples/worker_pool.pith # tasks share a worker pool +# pith run examples/worker_pool.pith # tasks share a worker pool (linux default) +# PITH_GREEN=0 pith run examples/worker_pool.pith # one os thread per task # # the total is a sum, so it does not depend on which worker takes which job and # the output is identical on both backends. diff --git a/std/web.pith b/std/web.pith index d371196a..2f6067e4 100644 --- a/std/web.pith +++ b/std/web.pith @@ -2,8 +2,8 @@ # # spin up a server in about ten lines: build an App, hang some routes off it, # and call listen(). listen() owns the accept loop and runs every connection on -# its own task, so slow clients never block each other. it works the same on os -# threads and on the green runtime (PITH_GREEN=1). +# its own task, so slow clients never block each other. it works the same on the +# green runtime (the default on linux) and on os threads (PITH_GREEN=0). # # import std.web as web # import std.net.http as http From 0cc19f3fd3fb03f10f84748d459b4275fcaff551 Mon Sep 17 00:00:00 2001 From: Kacy Fortner Date: Mon, 27 Jul 2026 05:32:35 +0000 Subject: [PATCH 4/4] accept no/yes for the backend switch, and fix two stale strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three small corrections on top of the flip. `PITH_GREEN=no` fell to the platform default, which on linux is green — so someone reaching for the escape hatch and writing the most natural word for "off" got exactly what they were trying to turn off. no/n now mean os threads and yes/y mean green, which keeps the rule (only spelled-out values move the backend) while making the direction that costs something hard to miss. the green corpus target claimed it "matches os-thread output". it compares against a fixed expected file, which is the stronger check and the reason it catches a drift both backends share. now that there is a sibling target that really does run os threads, the old wording is worth correcting. and the corpus is 261 cases, not 260, since the dns case joined it. --- Makefile | 2 +- .../runtime/src/concurrency/scheduler.rs | 25 +++++++++++-------- docs/limitations.md | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index b016231b..dfed627c 100644 --- a/Makefile +++ b/Makefile @@ -1053,7 +1053,7 @@ verify-green-corpus-only: done; \ echo "$$pass passed, $$fail failed, $$skip without a source"; \ if [ $$fail -gt 0 ]; then exit 1; fi; \ - echo "green corpus matches os-thread output" + echo "green corpus matches the expected output" verify-osthread-corpus: build verify-osthread-corpus-only diff --git a/cranelift/runtime/src/concurrency/scheduler.rs b/cranelift/runtime/src/concurrency/scheduler.rs index d2f4b508..2edb781d 100644 --- a/cranelift/runtime/src/concurrency/scheduler.rs +++ b/cranelift/runtime/src/concurrency/scheduler.rs @@ -48,16 +48,19 @@ const fn platform_default() -> Backend { /// /// Unrecognized values fall to the platform default rather than to os threads, /// so the only values that move the backend are the ones spelled out here. A -/// typo like `PITH_GREEN=yes` then behaves exactly like not setting the variable, -/// which is easier to reason about than a rule where some unknown values mean -/// "off" and the absent one means "default". Spellings are matched -/// case-insensitively and after trimming, because the off switch is the escape -/// hatch for anyone the linux default hurts and it should not be possible to miss -/// it by a capital letter. +/// typo like `PITH_GREEN=maybe` then behaves exactly like not setting the +/// variable, which is easier to reason about than a rule where some unknown +/// values mean "off" and the absent one means "default". +/// +/// The off spellings are deliberately generous. `PITH_GREEN=0` is the escape +/// hatch for anyone the linux default hurts, and someone reaching for it who +/// writes `no` or `NO` must not silently get green instead — that is the one +/// misreading with a real cost. Values are trimmed and lowercased for the same +/// reason. fn backend_from_env(value: Option<&str>) -> Backend { match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() { - Some("1") | Some("on") | Some("true") => Backend::Green, - Some("0") | Some("off") | Some("false") => Backend::OsThread, + Some("1") | Some("on") | Some("true") | Some("yes") | Some("y") => Backend::Green, + Some("0") | Some("off") | Some("false") | Some("no") | Some("n") => Backend::OsThread, _ => platform_default(), } } @@ -136,21 +139,21 @@ mod tests { #[test] fn on_spellings_force_green() { - for value in ["1", "on", "true", "ON", "True", " on "] { + for value in ["1", "on", "true", "yes", "y", "ON", "True", " on "] { assert_eq!(backend_from_env(Some(value)), Backend::Green, "{value:?}"); } } #[test] fn off_spellings_force_os_threads() { - for value in ["0", "off", "false", "OFF", "False", " off "] { + for value in ["0", "off", "false", "no", "n", "OFF", "False", " off ", "NO"] { assert_eq!(backend_from_env(Some(value)), Backend::OsThread, "{value:?}"); } } #[test] fn unrecognized_values_behave_like_unset() { - for value in ["", "yes", "no", "2", "green", "osthread"] { + for value in ["", "maybe", "2", "green", "osthread"] { assert_eq!(backend_from_env(Some(value)), platform_default(), "{value:?}"); } } diff --git a/docs/limitations.md b/docs/limitations.md index 2060fc5d..666379b8 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -116,7 +116,7 @@ macos and the bsds os threads are still the default with `PITH_GREEN=1` as the opt-in. green wins every shape this repo measures: spawn is ~30x the os-thread backend at a seventeenth of the memory, and the channel fan-out benchmark runs 2.6x faster than os threads and ahead of rust and zig. the whole regression -corpus, 260 cases at both worker counts, produces byte-identical output to the +corpus, 261 cases at both worker counts, produces byte-identical output to the fixed os-thread answers (`make verify-green-corpus`, run in ci). what follows is what the new default still costs you, not a list of things blocking it.