perf: streamline timer service - #3
Draft
tisonkun wants to merge 6 commits into
Draft
Conversation
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.
Summary
TimerService/TimerContextownership pair after comparing its boundary with Boost.Asio and Tokio; do not introduce an aggregate context whose only capability would be optionalMutex<VecDeque<_>>queue that owns admission and the replaceable reactor wakerslabas Scorpio's only production dependency and replace the former boxed per-delay waker cell with a private, non-blocking three-state inline slotDesign Notes
API and ownership
The Asio comparison is structural rather than a one-to-one type translation. Asio's
execution_contextis a service-registry base,io_contextadds a public run loop, andbasic_waitable_timeris executor-associated. Internally,deadline_timer_serviceowns 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.
TimerContextis the cheap task-facing submission and clock-observation capability.TimerServiceis the timer-specific reactor facility that owns advancement, the timing wheel, wake integration, and shutdown.TimerDriverwould overstate ownership of the enclosing reactor,TimerSchedulerwould imply responsibility for platform waiting, andTimerQueuewould 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
VecDequestorage, 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.
TurnBudgetindependently 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 separateBox<Waker>for every first poll and used aSeqCstfence pair to prevent registration and terminal publication from both missing one another. The final slot storesOption<Waker>inline in the existingArc<TimerState>allocation and uses oneAtomicU8with three states:READYREGISTERINGTERMINALREADYpredecessor transferred the waker to the publisher, while aREGISTERINGpredecessor delegated cleanup to that polling taskThe first waker is installed while
TimerStateis still private, so first poll needs neither slot synchronization nor a second allocation. On a genuinely changed waker, cloning happens before claimingREGISTERING, 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 restoresRefUnwindSafe, with compile-time tests preserving the publicTimerContext,TimerService,Delay, andIntervalauto traits.The Loom model mirrors both ownership state and slot contents as
OLD,NEW, orEMPTY; 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_lifecyclemeasures 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_serviceseparately measures registration and cancellation queue draining, which Scorpio deliberately exposes to the application-owned reactor.timer/expire_registeredconstructs and registers inputs outside timing, then measures direct service expiry plus terminal polling for same-deadline buckets and a distribution spanning selected wheel levels.TimerServiceembeds 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
aa857fewith the final three-state candidate:aa857feThe 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:
aa857feThis 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:
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
TimerStategrows 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 oneArc<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. Atry_lockfast path did not recover the loss.AtomicBoolspin 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.TERMINALcan 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
StateCelland its internalAtomicWakerinside the pinnedTimerEntry; its driver uses an intrusive raw handle under the driver lock. Scorpio still allocates oneArc<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
Unpindelay 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:
TimerStateallocation 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.No direct or production Crossbeam,
mea, or external AtomicWaker synchronization dependency remains. The benchmark-only async-io comparison still bringscrossbeam-utilstransitively throughconcurrent-queue; Scorpio production depends only onslab.Validation
cargo x test: 60 unit tests and 3 doctests passedcargo x build --locked: all workspace targets and lockfile validation passedcargo x lint: Clippy with denied warnings, formatting, TOML formatting, spelling, license headers, and rustdoc passedcargo x bench --quick: all 20 Divan smoke cases passedcargo +1.85.0 check -p scorpio --all-features --tests: MSRV check passed