Our first implementation is incredibly slow, and failed to scale with increased threads. In order to fix this, we are implementing cache-aware batching of message processing, and an epoch-based actor rebalancing using shard pools.
113 lines
No EOL
4.7 KiB
Markdown
113 lines
No EOL
4.7 KiB
Markdown
struct MessageRing:
|
|
buffer: [u8; 4096]
|
|
head: AtomicUsize # Sender writes (Release)
|
|
tail: AtomicUsize # Worker reads (Acquire)
|
|
|
|
struct LocalArena:
|
|
buffer: [u8; 262144] # Raw message bytes from rings
|
|
bump: usize
|
|
|
|
# Key addition: Bucket buffer (indices into arena, not copies)
|
|
struct BucketBuffer:
|
|
# Pre-allocated array of slices. Max 64K actors, resize if needed.
|
|
# bucket[i] contains indices of messages for actor i.
|
|
buckets: Vec<Vec<Slice>> # Or flat Vec with head/tail if arena-allocated
|
|
actor_order: Vec<ActorId> # Which actors have messages (for iteration)
|
|
|
|
struct Worker:
|
|
worker_id: ID
|
|
inbox_rings: Vec<MessageRing> # Per-sender rings
|
|
arena: LocalArena # Contiguous message storage
|
|
buckets: BucketBuffer # Grouped by actor
|
|
actor_table: Vec<Actor> # ActorId -> Actor
|
|
|
|
# Sender side (unchanged, 30 cycles)
|
|
function send_message(sender, target_worker, actor_id, payload):
|
|
ring = sender.rings[target_worker]
|
|
offset = reserve_in_ring(ring, 8 + len(payload))
|
|
serialize(ring.buffer[offset:], actor_id, len(payload), payload)
|
|
ring.head.store(offset + 8 + len(payload), Release)
|
|
|
|
# Worker side: Three-phase pipeline
|
|
function worker_run(worker):
|
|
while true:
|
|
# PHASE 1: DRAIN (same as before, ~5 cycles per message)
|
|
# -----------------------------------------------
|
|
for ring in worker.inbox_rings:
|
|
head = ring.head.load(Acquire)
|
|
tail = ring.tail.load(Relaxed)
|
|
if head == tail: continue
|
|
|
|
# Copy sequential chunk from ring -> arena (hardware prefetch)
|
|
size = head - tail
|
|
memcpy(worker.arena.buffer[worker.arena.bump:],
|
|
ring.buffer[tail:], size)
|
|
|
|
# Parse boundaries while copying to avoid second pass
|
|
parse_and_bucket(worker.arena, worker.arena.bump, size, worker.buckets)
|
|
|
|
worker.arena.bump += size
|
|
ring.tail.store(head, Relaxed)
|
|
|
|
if worker.arena.bump == 0:
|
|
cpu_relax()
|
|
continue
|
|
|
|
# PHASE 2: RADIX BUCKET (O(N), deterministic ~300 cycles)
|
|
# ------------------------------------------------------
|
|
# We already built buckets during parse_and_bucket above,
|
|
# but if we deferred parsing, do it now:
|
|
|
|
# Option A: If parsed during drain (optimal)
|
|
# Buckets already filled with (offset, len) pairs pointing into arena
|
|
|
|
# Option B: Linear scan to build buckets (if raw bytes in arena)
|
|
offset = 0
|
|
while offset < worker.arena.bump:
|
|
actor_id = read_u32(arena[offset:])
|
|
msg_len = read_u32(arena[offset+4:])
|
|
|
|
# Append to actor's bucket (Vec push, amortized O(1))
|
|
# Each bucket entry: (offset, msg_len) = 16 bytes
|
|
worker.buckets.buckets[actor_id].append((offset+8, msg_len))
|
|
|
|
# Track unique actors (optional, avoids empty bucket scans)
|
|
if worker.buckets.buckets[actor_id].len() == 1:
|
|
worker.buckets.actor_order.append(actor_id)
|
|
|
|
offset += 8 + msg_len
|
|
|
|
# PHASE 3: PROCESS BY ACTOR (hidden message fetch, hot actor state)
|
|
# ----------------------------------------------------------------
|
|
for actor_id in worker.buckets.actor_order:
|
|
actor = worker.actor_table[actor_id] # First access: L3 miss (250 cycles)
|
|
|
|
# Prefetch next actor's state while processing current (optional)
|
|
prefetch_actor(worker.buckets.actor_order, worker.actor_table)
|
|
|
|
# Process all messages for this actor
|
|
# arena[slice] is L1 hit (12 cycles) - scanned sequentially within actor
|
|
for (msg_offset, msg_len) in worker.buckets.buckets[actor_id]:
|
|
msg_data = worker.arena.buffer[msg_offset : msg_offset+msg_len]
|
|
actor.process(msg_data) # 100 cycles work
|
|
|
|
# actor state stays in L1 for entire inner loop
|
|
|
|
# PHASE 4: RESET (zero cost)
|
|
worker.arena.bump = 0
|
|
clear_buckets(worker.buckets) # Just reset lengths, don't free
|
|
|
|
# Helper: Parse during drain to avoid touching bytes twice
|
|
function parse_and_bucket(arena, base_offset, size, buckets):
|
|
ptr = 0
|
|
while ptr < size:
|
|
actor_id = read_u32(arena[base_offset + ptr:])
|
|
msg_len = read_u32(arena[base_offset + ptr + 4:])
|
|
|
|
# Append metadata to bucket (16 bytes: offset, len)
|
|
buckets.buckets[actor_id].append((base_offset + ptr + 8, msg_len))
|
|
|
|
if buckets.buckets[actor_id].len() == 1:
|
|
buckets.actor_order.append(actor_id)
|
|
|
|
ptr += 8 + msg_len |