framealloc
Deterministic, frame-based memory allocation for Rust game engines.
framealloc is an engine-shaped memory allocation crate designed for predictable performance, explicit lifetimes, and out-of-the-box scaling from single-threaded to multi-threaded workloads.
It is not a general-purpose replacement for Rust's global allocator. It is a purpose-built tool for game engines, renderers, simulations, and real-time systems.
Why framealloc?
Most game engine memory:
- Is short-lived
- Has a clear lifetime (per-frame, per-system, per-task)
- Is performance-sensitive
- Should never hit the system allocator in hot paths
Yet most Rust code still relies on:
Vec,Box, andArceverywhere- Implicit heap allocations
- Allocators optimized for average-case workloads
framealloc makes memory intent explicit and cheap.
Core Concepts
1. Frame-based allocation
The primary allocator is a frame arena:
- Fast bump allocation
- No per-allocation free
- Reset once per frame
use ;
let alloc = new;
alloc.begin_frame;
let tmp = alloc.;
let verts = alloc.;
// All frame allocations are invalid after end_frame
alloc.end_frame;
This model:
- Eliminates fragmentation
- Guarantees O(1) allocation
- Matches how engines actually work
2. Thread-local fast paths
Every thread automatically gets:
- Its own frame arena
- Its own small-object pools
- Zero locks on hot paths
This is always enabled — even in single-threaded programs.
Single-threaded: 1 TLS allocator
Multi-threaded: N TLS allocators
Same API. Same behavior.
No mode switching. No configuration required.
3. Automatic single → multi-thread scaling
framealloc scales automatically when used across threads:
-
Single-threaded usage:
- No mutex contention
- No atomic overhead in hot paths
-
Multi-threaded usage:
- Thread-local allocation remains lock-free
- Shared state is only touched during refills
The user never toggles a "threaded mode".
use SmartAlloc;
use Arc;
let alloc = new;
spawn;
This works out of the box.
4. Allocation by intent
Allocations are categorized by intent, not just size:
| Intent | Method | Behavior |
|---|---|---|
Frame |
frame_alloc::<T>() |
Bump allocation, reset every frame |
Pool |
pool_alloc::<T>() |
Thread-local pooled allocation |
Heap |
heap_alloc::<T>() |
System allocator (large objects) |
This allows:
- Better locality
- Predictable behavior
- Meaningful diagnostics
5. Designed for game engines (and Bevy)
framealloc integrates cleanly with Bevy:
use *;
use SmartAllocPlugin;
This automatically:
- Inserts the allocator as a Bevy resource
- Resets frame arenas at frame boundaries
- Works across Bevy's parallel systems
Inside a system:
No lifetimes exposed. No unsafe in user code. No boilerplate.
What framealloc is not
To set expectations clearly:
❌ Not a global allocator replacement
❌ Not a garbage collector
❌ Not a drop-in replacement for Vec
❌ Not optimized for long-lived arbitrary object graphs
If you need:
- General heap allocation → use the standard allocator
- Reference-counted sharing → use
Arc - Data structures with unknown lifetimes → use
Vec/Box
Use framealloc where lifetime and performance are known.
Architecture Overview
SmartAlloc (Arc)
├── GlobalState (shared)
│ ├── SystemHeap (mutex-protected, large allocations)
│ ├── SlabRegistry (mutex-protected page pools)
│ ├── BudgetManager (optional limits)
│ └── GlobalStats (atomics)
│
└── ThreadLocalState (TLS, per thread)
├── FrameArena (bump allocator, no sync)
├── LocalPools (small-object free lists, no sync)
├── DeferredFreeQueue (lock-free cross-thread frees)
└── ThreadStats (local counters)
Key rule:
Allocation always tries thread-local memory first.
Global synchronization only occurs during:
- Slab refills (rare, batched)
- Large allocations (>4KB default)
- Cross-thread frees (deferred, amortized)
Performance Characteristics
| Operation | Cost | Synchronization |
|---|---|---|
| Frame allocation | O(1) | None |
| Frame reset | O(1) | None |
| Pool allocation | O(1) | None (local hit) |
| Pool refill | O(1) amortized | Mutex (rare) |
| Pool free | O(1) | None |
| Cross-thread free | O(1) | Lock-free queue |
| Large alloc/free | O(1) | Mutex |
This design favors:
- Predictability over throughput
- Cache locality
- Stable frame times
Safety Model
- All unsafe code is isolated inside
allocators/module - Public API is fully safe Rust
- Frame memory is invalidated explicitly at
end_frame() - Debug builds poison freed memory with
0xCDpattern - Optional allocation backtraces for leak detection
Feature Flags
[]
= []
# Use parking_lot for faster mutexes
= ["dep:parking_lot"]
# Bevy integration
= ["dep:bevy_ecs", "dep:bevy_app"]
# Debug features: memory poisoning, allocation backtraces
= ["dep:backtrace"]
Advanced Features
Memory Budgets (Per-Tag Limits)
Track and limit memory usage by subsystem:
use ;
let config = default.with_budgets;
let alloc = new;
// Register budgets for subsystems
let rendering_tag = new;
// Budget manager is accessed through global state
Budget events can trigger callbacks for monitoring:
SoftLimitExceeded- Warning threshold crossedHardLimitExceeded- Allocation may failNewPeak- High water mark updated
Streaming Allocator
For large assets loaded incrementally (textures, meshes, audio):
use ;
let streaming = new; // 64MB budget
// Reserve space for an asset
let id = streaming.reserve.unwrap;
// Load data incrementally
let ptr = streaming.begin_load.unwrap;
// ... write data to ptr ...
streaming.report_progress;
streaming.finish_load;
// Access the data
let data = streaming.access.unwrap;
// Automatic eviction under memory pressure (LRU + priority)
Diagnostics UI Hooks
For integration with imgui, egui, or custom debug UIs:
use ;
let mut hooks = new;
// Register event listeners
hooks.add_listener;
// Get graph data for visualization
let graph_data = hooks.get_memory_graph_data;
Snapshot history provides time-series data for memory graphs.
Handle-Based Allocation
Stable handles that survive memory relocation:
use ;
let allocator = new;
// Allocate and get a handle (not a raw pointer)
let handle: = allocator.alloc.unwrap;
// Resolve handle to pointer when needed
let ptr = allocator.resolve_mut.unwrap;
unsafe
// Pin to prevent relocation during critical sections
allocator.pin;
// ... use raw pointer safely ...
allocator.unpin;
// Defragment memory (relocates unpinned allocations)
let relocations = allocator.defragment;
// Handle remains valid after relocation
let ptr = allocator.resolve.unwrap;
Allocation Groups
Free multiple allocations at once:
use SmartAlloc;
let alloc = with_defaults;
let groups = alloc.groups;
// Create a group for level assets
let level_group = groups.create_group;
// Allocate into the group
groups.alloc_val;
groups.alloc_val;
groups.alloc_val;
// Free everything at once when unloading level
groups.free_group;
Safe Wrapper Types
RAII wrappers for automatic memory management:
use SmartAlloc;
let alloc = with_defaults;
// FrameBox - valid until end_frame()
alloc.begin_frame;
let data = alloc.frame_box.unwrap;
println!; // Deref works
alloc.end_frame;
// PoolBox - auto-freed on drop
// Freed here
// HeapBox - auto-freed on drop
// Freed here
Profiler Integration
Hooks for Tracy, Optick, or custom profilers:
use ;
let mut hooks = new;
hooks.set_callback;
v0.2.0 Features
Frame Phases
Divide frames into named phases for profiling and diagnostics:
alloc.begin_frame;
alloc.begin_phase;
let contacts = alloc.;
// All allocations tracked under "physics"
alloc.end_phase;
alloc.begin_phase;
let verts = alloc.;
alloc.end_phase;
alloc.end_frame;
Features:
- Zero overhead when not using phases
- Nested phase support
- Per-phase allocation statistics
- Integrates with diagnostics and profiler hooks
Use with RAII guard:
// phase ends automatically
Frame Checkpoints
Save and rollback speculative allocations:
alloc.begin_frame;
let checkpoint = alloc.frame_checkpoint;
// Speculative allocations
let result = try_complex_operation;
if result.is_err
alloc.end_frame;
Speculative blocks:
let result = alloc.speculative;
// Automatically rolled back on Err
Use cases:
- Pathfinding with dead-end rollback
- Physics with speculative contacts
- UI layout with try/fail patterns
Frame Collections
Bounded, frame-local collections:
// FrameVec - fixed capacity vector
let mut entities = alloc.;
entities.push;
entities.push;
for entity in entities.iter
// Freed at end_frame()
// FrameMap - fixed capacity hash map
let mut lookup = alloc.;
lookup.insert;
if let Some = lookup.get
Properties:
- Cannot grow beyond initial capacity
- Cannot escape the frame (lifetime-bound)
- Full iterator support
- Familiar API (push, pop, get, iter)
Tagged Allocations
First-class allocation attribution:
alloc.with_tag;
// Check current tag
println!;
println!; // "ai::pathfinding"
Benefits:
- Automatic budget attribution
- Better profiling granularity
- Clearer diagnostics
Scratch Pools
Cross-frame reusable memory:
// Get or create a named pool
let pool = alloc.scratch_pool;
// Allocate (persists across frames)
let nodes = pool.;
// Use across multiple frames...
// Reset when done (e.g., level unload)
pool.reset;
Registry access:
let scratch = alloc.scratch;
// Get stats for all pools
for stat in scratch.stats
// Reset all pools
scratch.reset_all;
Use cases:
- Pathfinding node storage
- Level-specific allocations
- Subsystem scratch memory that outlives frames
v0.3.0: Frame Retention & Promotion
Overview
Frame allocations normally vanish at end_frame(). The retention system lets allocations optionally "escape" to other allocators.
Key principle: This is NOT garbage collection. It's explicit, deterministic post-frame ownership transfer.
Retention Policies
Basic Usage
// Allocate with retention policy
let mut data = alloc.;
data.calculate_paths;
// At frame end, get promoted allocations
let result = alloc.end_frame_with_promotions;
for item in result.promoted
Importance Levels (Semantic Sugar)
For more intuitive usage:
// Usage
let path = alloc.;
let config = alloc.;
Frame Summary Diagnostics
Get detailed statistics about what happened at frame end:
let result = alloc.end_frame_with_promotions;
let summary = result.summary;
println!;
println!;
println!;
println!;
println!;
// Breakdown by failure reason
let failures = &summary.failures_by_reason;
if failures.budget_exceeded > 0
if failures.scratch_pool_full > 0
Integration with Tags
Retained allocations preserve their tag attribution:
alloc.with_tag;
Design Principles
| Principle | Implementation |
|---|---|
| Explicit | Must opt-in per allocation |
| Deterministic | All decisions at end_frame() |
| Bounded | Subject to budgets and limits |
| No Magic | No heuristics or auto-promotion |
When to Use Retention
| Scenario | Recommendation |
|---|---|
| Pathfinding result might be reused | Reusable / PromoteToPool |
| Computed data proved useful | Reusable / PromoteToPool |
| Config loaded during frame | Persistent / PromoteToHeap |
| Subsystem scratch that persists | Scratch("name") |
| Truly temporary data | Ephemeral / Discard (default) |
API Reference
// Allocate with retention
Diagnostics System
framealloc provides allocator-specific diagnostics at build time, compile time, and runtime.
It does not replace Rust compiler warnings — it explains engine-level mistakes.
Diagnostic Codes Reference
All diagnostics use codes for easy searching and documentation:
| Code | Category | Meaning |
|---|---|---|
| FA001 | Frame | Frame allocation used outside active frame |
| FA002 | Frame | Frame memory reference escaped scope |
| FA003 | Frame | Frame arena exhausted |
| FA101 | Bevy | SmartAllocPlugin not registered |
| FA102 | Bevy | Frame hooks not executed this frame |
| FA201 | Thread | Invalid cross-thread memory free |
| FA202 | Thread | Thread-local state accessed before init |
| FA301 | Budget | Global memory budget exceeded |
| FA302 | Budget | Tag-specific budget exceeded |
| FA401 | Handle | Invalid or freed handle accessed |
| FA402 | Stream | Streaming allocator budget exhausted |
| FA901 | Internal | Internal allocator error (report bug) |
Runtime Diagnostics
Diagnostics are emitted to stderr in debug builds:
use ;
// Emit a custom diagnostic
fa_diagnostic!;
// Emit a predefined diagnostic
fa_emit!;
// Emit with context (captures thread, frame number, etc.)
fa_emit_ctx!;
Sample output:
[framealloc][FA001] error: frame allocation used outside an active frame
note: this allocation was requested when no frame was active
help: call alloc.begin_frame() before allocating, or use pool_alloc()/heap_alloc()
Compile-Time Diagnostics
For errors detectable at compile time:
// Hard compiler error with formatted message
fa_compile_error!;
Strict Mode (CI Integration)
Configure diagnostics to panic instead of warn — useful for CI:
use ;
// Panic on any error diagnostic
set_strict_mode;
// Or use a guard for scoped strict mode
// Back to normal behavior
Environment variable:
# In CI
FRAMEALLOC_STRICT=error
# Options: warn, error, warning (panics on warnings too)
Conditional Assertions
Assert conditions with automatic diagnostics:
use fa_assert;
// Emits FA001 if condition is false
fa_assert!;
// With context capture
fa_assert!;
Diagnostic Context
Diagnostics can capture runtime context:
use ;
// Mark that we're in a Bevy app
set_bevy_context;
// Capture current context
let ctx = capture;
println!;
Context includes:
- Whether Bevy integration is active
- Current frame number
- Whether a frame is active
- Thread ID and name
- Whether this is the main thread
Build-Time Diagnostics
The build.rs script provides helpful messages during compilation:
Feature Detection
[framealloc] ℹ️ Bevy integration enabled
[framealloc] Remember to add SmartAllocPlugin to your Bevy App:
[framealloc] app.add_plugins(framealloc::bevy::SmartAllocPlugin::default())
Debug Mode Hints
[framealloc] ℹ️ Debug features enabled
[framealloc] Debug mode provides:
[framealloc] • Memory poisoning (freed memory filled with 0xCD)
[framealloc] • Allocation backtraces (for leak detection)
[framealloc] • Extended validation checks
Release Build Recommendations
[framealloc] ℹ️ Building in release mode
[framealloc] Tip: Consider enabling 'parking_lot' for better mutex performance
Quick Reference (printed during build)
[framealloc] ────────────────────────────────────────
[framealloc] ℹ️ framealloc Quick Reference
[framealloc] ────────────────────────────────────────
[framealloc] Frame allocation (fastest, reset per frame):
[framealloc] alloc.begin_frame();
[framealloc] let data = alloc.frame_box(value);
[framealloc] alloc.end_frame();
Complete Feature Flags
[]
= []
# Use parking_lot for faster mutexes
= ["dep:parking_lot"]
# Bevy integration
= ["dep:bevy_ecs", "dep:bevy_app"]
# Debug features: memory poisoning, allocation backtraces
= ["dep:backtrace"]
# Tracy profiler integration
= ["dep:tracy-client"]
# Nightly: std::alloc::Allocator trait implementations
= []
# Enhanced runtime diagnostics
= []
std::alloc::Allocator Trait (Nightly)
With the nightly feature, use framealloc with standard collections:
use ;
let alloc = with_defaults;
alloc.begin_frame;
// Use with Vec
let frame_alloc = new;
let mut vec: = Vecnew_in;
vec.push;
alloc.end_frame;
Enable with:
= { = "0.1", = ["nightly"] }
Common Patterns
Pattern 1: Game Loop
let alloc = with_defaults;
loop
Pattern 2: Level Loading
let alloc = with_defaults;
let groups = alloc.groups;
// Load level
let level_group = groups.create_group;
for asset in level_assets
// ... play level ...
// Unload - free everything at once
groups.free_group;
Pattern 3: Streaming Assets
let streaming = alloc.streaming;
// Reserve space before loading
let texture_id = streaming.reserve?;
// Load asynchronously
let ptr = streaming.begin_load?;
load_texture_async;
streaming.finish_load;
// Access when ready
if let Some = streaming.access
Pattern 4: Safe Wrappers
let alloc = with_defaults;
// Prefer safe wrappers over raw pointers
let pool_data = alloc.pool_box?; // Auto-freed on drop
let heap_data = alloc.heap_box?; // Auto-freed on drop
alloc.begin_frame;
let frame_data = alloc.frame_box?; // Valid until end_frame
// Use frame_data...
alloc.end_frame;
Troubleshooting
"Frame allocation used outside an active frame" (FA001)
Problem: You called frame_alloc() without begin_frame().
Fix:
alloc.begin_frame; // Add this
let data = alloc.;
// ...
alloc.end_frame;
Or use persistent allocation:
let data = alloc.pool_box; // Lives until dropped
"Bevy plugin missing" (FA101)
Problem: Bevy feature enabled but plugin not added.
Fix:
new
.add_plugins // Add this
.run;
"Frame arena exhausted" (FA003)
Problem: Too many frame allocations for the arena size.
Fix:
let config = default
.with_frame_arena_size; // Increase to 64MB
let alloc = new;
Memory appears corrupted
Debug with poisoning:
= { = "0.1", = ["debug"] }
Freed memory is filled with 0xCD. If you see this pattern, you're using freed memory.
Finding memory leaks
Enable backtraces:
= { = "0.1", = ["debug"] }
// In debug builds, leaked allocations are tracked
let stats = alloc.stats;
println!;
When should I use framealloc?
You should consider framealloc if you are building:
- A game engine
- A renderer
- A physics or simulation engine
- A real-time or embedded system
- A performance-critical tool with frame-like execution
Philosophy
Make memory lifetime explicit.
Make the fast path obvious.
Make the slow path predictable.
framealloc exists to give Rust game developers the same level of control engine developers expect — without sacrificing safety or ergonomics.
License
Licensed under either of:
- Apache License, Version 2.0
- MIT license
at your option.