feat(runtime): sendable actor heaps and the explicit execution context - #3
Merged
Merged
Conversation
The scheduler runs on one thread and the BEAM comparison records actor throughput and scheduler fairness as unmeasured. Reaching parallelism means changing heap ownership, which is the hardest thing in the runtime to change once more code depends on it. The spec sequences five required steps and designs the first: heap ownership moves out of thread-local storage into actors and domains, leaving the thread-local as a scheduling cursor. Foreign entry point signatures stay unchanged, which keeps this step independent of the codegen work preemption will need. It records two findings from reading the tree. Per-actor heap isolation already exists, since Store holds a map of slots each owning a Heap behind an active cursor. The obstacle to migration is therefore not the !Send marker but the shared invocation heap that actor payload heaps hold control edges into. Determinism becomes a property of the simulation driver rather than of the runtime, because a BEAM-shaped runtime cannot offer bit-exact replay of a real multi-threaded run. ThreadSanitizer enters the gate here, before step 2 makes it load-bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten TDD tasks implementing step 1 of the parallel actor work. Tasks 1 to 5 extract the thread-local store into an explicit Domain without changing ownership, so the mechanical work is reviewable on its own. Task 6 reduces the thread-local to a cursor, which is the only ownership change. Tasks 7 to 10 add the type-level guard, the cross-heap edge oracle, ThreadSanitizer and the measurements the decision record needs. Reading the tree while planning corrected the spec twice. The 257 runtime entry points reach the store through roughly twelve functions in memory.rs, so no entry point signature or call site outside memory/heaps.rs changes. And invocation-heap collection scans every actor heap in the domain, a sharper coupling than the control edges the spec identified, which constrains the scheduler design in step 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ask 2 test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ation path Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root::drop still resolves through the thread-local store, so the test retires its token explicitly rather than through Drop. Task 6 makes the cursor authoritative and closes that gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widen Heap to pub(crate) so Domain's crate-visible accessors can name it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collect_active keeps the active==0 branch that scans every actor heap in the domain for control words. That loop is why a domain must own its actor heaps rather than lend them out, which constrains the scheduler design in step 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The thread-local no longer owns heaps. It holds a pointer to the domain executing on this thread, installed by an Activation guard that restores the previous value on drop, so the invariant survives unwinding and fault paths. A domain built on one thread can now be moved to another and serve the ordinary allocation path there. Replacing RefCell with a raw pointer would have silently dropped the aliasing check RefCell enforced, turning a would-be panic into undefined behaviour, so the cursor path keeps an explicit re-entrancy guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…enarios verify_edges reports any managed edge leaving an actor payload heap for something other than invocation control storage, which is the reference step 4 has to sever. A negative test fabricates an edge the collector would never build, so the oracle is demonstrably able to fail rather than vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shipped standard library is not instrumented, so -Zsanitizer=thread alone fails the sanitizer ABI check. The command rebuilds std from source and detects the host triple, which the sanitizer requires to be explicit. Route remove_root through the same aliasing guard as with_current. A finalizer running during collection can drop a Root, which re-enters remove_root while collect_active holds the domain. RefCell made that a panic; the raw cursor would have made it undefined behaviour. The instrumented suite takes about 40 minutes and finds nothing while execution is single threaded. It joins the gate now so the harness exists before parallel schedulers make it load bearing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decision156 records the ownership change, the two findings the work produced and the measured cost. The allocation-heavy immutable model regresses 59.03 to 59.96 ms, +1.58% over 25 interleaved samples per build, while the scalar control moves +0.09%. That locates the cost in the allocation path, where the un-activated default path now takes two thread-local accesses instead of one. The regression is recorded rather than rounded off; whether step 2 recovers it by making the cursor path hot is a measurement for that step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 1 of five toward parallel actor execution, specified in
docs/superpowers/specs/2026-09-15-sendable-actor-heaps-design.mdand planned indocs/superpowers/plans/2026-09-15-sendable-actor-heaps.md.Why
The scheduler executes FIFO continuation callbacks on one thread, and the BEAM
comparison records actor throughput, scheduler fairness and fault recovery as
unmeasured. Reaching parallelism means changing heap ownership, which is the
hardest thing in the runtime to change once more code depends on it.
This step produces no parallelism. It should be judged on whether it makes step 2
possible without a second ownership rewrite.
What changed
Heap ownership moves out of
thread_local! { static STORE }into an explicitDomain. The thread-local keeps only a cursor to the domain executing on thisthread, installed by an
Activationguard that restores the previous value ondrop.
DomainisSend;RootandScopestay thread-bound.Foreign entry point signatures and the compiler's symbol contract are unchanged.
The 257
no_mangleentry points reach the store through roughly twelve functionsin
memory.rs, so nothing outsidememory/heaps.rschanged shape and no codegenwork was required.
Commits 1-5 are a mechanical extraction that changes no ownership. Commit 6 is the
only ownership change. Reviewing them in order is much easier than reviewing the
diff as a whole.
Evidence
01a55a0046de5614before andafter, with identical 48,334 callbacks, 1,226 delivered, 3,774 timeouts, 2,501
restarts and 9,977 churn actors, and zero cleanup residue. Record and replay
round-trips.
compatibility programs, 295 atomic rejections, 64+192+231 fuzz cases.
negative case fabricates an edge the collector would never build, so the oracle
is demonstrably able to fail.
2,389 s on one Apple M4.
Measured cost
The allocation-heavy immutable model (
workloads.mr model 100000) regresses froma 59.03 ms to a 59.96 ms median, +1.58%, over 25 interleaved samples per build.
The allocation-light scalar control moves +0.09%, which locates the cost in the
allocation path: the un-activated default path now takes two thread-local accesses
where it took one.
libmorrow_runtime.agrows 13,494,480 to 13,506,976 bytes.The regression is recorded rather than rounded off. Step 2 runs every actor under
an activated domain, making the cursor path hot instead of the default path;
whether that recovers the difference is a measurement for that step.
Worth reviewing closely
Busyaliasing guard. ReplacingRefCellwith a raw pointer wouldsilently drop the aliasing check it enforced.
Root::dropcan run from afinalizer during collection, re-entering the cursor while
collect_activeholds the domain. That was a panic; without the guard it becomes undefined
behaviour.
collect_activescans every actor heap in the domain when the invocationheap is active. The design document missed this. It is a collection-time
coupling, and it means a scheduler's domain must own its actor heaps rather
than lend them out, which constrains step 2.
Domain: Sendis asserted at compile time;Root/Scopestaying!Sendisnot. Rust cannot express a negative auto-trait bound without fragile tricks,
so that half rests on the
PhantomData<Rc<()>>markers.Note on the new CI job
The ThreadSanitizer job takes about 40 minutes and finds nothing while execution
is single threaded. It is added now so the harness and any suppressions exist
before parallel schedulers make it load bearing. Restricting it to pushes on
mainrather than every pull request is a reasonable call.🤖 Generated with Claude Code