Rust arena allocation to keep streamerOS under 152 MB for 12h

8 min readYaseen Khatib · MERN + AI Architect

Problem framing: 12 hours, 152 MB, no fragmentation

streamerOS handles RTMP/WebRTC ingest, overlay composition, and encoding. Early heap profiles were fine at the 30-minute mark, then, past six hours, fragmentation and small leaks started to show up. We moved the system to region-based allocation (arenas) with explicit lifetime buckets and hard per-bucket caps.

A concrete budget we hold during a 12-hour run:

Bucket Capacity Notes
Video ring (YUV planes + staging) 64 MB Fixed N frames, no growth
Audio ring 2 MB Fixed PCM windows
Asset arena (fonts, emotes, shader params) 30 MB Long-lived; reset only on scene reload
Segment arena (per-GOP/per-scene tick) 16 MB Reset every 2s (GOP)
Network bytes pool 16 MB Reused BytesMut, backpressured
Scratch arena (layout, AST, temp) 8 MB Reset per frame
Misc (actors, slab indices, logs) 10–12 MB Monitored

The principle is simple: couple allocation to lifetime. Long-lived state goes behind handles in a table. Short-lived work lands in a bump arena we reset at the right boundary.

Pattern 1: Per-epoch bump arenas with reset

For hot, short-lived allocations (layout trees, filter params, chat render buffers), use a bump allocator and drop them in one shot at a well-defined boundary (GOP, scene tick, or render frame).

bumpalo fits this use case.

use bumpalo::Bump;
use bumpalo::collections::{Vec as BArenaVec, String as BArenaString};

#[derive(Debug)]
struct GlyphRun<'a> {
    text: &'a str,
    x: f32,
    y: f32,
}

#[derive(Debug)]
struct OverlayFrame<'a> {
    runs: BArenaVec<'a, GlyphRun<'a>>,
}

fn build_overlay_frame<'a>(seg: &'a Bump, chat_msgs: &[&str]) -> OverlayFrame<'a> {
    // Reserve predictably to avoid re-alloc within the arena page.
    let mut runs = BArenaVec::with_capacity_in(chat_msgs.len(), seg);

    for (i, msg) in chat_msgs.iter().enumerate() {
        // Copy small strings into the arena to tightly pack
        let mut s = BArenaString::with_capacity_in(msg.len(), seg);
        s.push_str(msg);
        let text: &'a str = s.into_bump_str();

        runs.push(GlyphRun { text, x: 16.0, y: 24.0 + 18.0 * i as f32 });
    }

    OverlayFrame { runs }
}

struct SegmentCtx {
    arena: Bump,
}

impl SegmentCtx {
    fn new() -> Self { Self { arena: Bump::with_capacity(1 << 20) } } // 1 MiB first page
    fn reset(&mut self) { self.arena.reset(); }
}

fn render_segment(mut seg: SegmentCtx) {
    for gop in 0..7200 { // ~4 hours at 2s GOP; example only
        let chat = ["hi", ":)", "new follower", "gg"]; // streamed in
        let frame = build_overlay_frame(&seg.arena, &chat);
        // ... composite into video planes using frame.runs ...
        // Drop per-GOP allocations in one shot:
        seg.reset();
    }
}

Notes:

  • Addresses inside the arena are stable for the whole epoch, which makes passing slices to the compositor straightforward.
  • Reset is basically advancing a pointer back; pages stay hot in the cache.
  • Drop doesn’t run per object; don’t park RAII that must execute in Drop inside arena-owned values.

Avoiding 'static traps with async

Arenas rarely live 'static. Keep them thread-confined and pass handles across tasks instead of references. Example: an actor owns the segment arena and does not spawn background futures that capture borrows from it. Inter-actor messages carry handles or Bytes, never &'arena T.

Pattern 2: Handle indirection with a generational table for long-lived state

Pointers into an arena die on reset. For state that spans epochs (sessions, filters with mutable params, assets), store it in a stable table and pass typed handles.

slotmap is ergonomic and avoids ABA-style reuse.

use slotmap::{new_key_type, SlotMap};

new_key_type! { pub struct SessionKey; }

#[derive(Debug)]
struct Session {
    user_id: u64,
    bitrate_kbps: u32,
    // store IDs into interned strings or assets, not owned Strings
    display_name: StringId,
}

#[derive(Copy, Clone, Debug)]
struct StringId(u32); // points into our string interner (see below)

struct SessionTable {
    inner: SlotMap<SessionKey, Session>,
}

impl SessionTable {
    fn new() -> Self { Self { inner: SlotMap::with_key() } }
    fn insert(&mut self, sess: Session) -> SessionKey { self.inner.insert(sess) }
    fn get(&self, k: SessionKey) -> Option<&Session> { self.inner.get(k) }
    fn get_mut(&mut self, k: SessionKey) -> Option<&mut Session> { self.inner.get_mut(k) }
    fn remove(&mut self, k: SessionKey) { self.inner.remove(k); }
}

Pass SessionKey across threads or stash it in metrics, and avoid holding &Session across await points. This removes lifetime knots and prevents accidental cloning of heavy payloads.

Pattern 3: Fixed-cap string interning for chat/emotes

Chat overlays and logs repeat usernames, emotes, and commands. Intern strings into a fixed-cap arena to dedupe and keep RAM bounded. For deterministic caps, use a fixed bump allocator for the bytes plus a hash map from hash/key to offsets. Below is a minimal fixed-cap bump and a compact interner.

use core::{cell::Cell, mem, ptr, alloc::Layout};
use std::alloc::{alloc_zeroed, dealloc};
use ahash::AHashMap; // fast and predictable

struct FixedBump {
    ptr: *mut u8,
    cap: usize,
    off: Cell<usize>,
}

impl FixedBump {
    fn with_capacity(cap: usize) -> Self {
        let layout = Layout::from_size_align(cap, 64).unwrap();
        let ptr = unsafe { alloc_zeroed(layout) };
        Self { ptr, cap, off: Cell::new(0) }
    }
    fn alloc_bytes(&self, n: usize, align: usize) -> Option<*mut u8> {
        let base = self.ptr as usize;
        let cur = self.off.get();
        let aligned = (base + cur + (align - 1)) & !(align - 1);
        let new_off = (aligned - base) + n;
        if new_off > self.cap { return None; }
        self.off.set(new_off);
        Some(aligned as *mut u8)
    }
    fn alloc_str(&self, s: &str) -> Option<&str> {
        unsafe {
            let p = self.alloc_bytes(s.len(), mem::align_of::<u8>())?;
            ptr::copy_nonoverlapping(s.as_ptr(), p, s.len());
            Some(std::str::from_utf8_unchecked(std::slice::from_raw_parts(p, s.len())))
        }
    }
}

impl Drop for FixedBump {
    fn drop(&mut self) {
        unsafe { dealloc(self.ptr, Layout::from_size_align(self.cap, 64).unwrap()) }
    }
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct StringId(u32);

pub struct Interner<'a> {
    bump: &'a FixedBump,
    map: AHashMap<&'a str, StringId>,
    rev: Vec<&'a str>,
}

impl<'a> Interner<'a> {
    pub fn new(bump: &'a FixedBump, cap: usize) -> Self {
        Self { bump, map: AHashMap::with_capacity(cap), rev: Vec::with_capacity(cap) }
    }
    pub fn intern(&mut self, s: &str) -> Option<StringId> {
        if let Some(id) = self.map.get(s) { return Some(*id); }
        let stored = self.bump.alloc_str(s)?; // returns None if out of space
        let id = StringId(self.rev.len() as u32);
        self.map.insert(stored, id);
        self.rev.push(stored);
        Some(id)
    }
    pub fn get(&self, id: StringId) -> &'a str { self.rev[id.0 as usize] }
}

Allocate a single 8–12 MB fixed region for strings. When alloc_str returns None, evict oldest or least-used IDs, or shed non-essential interning (for example, skip non-emote chat). With deduplication, most runs never reach the cap.

Pattern 4: Zero-copy bytes pool for network and IPC

Reduce churn in network parsing and serialization by pooling BytesMut slabs. bytes::Bytes/BytesMut provide zero-copy slicing and refcounted sharing. Pre-allocate a small number of large slabs and recycle them.

use bytes::{Bytes, BytesMut, BufMut};
use crossbeam_queue::ArrayQueue;
use std::sync::Arc;

struct BytesPool {
    q: Arc<ArrayQueue<BytesMut>>, // lock-free SPSC/MPSC works well per-actor
}

impl BytesPool {
    fn with_slabs(n: usize, slab_size: usize) -> Self {
        let q = Arc::new(ArrayQueue::new(n));
        for _ in 0..n { let _ = q.push(BytesMut::with_capacity(slab_size)); }
        Self { q }
    }
    fn take(&self) -> Option<BytesMut> { self.q.pop().ok() }
    fn give(&self, mut b: BytesMut) {
        b.clear();
        let _ = self.q.push(b); // if full, drop -> backpressure via allocation
    }
}

// usage in an actor
fn serialize_msg(pool: &BytesPool, payload: &[u8]) -> Bytes {
    let mut buf = pool.take().unwrap_or_else(|| BytesMut::with_capacity(16 * 1024));
    buf.put_u16(payload.len() as u16);
    buf.extend_from_slice(payload);
    buf.freeze() // zero-copy share; return slab to pool when refcount drops
}

The pool keeps memory use predictable. If producers outpace consumers, either allow a temporary allocation and account for it, or prefer backpressure by refusing to send.

Glueing it together: lifetime buckets per actor

  • AssetActor: owns the Asset arena (bumpalo or FixedBump+Interner), a slotmap for filters/shaders, and image atlases; exposes handles.
  • SegmentActor: owns the per-GOP bump arena; builds overlay layout, text shaping, and effect params; resets on every GOP.
  • IOActor(s): own the BytesPool; network buffers are Bytes/BytesMut; communication with other actors is handles and Bytes only.

Cross-actor messages contain only:

  • Small POD structs (Copy),
  • Slotmap keys (handles),
  • Bytes (for payloads),
  • Compact indices (StringId, TextureId).

Do not pass &'a T between actors. Keep Vec and owned Strings out of the hot path.

Instrumentation and enforcement

  • Track current and high-water usage per bucket. With bumpalo arenas, keep a manual counter for known allocations and compare with page sizes. With FixedBump, the off counter is exact.
  • Export gauges via metrics and assert on thresholds (for example, if the segment arena exceeds 16 MB twice consecutively, drop debug overlays or shed features).
  • Prefer fallible allocation in hot paths (Option/Result) so the system can degrade cleanly instead of hitting OOM.

Example: bound per-segment cost for text shaping.

struct BudgetGuard { used: usize, limit: usize }
impl BudgetGuard {
    fn try_alloc(&mut self, n: usize) -> bool {
        if self.used + n > self.limit { return false; }
        self.used += n; true
    }
}

fn push_text(seg: &Bump, budget: &mut BudgetGuard, s: &str, out: &mut BArenaVec<'_, GlyphRun<'_>>) -> bool {
    if !budget.try_alloc(s.len()) { return false; }
    let text = BArenaString::from_str_in(s, seg).into_bump_str();
    out.push(GlyphRun { text, x: 0.0, y: 0.0 });
    true
}

Practical pitfalls

  • Don’t store Drop-heavy types in arenas. Use handles to resources that use standard ownership.
  • Avoid futures that borrow from arenas. Keep arenas thread-confined; send handles or Bytes.
  • Pre-size collections in arenas. bumpalo::collections::Vec grows by allocating more pages; reserve accurate sizes to prevent page bloat.
  • Shrink or clear long-lived Vecs only when you know it helps. Prefer pooled buffers to repeated alloc/free cycles.
  • Use smallvec/arrayvec for tiny collections to keep storage on the stack and predictable.

Why this keeps us under 152 MB for 12 hours

  • Bump arenas don’t fragment since allocation is a pointer bump followed by a reset.
  • Long-lived state sits behind compact handles and interned strings, avoiding accidental clones of large JSON/strings during spikes.
  • Bytes pooling plus handle-only messages avoid payload copies across actors.
  • Caps are explicit; when breached, the system degrades predictably instead of leaking.

This isn’t micro-optimization. It’s matching allocation to lifetime and enforcing caps with backpressure. The result is a flat RSS over a 12-hour broadcast and steady performance where it matters.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.