Skip to content

perf(context): lazy event-context seeding — skip stack work on untouched polls - #164

Merged
bodymindarts merged 3 commits into
mainfrom
perf/lazy-event-context-seeding
Jul 27, 2026
Merged

perf(context): lazy event-context seeding — skip stack work on untouched polls#164
bodymindarts merged 3 commits into
mainfrom
perf/lazy-event-context-seeding

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Jul 27, 2026

Copy link
Copy Markdown
Member

Motivation

Follow-up to the review discussion on #163. Benchmarking that PR's two changes in isolation (criterion suite included here; A/B protocol below) showed:

So this PR keeps im and instead removes the machinery cost for the polls that don't need it — which is almost all of them: EventContext is observed once at request setup (the #[es_event_context] macro's inserts) and once per event persist, while the wrapper's poll runs at every .await resumption.

Design

EventContextFuture::poll no longer eagerly creates a context-stack entry. Instead it parks its ContextData on a thread-local pending-seed stack (a move — no clone, no allocation) and pops it on exit:

  • Untouched poll (fast path): the data is taken back unchanged. Zero heap allocations, zero clones, zero CONTEXT_STACK traffic — just two thread-local Vec push/pops.
  • Observed poll: EventContext::current() / seed() first materialize all pending seeds (in push order) into real stack entries, then proceed exactly as before. On poll exit the wrapper harvests ctx.data() from the materialized entry — identical write-back semantics, including the entry surviving if the inner future kept a handle across the poll.

Ordering invariant: pending seeds always sit logically above every entry that existed when they were parked; since every push path (current/seed/fork) materializes first, nested wrappers, forks, and direct seeds interleave identically to eager seeding.

Panic safety: a guard pops the pending record on unwind and drops any materialized handle through the normal EventContext drop path — write-back skipped, matching eager behavior. (Re-polling an EventContextFuture after a panicked poll now panics with an explicit message; futures are contractually dead after a panic.)

Measured effect

Criterion, Apple Silicon, --baseline im = current main (reproduce: cargo bench --bench context -- --save-baseline im on main + the bench commit, then -- --baseline im here):

main (im, eager) #163 (Arc<Vec>) this PR (im, lazy)
wrapper cost per poll (raw driver) 21.4 ns 18.6 ns (−13%) 12.6 ns (−41%)
wrapped 65-yield tokio task 1.91 µs 1.72 µs (−9%) 0.89 µs (−54%) — ~5.8 ns/poll added
heap allocs per untouched poll 1 (Rc::new) 1 (Rc::new) 0
clones per untouched poll 2 1 0
EntityEvents::push persist path 177 ns 123 ns 179 ns (unchanged, p > 0.05)
explicit EventContext::seed() 17.8 ns 16.7 ns 19.7 ns (+10%, materialize check)

Trade-offs, honestly: direct seed() and first-observation polls pay a ~2 ns materialize check; the persist path is unchanged (this PR deliberately doesn't touch ContextData — container/serialization changes can be argued separately on hygiene grounds).

Scale disclaimer (why this is framed as hygiene, not throughput): even at 1M poll resumptions/sec the eager machinery costs ~2% of one core. What this PR does eliminate at any rate is the per-poll allocation and clone traffic — the malloc/free and LocalKey::try_with signals visible in the lana-bank stress-run profile.

Testing

  • New unit tests: untouched-poll fast path (stacks provably untouched), fork-under-wrapper materialization ordering, panic-unwind balance; existing spawned-context tests updated for the lazy depth semantics (entries appear on first observation).
  • nix run .#nextest green (full DB-backed integration suite against live process-compose Postgres); all 37 lib tests + doctests pass; cargo clippy --workspace --all-features clean; cargo fmt --check clean.
  • The criterion bench suite (benches/context.rs, structured after cala-perf) is included so the numbers are reproducible on any branch — it only uses public API, so it cherry-picks onto main and perf(context): eliminate per-poll context clone overhead #163 alike.

🤖 Generated with Claude Code


Note

Medium Risk
Changes async context propagation semantics (lazy vs eager stack depth) across spawned tasks and panics; behavior is heavily tested but ordering bugs could affect event context on persist.

Overview
with_event_context no longer eagerly seeds the context stack on every poll. Each poll parks ContextData on a thread-local pending-seed stack; a real stack entry is created only when the inner future calls EventContext::current() or seed() (via materialize_pending_seeds). Untouched polls avoid Rc allocation, ContextData clone, and CONTEXT_STACK push/pop.

EventContext::seed / current materialize pending seeds before proceeding; stack pushes go through a new private push_entry. SeedGuard handles normal write-back and panic unwind so stacks stay balanced.

Adds benches/context (Criterion) for per-poll overhead, tokio yield, clone, lifecycle, and persist paths, plus tests for lazy depth, untouched polls, fork ordering, and panic cleanup.

Reviewed by Cursor Bugbot for commit 03274df. Bugbot is set up for automated code reviews on this repo. Configure here.

bodymindarts and others added 3 commits July 27, 2026 11:06
Criterion benches (structured after cala-perf) isolating the paths PR #163
touches, using only public API shared by the im and Arc<Vec> implementations
so the commit can be cherry-picked onto either side for an A/B comparison:

1. per_poll_overhead - EventContextFuture wrapper cost per poll (no-op waker)
2. tokio_yield       - same on a 1-worker tokio runtime for realistic scale
3. context_data_clone - the per-poll ContextData clone
4. context_lifecycle - seed/drop (kept by the PR) and seed/insert/drop (CoW)
5. persist_path      - EntityEvents::push (real data_for_storing) + to_value

Run with --features tracing-context to include the per-persist tracing insert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hed polls

EventContextFuture::poll previously did, on every poll of every wrapped
request/job future: clone the context data, push a stack entry
(TLS access + Rc allocation), and on exit clone the data back out and
remove the entry (two more TLS accesses). Criterion measurement of that
wrapper cost: ~21ns/poll, ~18ns of which is the seed/drop machinery —
independent of the ContextData container (an im map clone is a flat
~3.4ns regardless of size).

Almost no poll actually observes the context: EventContext is touched
once at request setup (macro inserts) and once per event persist. So the
wrapper now parks its ContextData on a thread-local pending-seed stack
(a move — no clone, no allocation) and only materializes a real
CONTEXT_STACK entry if the inner future calls EventContext::current()
or ::seed() during that poll. On exit the data is either taken back
unchanged (fast path) or harvested from the materialized entry exactly
as before.

Ordering invariant: pending seeds always sit logically above every
entry that existed when they were parked; current() and seed()
materialize all pending seeds (in push order) before touching the
stack, so nested wrappers, fork(), and direct seeds interleave
identically to eager seeding. A guard pops the pending record on
panic-unwind, dropping any materialized handle through the normal
EventContext drop path.

Measured (criterion, vs main):
- wrapper cost per poll: 21.4ns -> 12.6ns (-41%)
- wrapped 65-yield tokio task: 1.91us -> 0.89us (-54%); ~5.8ns/poll added
- untouched polls now perform zero heap allocations and zero clones
- explicit EventContext::seed(): +10% (~2ns, materialize check)
- EntityEvents::push persist path: unchanged (p > 0.05)

Public API and semantics are unchanged. Two cfg(test) stack-depth
assertions were updated: the wrapper's entry now appears only after the
context is first observed within the poll. Polling an
EventContextFuture again after a poll panicked now panics with an
explicit message (futures are contractually dead after a panic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pending-seed stack, materialization, and poll guard exist solely to
support EventContextFuture's lazy seeding — they are wrapper plumbing,
not part of the EventContext/ContextData core, so they live with the
future code. mod.rs keeps only the two materialize calls at its stack
entry points (current/seed). SeedGuard drops from pub(crate) to
module-private.

No behavior change; poll-path benchmarks unchanged (-40% vs main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bodymindarts
bodymindarts merged commit 587c31f into main Jul 27, 2026
7 checks passed
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