swactor/CLAUDE/notes/dispatch_comparison.md

103 lines
5.2 KiB
Markdown
Raw Normal View History

# 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.