Skip to content

perf: streamline timer service - #3

Draft
tisonkun wants to merge 6 commits into
mainfrom
feat/timer-context
Draft

perf: streamline timer service#3
tisonkun wants to merge 6 commits into
mainfrom
feat/timer-context

Conversation

@tisonkun

@tisonkun tisonkun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • keep the direct TimerService/TimerContext ownership pair after comparing its boundary with Boost.Asio and Tokio; do not introduce an aggregate context whose only capability would be optional
  • replace the operation channel and separate wake handshake with a reusable standard-library Mutex<VecDeque<_>> queue that owns admission and the replaceable reactor waker
  • keep slab as Scorpio's only production dependency and replace the former boxed per-delay waker cell with a private, non-blocking three-state inline slot
  • use Divan to separate caller lifecycle, service registration/cancellation drain, and registered expiry, with boxed service inputs and deferred exact-count validation outside timed regions
  • document the retained API, synchronization and shutdown invariants, benchmark boundaries, local allocation/contention experiments, rejected alternatives, and the remaining Tokio gap

Design Notes

API and ownership

The Asio comparison is structural rather than a one-to-one type translation. Asio's execution_context is a service-registry base, io_context adds a public run loop, and basic_waitable_timer is executor-associated. Internally, deadline_timer_service owns a separate timer queue and calls a platform-selected timer scheduler.

Scorpio does not own an application run loop, platform wait primitive, or service registry. TimerContext is the cheap task-facing submission and clock-observation capability. TimerService is the timer-specific reactor facility that owns advancement, the timing wheel, wake integration, and shutdown. TimerDriver would overstate ownership of the enclosing reactor, TimerScheduler would imply responsibility for platform waiting, and TimerQueue would omit advancement and lifecycle responsibilities.

There is no aggregate IoContext. With timer as the only concrete capability, a bundle would add an empty state, a builder, and optional access without removing caller responsibility. If multiple independently enabled capabilities emerge, an aggregate can be reconsidered with feature-selected, non-optional fields so every constructed context contains every compiled capability.

Queue, wake integration, and bounded work

The operation queue uses one standard-library mutex for admission, reusable VecDeque storage, and the replaceable reactor waker. Producers append under the mutex and only an empty-to-nonempty transition takes the reactor waker. Cloning, dropping, and invoking user-provided wakers happen outside the critical section. The single-writer service swaps a bounded operation batch into private scratch storage before mutating the wheel. Closing stops admission, extracts queued work and the reactor waker, and performs user-visible destruction and wake calls after unlocking.

Queue emptiness is the only operation-backlog source; there is no duplicate pending bit or fence handshake. TurnBudget independently bounds operation messages and wheel entries. The Divan sentinel validator appends one known operation after each declared batch, proves that a count-sized turn stops immediately before it, then proves that a one-operation turn consumes it and leaves no hidden work. Production tests cover the same exact-limit behavior.

Inline delay waker

The previous private AtomicPtr<Waker> cell allocated a separate Box<Waker> for every first poll and used a SeqCst fence pair to prevent registration and terminal publication from both missing one another. The final slot stores Option<Waker> inline in the existing Arc<TimerState> allocation and uses one AtomicU8 with three states:

State Owner and transition
READY the slot contains the current waker; a terminal swap takes it
REGISTERING the polling task exclusively replaces the inline waker
TERMINAL absorbing; a READY predecessor transferred the waker to the publisher, while a REGISTERING predecessor delegated cleanup to that polling task

The first waker is installed while TimerState is still private, so first poll needs neither slot synchronization nor a second allocation. On a genuinely changed waker, cloning happens before claiming REGISTERING, replacement is published with release ordering, and the old clone is dropped after the slot is stable. The cached raw identity is cleared before entering this unwind-capable path, so catching a custom RawWaker drop panic cannot leave an old identity pointing at a newly replaced slot. Repeated polls with the same data/vtable identity remain a no-clone fast path; semantically equivalent wakers with different raw representations are safely republished.

Terminal publication changes the lifecycle first and then performs one non-blocking atomic state transition. If it encounters REGISTERING, it returns immediately; the active poll owns cleanup, acquires the terminal transition, and observes the already-published lifecycle. There is no mutex wait, spin loop, or user RawWaker operation inside the exclusive state. The narrow private type explicitly restores RefUnwindSafe, with compile-time tests preserving the public TimerContext, TimerService, Delay, and Interval auto traits.

The Loom model mirrors both ownership state and slot contents as OLD, NEW, or EMPTY; it accepts only a poll that observes terminal state or a publisher that actually takes the new waker. This avoids the weaker assertion that could mistake waking an obsolete waker for success. Miri exercises changed-waker replacement, same-waker reuse, registration/fire, cancellation/fire, and the caught-drop-panic path.

Divan measurement boundaries

timer/frontend_lifecycle measures relative-delay creation, first poll, and drop for Scorpio, Tokio, async-io, and futures-timer at 1, 64, and 1,024 timers. It intentionally excludes service-side work. Divan creates a fresh boxed Scorpio service and context outside the measured iteration; deferred output destruction drains and verifies the exact operation batch outside the timed interval.

timer/scorpio_service separately measures registration and cancellation queue draining, which Scorpio deliberately exposes to the application-owned reactor. timer/expire_registered constructs and registers inputs outside timing, then measures direct service expiry plus terminal polling for same-deadline buckets and a distribution spanning selected wheel levels.

TimerService embeds a 12,584-byte wheel in the reviewed arm64 artifact. Every service input is boxed before Divan's by-value handoff, so the timed closure moves one pointer instead of copying the wheel. Earlier unboxed results were discarded. Validators run after timing and prove the declared item count and final public state.

Performance evidence

Measurements were collected on an Apple M4 Max (14 cores, 36 GB) running macOS 26.3.1 with rustc/cargo 1.95.0. Divan reported 41 ns timer precision. Statistical cases used --sample-count 5000 --sample-size 1 --color never; each table entry is the median of three independent run medians. Baseline and candidate runs were interleaved on the same machine. Absolute values move with machine state, so conclusions use only each immediate A/B cohort.

The inline-waker A/B compares the current draft head aa857fe with the final three-state candidate:

Boundary Items aa857fe Inline waker Change
Frontend lifecycle 64 5.207 us 4.249 us -18.4%
Frontend lifecycle 1,024 78.45 us 64.70 us -17.5%
Mixed-level expiry 64 3.457 us 2.833 us -18.1%
Mixed-level expiry 1,024 51.49 us 42.95 us -16.6%
Same-deadline expiry 64 3.291 us 2.707 us -17.7%
Same-deadline expiry 1,024 51.45 us 41.24 us -19.8%

The service-only boundary shows no material regression. Registration was +3.3% at 64 items and -1.2% at 1,024; cancellation was -8.7% and -7.6%. The cancellation movement is not attributed to this change because waker destruction occurs during out-of-band setup, and the small registration movement is close to run spread and timer precision.

At the common frontend boundary, the Tokio comparison in the same measurement session is:

Items aa857fe Inline waker Tokio Gap before Gap after
64 5.207 us 4.249 us 3.624 us +43.7% +17.2%
1,024 78.45 us 64.70 us 56.95 us +37.8% +13.6%

This narrows rather than eliminates the gap. The comparison is boundary-specific, not a universal runtime ranking: Tokio owns a runtime and driver lock, while Scorpio exposes service advancement to the caller; async-io and futures-timer also have different helper-thread and global-fallback ownership.

An earlier isolated A/B in this PR replaced the operation channel/wake handshake with the final queue. Against draft head 33069a6, the mechanically aligned boxed-service cohort reduced 1,024-item registration and cancellation drain from 20.29/19.24 us to 16.95/14.08 us. Those queue results and the inline-waker results are separate cohorts and are not arithmetically combined.

Local bottleneck findings and rejected variants

The original boxed-waker phase probe localized most of the public lifecycle gap to first poll:

Phase Items Scorpio Tokio Absolute gap
Create 64 1.624 us 1.499 us 0.125 us
First poll 64 2.582 us 958.7 ns 1.623 us
Drop 64 1.041 us 1.040 us 0.001 us
Create 1,024 26.16 us 24.12 us 2.04 us
First poll 1,024 36.37 us 14.74 us 21.63 us
Drop 1,024 16.83 us 16.08 us 0.75 us

At 1,024 timers, the final allocation probe records 1,025 allocation calls during creation and 1,026 during first poll. The boxed baseline recorded 2,050 first-poll allocation calls. The exact reduction of 1,024 calls is the removed per-delay waker box. Requested first-poll bytes remain about 65.7 KB because TimerState grows from 32 to 48 bytes: the waker bytes move into the existing Arc allocation rather than disappearing. Eight queue growth events remain, with 24.48 KB of capacity growth. The remaining stable first-poll allocation cost is therefore one Arc<TimerState> per delay plus small queue/container allocation.

Several alternatives were measured and rejected:

  • Mutex<Option<Waker>> was simpler but its 1,024-item lifecycle rose to roughly 86-87 us versus a 76-78 us pointer-cell cohort. A try_lock fast path did not recover the loss.
  • An AtomicBool spin cell removed the allocation and matched the fast candidate, but a preempted polling thread could make the reactor spin indefinitely. A short critical section does not provide a progress guarantee.
  • Keeping an inline initial waker beside the pointer cell required two independent ownership atomics plus the old fence protocol. It retained more mechanism and was slower in the larger lifecycle cohort than the unified state word.
  • The first non-blocking implementation used four states and a CAS retry loop. The final protocol observes that TERMINAL can also mean “cleanup delegated to the current registrar,” removing the deferred-take state and the retry loop without losing a transition or measurable performance.

A persistent-worker multi-producer probe previously showed that 2-8 producers degrade relative to one producer in a manner consistent with queue-mutex contention. It did not isolate lock waiting from allocation and scheduling, so it does not justify Crossbeam, a lock-free queue, or a caller-visible batch API.

Remaining Tokio gap and proportionate next steps

Tokio 1.53.1 stores StateCell and its internal AtomicWaker inside the pinned TimerEntry; its driver uses an intrusive raw handle under the driver lock. Scorpio still allocates one Arc<TimerState> at first poll and submits registration/cancellation through the explicit multi-producer operation queue. The final allocation probe and Tokio's zero-allocation first-poll phase make that Arc allocation the next plausible single-producer target.

Copying Tokio's layout directly would make Scorpio's currently Unpin delay self-referential and couple correctness to pinning, raw-pointer lifetime, driver locking, and cancellation before memory reuse. That is not a proportionate follow-up merely to chase the last 13-17% in this synthetic boundary.

The next investigations should therefore remain gated:

  1. Isolate the remaining TimerState allocation before changing ownership. A generation-checked service arena is worth prototyping only if it demonstrates a material win and does not retain cancelled states or complicate cross-thread drop.
  2. Profile cancellation drain and Arc/cache behavior before adding batching. The current public API should not expose a bulk path solely for a benchmark.
  3. Revisit producer sharding or an intrusive single-consumer queue only when a real workload registers timers concurrently from several threads and one-producer latency remains intact.
  4. Do not add Crossbeam, an external AtomicWaker, pools, thread-local direct paths, or another public context layer without evidence that the corresponding workload pays for them.

No direct or production Crossbeam, mea, or external AtomicWaker synchronization dependency remains. The benchmark-only async-io comparison still brings crossbeam-utils transitively through concurrent-queue; Scorpio production depends only on slab.

Validation

  • cargo x test: 60 unit tests and 3 doctests passed
  • cargo x build --locked: all workspace targets and lockfile validation passed
  • cargo x lint: Clippy with denied warnings, formatting, TOML formatting, spelling, license headers, and rustdoc passed
  • cargo x bench --quick: all 20 Divan smoke cases passed
  • cargo +1.85.0 check -p scorpio --all-features --tests: MSRV check passed
  • Miri passed changed-waker replacement, repeated same-waker polls, caught RawWaker-drop unwind recovery, concurrent registration/fire, and concurrent cancellation/fire reclamation
  • the strengthened Loom model exhaustively checked old/new/empty slot publication for the modeled interleavings
  • repeated context-isolated ScopeDB reviews covered progress, memory ordering, unwind safety, auto traits, benchmark value, API shape, documentation, and PR/tree consistency

@tisonkun tisonkun changed the title feat: add composable I/O context perf: streamline timer context Aug 10, 2026
@tisonkun tisonkun changed the title perf: streamline timer context perf: streamline timer service Aug 10, 2026
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