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..dfed627c 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 @@ -1044,7 +1053,28 @@ 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 + +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 @@ -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/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/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/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/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..2edb781d 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,58 @@ 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=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") | Some("yes") | Some("y") => Backend::Green, + Some("0") | Some("off") | Some("false") | Some("no") | Some("n") => 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 +119,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", "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", "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 ["", "maybe", "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; 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..666379b8 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, 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. + +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 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