MoonBit memory profiling with moon-pprof
Find allocation hotspots in MoonBit code by building a small wasm entry that
exercises the hot path, profiling it with moon-pprof memprofile, and reading
the per-site allocation summary. This is how the HTML parser allocation wins
(#304: −71% parse allocations) were found.
moon-pprof: https://github.com/mizchi/moon-pprof
When to use
- "memory profile / allocation profile this", "reduce allocations", "why is GC hot", "profile the parser/cascade/layout".
- After a CPU/time finding, to see whether allocation is the cause.
- The win is usually wasm-specific:
Stringop_get(s[i]), per-charStringBuilder.write_char,Some(...)inpeek/consume, and per-elementMap/Arrayallocation dominate. JS-target benches often won't show it (JS string indexing doesn't allocate), so profile wasm.
One-time setup
# moon-pprof needs protoc + rustc 1.80+
apt-get install -y protobuf-compiler # if `protoc` is missing
cargo install moon-pprof --locked # ~5 min; binary lands in ~/.cargo/bin
export PATH="$HOME/.cargo/bin:$HOME/.moon/bin:/root/.moon/bin:$PATH"
Workflow
1. Add a temporary wasm main that drives the hot path
Create a throwaway package inside the module you're profiling (so imports
resolve locally), e.g. dom/profmain/:
dom/profmain/moon.pkg
import { "mizchi/crater-dom/html" }
options( "is-main": true )
dom/profmain/main.mbt — generate representative input and run the hot path
once over a small workload (instrumented wasm is slow). Reference the
result so it isn't optimised away:
fn main {
let html = gen_attr_heavy_html(200) // ~200 elements is plenty
let doc = @html.parse_document(html)
println("children=\{doc.root.children.length()}")
}
Make the input shaped like the real concern (e.g. inline style=/class= on
every element for parser work), not bare tags.
2. Build to wasm (NOT wasm-gc, NOT release)
cd <module> # e.g. cd dom
moon build --target wasm
# artifact: _build/wasm/debug/build/<module-name>/<pkg>/<pkg>.wasm
- Use debug: release strips the alloc-hook sites → "no instrumentation sites found in this wasm".
- Use
--target wasm(linear memory):memprofilehooksmoonbit.malloc.
3. Profile and summarise
WASM=_build/wasm/debug/build/mizchi/crater-dom/profmain/profmain.wasm
timeout 110 moon-pprof memprofile "$WASM" --out /tmp/prof.pb.gz # keep workload small or it times out
moon-pprof summary /tmp/prof.pb.gz # top sites by bytes + alloc count
moon-pprof summary --diff before.pb.gz after.pb.gz # compare before/after a fix
summary reports Total: <bytes> across N sites, Total allocations: M, and
top sites with bytes, %, allocs, and demangled function name.
4. Read the profile
- Sort by alloc count, not just bytes: a site with many tiny (~12B) allocs is GC pressure and usually a per-char/per-iteration bug.
- Names are demangled, e.g.
mizchi::crater_2ddom::html::find__substring(__=_),moonbit.unsafe_make_string_raw(a realStringallocation),moonbit.ref_array_make_raw,Map::set__with__hash. unsafe_make_string_rawwith few large allocs is usually necessary output (the parsed strings). Hunt the many-small-allocs sites instead.
5. Confirm attribution before fixing (important)
moon-pprof's site can mis-attribute (inlining). Stub the suspect and re-profile to confirm the allocs actually move:
// temporarily: let resource_html = html // was strip_noscript_blocks(html)
If the suspect's allocs vanish from summary, it's real. Then fix and re-profile
to measure.
6. Fix patterns (wasm allocation)
- Full-document
s[i]char scans → nativeString::contains/String::find/String::rev_find(allocation-free), or scan aStringView. - Per-char
StringBuildervalue building → scan an index range, then one slice:input[start:end].to_owned()(StringView → owned String; not.to_string(), which is deprecated for views). peek/consumereturningChar?per char → index loop withinput[p].to_int()/view.unsafe_charcode_at(p)(noOption).- Skip redundant whole-string passes (lowercasing + rebuilding when nothing matches): early-return the input unchanged.
7. Clean up
Remove the throwaway profmain/ package and _build artifacts; ship only the
library change. Keep before/after numbers from moon-pprof summary in the
commit/PR body.
CPU profiling (samply / pprof)
For time (not allocation): moon-pprof profile <wasm> runs wasm under wasmtime +
GuestProfiler and emits pprof; moon-pprof bench does baseline/patched
comparison across backends. samply consumes the Firefox-profiler format that
moon-pprof can convert. Prefer the JS bench harness (moon bench --package ... --target js --file X.mbt) for quick wall-clock numbers, and reserve
moon-pprof for allocation attribution.
Gotchas
- JS-target benches may show no change for a wasm allocation fix — that's expected; report it honestly and note the win is wasm-specific.
- Instrumented wasm is ~2–5× slower; shrink the workload (one pass, a few
hundred elements) so
memprofilefinishes well under the timeout. gc-alloc-sites=0in the memprofile log is normal for--target wasm(it wrapsmoonbit.malloc, not GC sites).