Skip to content

Low-alloc dispatch: dual behavior model, opt-in arena scheduler, allocation-free park primitive - #168

Open
thnak wants to merge 3 commits into
mainfrom
low-alloc-dispatch
Open

Low-alloc dispatch: dual behavior model, opt-in arena scheduler, allocation-free park primitive#168
thnak wants to merge 3 commits into
mainfrom
low-alloc-dispatch

Conversation

@thnak

@thnak thnak commented Jul 13, 2026

Copy link
Copy Markdown
Owner

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. IActivationBehavior dual 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.

  • One activation-lifetime IServiceScope + one cached behavior instance in GrainActivation (constructed once, reused per call, disposed on deactivation).
  • LocalGrainCallInvoker fast path: no scope, no construct, no locked ResolveService. Reentrant + IActivationBehavior throws NotSupportedException.
  • BehaviorStateAnalyzer: suppress QRK0020/QRK0021 for IActivationBehavior (per-activation fields are legitimate state); QRK0022 still fires; new QRK0023 (Warning) flags [Reentrant] + IActivationBehavior.
  • PostCoreAsync variants use PoolingAsyncValueTaskMethodBuilder so dispatch async boxes are pooled.
  • Measured (DispatchPipelineBenchmarks): 2039 → 1253 B/call for opted-in grains (−786 B); mean latency 14.2 → 6.8 µs.

2. Opt-in ArenaScheduler (next-gen scheduler, ArenaV2)

A fresh IActivationScheduler on dedicated worker threads, per-worker work-stealing deques, and a sharded injection queue — the P1 skeleton of the next-gen scheduler design. Selected only via SiloRuntimeOptions.SchedulerKind == ArenaV2; the legacy scheduler stays the default until the arena scheduler clears the full benchmark suite.

  • Spill-to-ThreadPool on await removes both blocking-drain latency and the bounded-worker reentrancy deadlock.
  • Per-worker in-flight backpressure (Dekker-fenced); same lost-wakeup-free park ordering as the legacy scheduler.
  • Coverage: async-resume, concurrency stress, in-flight bound, stealing, message-priority-lane tests. Perf runners gain --v2.

3. Low-alloc dispatch — allocation-free park primitive + shutdown fix

  • WorkerParkSignal replaces the per-worker SemaphoreSlim(0, int.MaxValue), whose cancelable WaitAsync(ct) slow path allocated ~200 B/park in the park regime. A single-consumer/multi-producer async auto-reset event backed by a reusable ManualResetValueTaskSourceCore<bool> — a park now allocates nothing. Token-free; DisposeAsync Set()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.
  • Folded-in earlier reductions from the same arc: spin-before-park, lock-free long[] bitmask idle registry (replacing ConcurrentStack<int>), StatelessWorkerRouter delegate caching, state-passing GrainActivationTable.GetOrCreateAsync<TState> overload.
  • Shutdown fix (pre-existing latent bug the primitive uncovered): since the Channel → ConcurrentQueue ready-queue rewrite, ScheduleAsync stopped rejecting 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 was masking the gap by accidentally 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 tests 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>.

Notes for reviewers

  • Commits are split by feature. Two shared runtime files (GrainActivation.cs, LocalGrainCallInvoker.cs) carry both Part A and Part B edits and land in commit 1 as the runtime enablement for IActivationBehavior; their pooling-builder micro-opts are described in commit 3's body and the design spec.
  • Design specs under 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).
  • Follow-up tracked in the spec: ArenaScheduler.ScheduleAsync has the same latent reject-on-shutdown gap (opt-in, currently accidentally-masked); needs ValueTask.FromException since its ScheduleAsync is synchronous.

🤖 Generated with Claude Code

thnak and others added 3 commits July 13, 2026 07:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant