103 lines
5.2 KiB
Markdown
103 lines
5.2 KiB
Markdown
|
|
# 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.
|