swactor/CLAUDE/notes/dispatch_comparison.md
Developer e91308720b feat: identity hashing for ActorAddress hot-path optimization (Cycle 20)
Replace SipHash with identity hasher on all hot-path HashMaps keyed by
ActorAddress. Since addresses are crypto-random, the first 8 bytes serve
as an excellent hash directly. Microbenchmarks show 1.9-4.6x lookup
speedup depending on map size.

- Custom Hash impl for ActorAddress (8-byte write_u64 instead of 32)
- AddrHasher/AddrBuildHasher identity hasher in delivery.rs
- AddrMap<V>/AddrSet type aliases used in 7 HashMap sites
- Stop-requests is_empty() short-circuit in tick_all inner loop
- 3 new behavioral tests, component microbenchmarks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:01:30 +00:00

5.2 KiB

Dispatch Model Comparison: Stakker vs Actix vs Swactor

Stakker — Closure-Based Dispatch (No HashMap, No Downcast)

Architecture: Single-threaded, synchronous actor runtime (like swactor). Messages are FnOnce closures, not typed structs.

Key Design:

  • call! macro turns method calls into closures: call!([actor], method(arg1, arg2)) generates a FnOnce that directly calls the method on the actor
  • The closure captures the actor reference and method pointer — dispatch is a direct function call, not a downcast
  • Closures stored in a flat heterogeneous FnOnce queue (byte Vec) — no per-message heap allocation
  • Compiler can inline the closure, reducing message handling to "a single branch to optimised inlined code"

Why It's Fast:

  • Zero Box<dyn Any> allocation per message
  • Zero TypeId downcast per message
  • Zero HashMap lookup per message (actors addressed by direct ActorOwn<A> references, not opaque addresses)
  • Queue is a flat contiguous memory region — excellent cache locality

Tradeoffs:

  • Single-threaded only (no multi-worker routing)
  • No location-transparent addresses (can't route to remote actors)
  • Uses unsafe code by default for the FnOnce queue

Actix — Vtable Dispatch (No HashMap, No Downcast)

Architecture: Async actor framework on tokio. Each actor is a pollable Future on an Arbiter thread.

Key Design:

  • Addr<A> wraps an AddressSender<A> — a direct channel reference, not an address in a map
  • Messages wrapped as Box<dyn EnvelopeProxy<A>> — vtable dispatch, not Box<dyn Any> downcast
  • Custom Vyukov lock-free MPSC queue (single AtomicPtr::swap for push)
  • Default mailbox capacity: 16

Why It's Fast:

  • Addr<A> is a direct channel reference — zero HashMap lookup per send
  • EnvelopeProxy vtable call — one virtual dispatch, no TypeId comparison
  • Vyukov MPSC queue — lock-free push, minimal atomic operations
  • do_send() bypasses backpressure for internal messages

Tradeoffs:

  • Requires async runtime (tokio dependency)
  • Addr<A> is typed — can't send different message types without Recipient<M> adaptation
  • do_send() silently drops messages to closed actors (no error feedback)

Swactor — Type-Erased Dispatch with HashMap Routing

Architecture: Synchronous tick-based runtime with multi-worker support. Messages are Box<dyn Any + Send>.

Message Send Path (per-message costs):

  1. Box::new(msg) — heap allocation (~10-15ns)
  2. address_map.lookup(&addr) — RwLock read + HashMap get with 32-byte key
  3. Push to VecDeque or crossbeam queue

Message Process Path (per-message costs):

  1. slot.mailbox.pop_front() — VecDeque pop
  2. msg.downcast::<Incoming>() — TypeId comparison (~1ns)
  3. catch_unwind(|| actor.handle_any(ctx, msg)) — unwind setup (~3-5ns)

Why It's Slower on Paper:

  • HashMap lookup on every send AND every deliver (2 lookups per message)
  • ActorAddress is 32 bytes — expensive to hash (SipHash: ~15ns for 32 bytes)
  • Box<dyn Any> heap allocation on every send

Why This Architecture Exists:

  • Location-transparent 32-byte addresses enable multi-worker routing, transport layer, and distribution
  • Type erasure enables heterogeneous mailboxes and type-agnostic forwarding
  • HashMap enables O(1) address → worker lookup for any actor, from any thread

Optimization Applied: Identity Hashing

Problem: Every HashMap operation hashes 32 bytes of ActorAddress with SipHash.

Solution (two-layer):

  1. Custom Hash impl on ActorAddress — only hashes first 8 bytes via write_u64 (SipHash on 8 bytes is ~3x faster than 32 bytes)
  2. Identity hasher (AddrHasher) on hot-path HashMaps — uses the 8-byte hash value directly as the bucket index, skipping SipHash entirely

Microbenchmark Results (same-process A/B comparison, reliable):

Operation SipHash (8-byte) Identity Speedup
Hash 1.35 ns 0.67 ns 2.0x
Lookup (100 entries) 29.8 ns 15.5 ns 1.9x
Lookup (1000 entries) 38.0 ns 8.2 ns 4.6x
Insert 1000 48.2 µs 21.1 µs 2.3x

Why It's Safe: ActorAddress bytes come from getrandom — cryptographically random, providing excellent uniform distribution without additional mixing.

Summary Table

Aspect Stakker Actix Swactor (optimized)
Message type FnOnce closure Box<dyn EnvelopeProxy> Box<dyn Any + Send>
Dispatch Direct call / inlined Vtable call TypeId downcast
Address lookup None (direct ref) None (direct channel) Identity-hash HashMap
Per-msg alloc None (flat queue) Box (MPSC node) Box (heap + VecDeque)
Multi-thread No Yes (tokio) Yes (worker threads)
Location transparency No No Yes (32-byte address)
Remote transport No No (without extras) Yes (pluggable)

Key Insight

Stakker and Actix are faster because they avoid the per-message HashMap lookup entirely — addresses are direct references, not opaque identifiers that need routing. Swactor pays for HashMap routing because its 32-byte addresses enable multi-worker distribution and remote transport. The identity hasher minimizes this cost without changing the architecture.