Rust arena allocation to keep streamerOS under 152 MB for 12h
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.