Low-alloc dispatch: dual behavior model, opt-in arena scheduler, allocation-free park primitive - #168
Open
thnak wants to merge 3 commits into
Open
Low-alloc dispatch: dual behavior model, opt-in arena scheduler, allocation-free park primitive#168thnak wants to merge 3 commits into
thnak wants to merge 3 commits into
Conversation
Introduce an opt-in per-activation behavior lifetime alongside the existing per-call IGrainBehavior, killing the ~792 B/call DI-scope + resolution bucket for grains that opt in. - IActivationBehavior marker: one activation-lifetime IServiceScope, one cached behavior instance in GrainActivation (constructed once via EnsureActivationBehavior, reused per call via BindActivationBehavior, disposed on deactivation). - LocalGrainCallInvoker fast-path branch selects the cached instance with no scope, no construct, no locked ResolveService. Reentrant + IActivationBehavior throws NotSupportedException. - BehaviorStateAnalyzer: suppress QRK0020/QRK0021 (mutable field / writable auto-property) for IActivationBehavior — per-activation fields are legitimate state; QRK0022 (mutable static) still fires. New QRK0023 (Warning) flags [Reentrant] + IActivationBehavior at build time. - GrainActivation.PostCoreAsync variants carry [AsyncMethodBuilder( PoolingAsyncValueTaskMethodBuilder)] so dispatch async boxes are pooled. Measured (DispatchPipelineBenchmarks): 2039 -> 1253 B/call for opted-in grains (-786 B, matches the predicted DI bucket); mean latency 14.2 -> 6.8 us. Covered by ActivationScopedBehaviorTests + 7 new BehaviorStateAnalyzerTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New IActivationScheduler built on dedicated worker threads, per-worker work-stealing deques, and a sharded injection queue — the P1 skeleton of docs/superpowers/specs/2026-07-12-next-gen-scheduler-design.md. Selected only when SiloRuntimeOptions.SchedulerKind == ArenaV2; the legacy ActivationScheduler remains the default until the arena scheduler clears the full benchmark suite. - Spill-to-ThreadPool on await: a synchronously-completing turn drains inline on the dedicated worker thread (no async frame, no hop); the instant a turn awaits, the drain remainder runs as a continuation and the worker returns to its loop — removing both blocking-drain latency and the bounded-worker reentrancy deadlock. - Per-worker in-flight backpressure (Dekker-fenced) caps suspended drains. - Same lost-wakeup-free park ordering the legacy scheduler proved out. - SchedulerKind option + DI selection in RuntimeServiceCollectionExtensions. - ActorPriority / MessagePriority scheduling abstractions. - Coverage: async-resume, concurrency stress, in-flight bound, stealing, message-priority-lane tests. Perf runners gain a --v2 switch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…shutdown fix Drive the activation-scoped dispatch path toward true-zero steady-state allocation, ranked by measured bytes (docs/.../2026-07-13-low-alloc-dispatch-design.md). Park primitive (WorkerParkSignal): replaces the per-worker SemaphoreSlim(0, int.MaxValue) whose cancelable WaitAsync(ct) slow path allocated ~200 B/park (CancellationPromise + registration node + TaskNode) in the park regime. A single-consumer/multi-producer async auto-reset event backed by a reusable ManualResetValueTaskSourceCore<bool>: a park now allocates nothing (the worker's async box already lives for RunWorkerAsync's lifetime). Token-free; DisposeAsync Set()s every worker on shutdown. A boolean auto-reset event is sound because the scheduler wake is edge-triggered — one wake re-sweeps all shards, so a single signal drains arbitrarily many ready activations. Verified against 20M multi-producer/consumer cycles of the exact wake protocol, zero lost wakes. Earlier reductions in the same arc, folded in here: spin-before-park, lock-free long[] bitmask idle registry (replacing ConcurrentStack<int>), StatelessWorkerRouter delegate caching, and a state-passing GrainActivationTable.GetOrCreateAsync<TState> overload — together 585 -> ~1.5 B/call on the activation-scoped path. Shutdown fix (pre-existing latent bug the primitive uncovered): since the Channel -> ConcurrentQueue ready-queue rewrite, ScheduleAsync no longer rejected work after shutdown, so an activation deactivating after the scheduler is disposed (the DI dispose order) posted its OnDeactivate turn onto dead workers and hung forever. The old Channel threw ChannelClosedException here, driving GrainActivation's inline-drain fallback; the SemaphoreSlim primitive masked the gap accidentally by throwing ObjectDisposedException from a disposed semaphore. ScheduleAsync now throws ObjectDisposedException when _cts.IsCancellationRequested (one hot-path volatile read), restoring the reject-on-shutdown contract explicitly. Locked in by ActivationSchedulerShutdownRejectsScheduleTests (both fail — one by hanging — with the guard removed). Measured (AllocByTypeProfiler, park regime): ~200 -> 1.3-1.9 B/call, residual is just the pooled MailboxWorkItem<T>. Scheduling 45/45, Fault 9/9, Integration 38/38. Co-Authored-By: Claude Opus 4.8 (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.
Drives the activation-scoped grain-call dispatch path toward true-zero steady-state allocation, ranked by measured bytes. Three orthogonal features that ship together; the branch tip builds clean (0 warnings) and is green across scheduling 45/45, Fault 9/9, Integration 38/38, unit 549–550/550 (the 1–2 residual failures are documented wall-clock timing flakes that shift across runs and pass in isolation).
Commits
1.
IActivationBehaviordual behavior model (Part A)An opt-in per-activation behavior lifetime alongside the existing per-call
IGrainBehavior, killing the ~792 B/call DI-scope + resolution bucket for grains that opt in.IServiceScope+ one cached behavior instance inGrainActivation(constructed once, reused per call, disposed on deactivation).LocalGrainCallInvokerfast path: no scope, no construct, no lockedResolveService. Reentrant +IActivationBehaviorthrowsNotSupportedException.BehaviorStateAnalyzer: suppress QRK0020/QRK0021 forIActivationBehavior(per-activation fields are legitimate state); QRK0022 still fires; new QRK0023 (Warning) flags[Reentrant]+IActivationBehavior.PostCoreAsyncvariants usePoolingAsyncValueTaskMethodBuilderso dispatch async boxes are pooled.2. Opt-in
ArenaScheduler(next-gen scheduler, ArenaV2)A fresh
IActivationScheduleron dedicated worker threads, per-worker work-stealing deques, and a sharded injection queue — the P1 skeleton of the next-gen scheduler design. Selected only viaSiloRuntimeOptions.SchedulerKind == ArenaV2; the legacy scheduler stays the default until the arena scheduler clears the full benchmark suite.--v2.3. Low-alloc dispatch — allocation-free park primitive + shutdown fix
WorkerParkSignalreplaces the per-workerSemaphoreSlim(0, int.MaxValue), whose cancelableWaitAsync(ct)slow path allocated ~200 B/park in the park regime. A single-consumer/multi-producer async auto-reset event backed by a reusableManualResetValueTaskSourceCore<bool>— a park now allocates nothing. Token-free;DisposeAsyncSet()s every worker on shutdown. A boolean auto-reset event is sound because the wake is edge-triggered (one wake re-sweeps all shards). Verified against 20M multi-producer/consumer cycles of the exact wake protocol, zero lost wakes.long[]bitmask idle registry (replacingConcurrentStack<int>),StatelessWorkerRouterdelegate caching, state-passingGrainActivationTable.GetOrCreateAsync<TState>overload.Channel → ConcurrentQueueready-queue rewrite,ScheduleAsyncstopped rejecting work after shutdown, so an activation deactivating after the scheduler is disposed (the DI dispose order) posted itsOnDeactivateturn onto dead workers and hung forever. The oldChannelthrewChannelClosedExceptionhere, drivingGrainActivation's inline-drain fallback; theSemaphoreSlimprimitive was masking the gap by accidentally throwingObjectDisposedExceptionfrom a disposed semaphore.ScheduleAsyncnow throwsObjectDisposedExceptionwhen_cts.IsCancellationRequested(one hot-path volatile read), restoring the reject-on-shutdown contract explicitly. Locked in byActivationSchedulerShutdownRejectsScheduleTests(both tests fail — one by hanging — with the guard removed).MailboxWorkItem<T>.Notes for reviewers
GrainActivation.cs,LocalGrainCallInvoker.cs) carry both Part A and Part B edits and land in commit 1 as the runtime enablement forIActivationBehavior; their pooling-builder micro-opts are described in commit 3's body and the design spec.docs/superpowers/specs/(2026-07-12-next-gen-scheduler-design.md,2026-07-13-low-alloc-dispatch-design.md,2026-07-13-scheduler-v2-pingpong-cost-profile.md).ArenaScheduler.ScheduleAsynchas the same latent reject-on-shutdown gap (opt-in, currently accidentally-masked); needsValueTask.FromExceptionsince itsScheduleAsyncis synchronous.🤖 Generated with Claude Code