Profiling Allocation Hot Spots in a Wasm Module
This guide finds where a module’s allocations come from — not whether memory leaks, but which code paths churn it — and covers the fixes that make the question moot: reusing buffers, sizing collections up front, and moving short-lived data onto an arena.
Allocation churn matters even when nothing leaks. Every allocation and free is work, fragmentation comes from mixed sizes and lifetimes, and a hot loop that allocates once per iteration will be dominated by the allocator rather than by its own arithmetic. On a boundary where you are already paying for crossings, that is an avoidable second tax.
Prerequisites
- [ ] A build you can add a custom global allocator to
- [ ] A representative workload — synthetic input allocates differently
- [ ] Somewhere to read the numbers from: an export the host can call
- [ ] A willingness to act on the result; profiling without a change is just curiosity
Step-by-step procedure
1. Bucket allocations by size
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
static BUCKETS: [AtomicUsize; 4] = [
AtomicUsize::new(0), AtomicUsize::new(0),
AtomicUsize::new(0), AtomicUsize::new(0),
];
fn bucket(size: usize) -> usize {
match size {
0..=64 => 0,
65..=1024 => 1,
1025..=65536 => 2,
_ => 3,
}
}
pub struct Profiling;
unsafe impl GlobalAlloc for Profiling {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
BUCKETS[bucket(l.size())].fetch_add(1, Relaxed);
System.alloc(l)
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
}
#[global_allocator]
static A: Profiling = Profiling;
#[no_mangle]
pub extern "C" fn alloc_count(bucket: u32) -> u32 {
BUCKETS[bucket as usize].load(Relaxed) as u32
}
Buckets rather than backtraces, deliberately: the cost is a single atomic increment, so the profile can run on a realistic workload without distorting it.
2. Read the profile after a workload
await runWorkload();
console.table([0, 1, 2, 3].map((b) => ({
bucket: ["≤64B", "≤1KB", "≤64KB", ">64KB"][b],
count: instance.exports.alloc_count(b),
})));
3. Narrow with feature-gated counters
Once a bucket dominates, add named counters at the suspected sites rather than a general backtrace mechanism:
#[cfg(feature = "alloc-profile")]
macro_rules! note { ($name:expr) => { SITE_COUNTS.entry($name).add(1) } }
4. Fix by removing the allocation
The three fixes that cover most cases:
// 1. reuse a scratch buffer across calls
pub struct Engine { scratch: Vec<f32> }
impl Engine {
pub fn process(&mut self, input: &[f32]) -> &[f32] {
self.scratch.clear(); // keeps the capacity
self.scratch.extend(input.iter().map(|x| x * 2.0));
&self.scratch
}
}
// 2. size collections up front
let mut out = Vec::with_capacity(input.len()); // no repeated reallocation
// 3. avoid per-item boxing entirely
let items: Vec<Item> = ...; // not Vec<Box<Item>>
5. Re-profile and compare
The bucket that dominated should collapse. If it did not, the site you changed was not the one responsible — which is exactly what the counters exist to tell you.
Expected output
// before
┌─────────┬────────┬─────────┐
│ (index) │ bucket │ count │
├─────────┼────────┼─────────┤
│ 0 │ '≤64B' │ 1204832 │
│ 1 │ '≤1KB' │ 18240 │
│ 2 │'≤64KB' │ 312 │
│ 3 │'>64KB' │ 9 │
└─────────┴────────┴─────────┘
// after reusing the scratch buffer and pre-sizing the output
│ 0 │ '≤64B' │ 2048 │
│ 1 │ '≤1KB' │ 96 │
The top bucket falling by three orders of magnitude is the shape you want. It also usually shows up as a throughput improvement — allocation was not free, and 1.2 million of them per workload was a meaningful fraction of the runtime.
Gotchas
The profiling allocator changes the behaviour you are measuring. An atomic increment per allocation is small but not free, and it can hide a marginal difference. Keep the instrumentation behind a feature so the shipped build is unaffected.
Counts look enormous but memory is flat. That is churn without a leak: allocate, free, repeat. It costs time and causes fragmentation, but it is a different problem from growth — do not conflate them.
Clearing a Vec does not free it. clear() keeps the capacity, which is exactly what you want for
reuse. If you actually intended to release it, that needs shrink_to_fit or a drop.
Pre-sizing with the wrong estimate is worse than not pre-sizing. Reserving far too much wastes
memory permanently, since linear memory never shrinks. Use a measured typical size, not a worst case.
The hot site is inside a dependency. Bucket counts point at a size class rather than a line, and if the allocations come from a library the fix is usually to call it differently — batching, or a different API — rather than to change it.
Performance note
The relationship between allocation count and runtime is stronger in WebAssembly than many people expect. The allocator is compiled into your module and runs as ordinary Wasm code, without any operating system fast path, so a million small allocations is a million executions of a non-trivial function. Removing them frequently produces a larger speedup than optimising the arithmetic around them.
The corollary is that the arena pattern is unusually attractive here. Where a phase of work has uniform lifetimes — a frame, a request, a parse — a bump allocator that resets at the boundary removes both the allocation cost and the fragmentation risk in one change, and it is a few dozen lines. See implementing a bump allocator in Wasm.
The arena pattern, applied
When the profile shows a dominant bucket of small, short-lived allocations, an arena usually removes the whole bucket rather than shrinking it. The structure is a scratch allocator reset at a natural boundary — a frame, a request, a parse — so individual frees disappear entirely:
pub struct Arena { buf: Vec<u8>, head: usize }
impl Arena {
pub fn with_capacity(n: usize) -> Self { Self { buf: vec![0; n], head: 0 } }
pub fn alloc(&mut self, len: usize, align: usize) -> &mut [u8] {
let start = (self.head + align - 1) & !(align - 1);
let end = start + len;
assert!(end <= self.buf.len(), "arena exhausted");
self.head = end;
&mut self.buf[start..end]
}
pub fn reset(&mut self) { self.head = 0; } // frees everything, in O(1)
}
The constraint is uniform lifetimes: everything allocated between two resets must die at the same point. That fits per-frame and per-request work naturally and fits long-lived caches not at all, so the usual arrangement is an arena for the transient data and the default allocator for anything that outlives the boundary.
The gain is larger than it looks on paper. A million small allocations become a million pointer increments with no free list to maintain, no fragmentation to accumulate, and no per-allocation bookkeeping in the binary. On an allocation-heavy kernel that has repeatedly been worth more than the arithmetic optimisations around it.
Knowing when to stop
Allocation profiling has a natural end point, and recognising it saves time. Once the dominant bucket has collapsed and the remaining allocations are the deliberate ones — a few large buffers, a handful of long-lived structures — further work has sharply diminishing returns.
Two signals say you are there. The bucket histogram becomes flat rather than dominated by one class, meaning no single pattern is responsible any more. And the throughput improvement from the last change was marginal, meaning the allocator is no longer a significant share of the runtime.
At that point the profitable work moves elsewhere: to the algorithm, to the data layout, or to the boundary crossings — whichever the profiler now says dominates. Allocation work is unusually satisfying because the wins are large and mechanical, which makes it easy to keep going past the point where it matters. The histogram is the check against that.
Frequently Asked Questions
Can I get real backtraces for allocations? With effort — a debug build, a shadow stack of site identifiers — but the cost is high and buckets plus targeted counters usually answer the question faster.
Does this apply to C and C++ modules too?
Yes. Wrap malloc and free with counting versions, or link an instrumented allocator. The analysis is
identical.
Is a custom allocator worth it just for profiling? Yes, temporarily. It is twenty lines, it is feature-gated, and it converts a vague performance suspicion into a number.
Should the buckets be tuned to my workload? Probably. The four ranges suggested here are a starting point; a module whose allocations are mostly between 100 and 300 bytes will see everything land in one bucket and learn nothing. Splitting that range into three costs nothing and makes the histogram informative again.
Does allocation count matter more than total bytes? For throughput, usually yes — the per-call cost of the allocator dominates for small allocations, so a million tiny allocations cost far more time than a handful of large ones totalling the same bytes. For memory footprint the reverse holds. Tracking both is a few extra counters and avoids optimising the wrong one.
Should the profiling allocator ship? No — feature-gate it so the release build uses the plain allocator.
Related
- Memory profiling & leak detection — the surrounding workflow.
- Linear memory management & allocators — choosing the allocator that suits the pattern you found.
- Finding Wasm memory leaks in the browser — the related but distinct question of retention.
- Wasm performance benchmarking — measuring the throughput change the fix produced.
← Back to Memory Profiling & Leak Detection