Stream: compiler development

Topic: wasm memory


view this post on Zulip Niclas Ahden (Aug 15 2026 at 15:09):

Hey! I'm optimizing the performance of Joy and may have gotten faster than Elm in js-framework-benchmark which is super exciting! However, I've run into some issues regarding memory and want to check my current understanding and what's intended behaviour:

  1. Roc applies a 64 MB default initial memory to wasm builds if you don't pass --wasm-memory, both through wasm-ld (--opt=size/speed) and the dev backend's linker. Would it be desirable to have the linker size the memory itself if you don't specify the size? I've submitted a PR: https://github.com/roc-lang/roc/pull/10803

  2. In --opt=speed, the --wasm-memory value is passed to wasm-ld as --initial-memory, which treats it as a fixed number and fails if it's too low. The internal Roc linker in --opt=dev treats it as a floor and happily bumps it up to the app's required level. So the same flag behaves differently between opt levels. What do we want to do here?

Suggestion:

Given the above I believe both opt levels should behave consistently. You could go in a different direction here, e.g. say you want to specify an initial heap size instead of "total" size etc.

  1. Is there/would we want a way to make Roc pass --stack-first to wasm-ld? Would that feature be desirable in Roc's linker? I'm not sure I'd use this if it were available, but it'd be interesting to try.

view this post on Zulip Richard Feldman (Aug 16 2026 at 02:37):

I think we should probably do --stack-first automatically, always - I didn't know that was an option!

view this post on Zulip Niclas Ahden (Aug 16 2026 at 07:40):

Yes, it’s great and should be the default! However, it also increases binary size because:

Even after brotli these can add up and give you 0.5-5% on your bundle size (my best case is an app full of translation strings and little logic). So, if you know you don’t need the safety of —stack-first, it’d be nice to be able to turn it off.

I’d like to port some apps and run full benchmarks with a plethora of config options.

view this post on Zulip Richard Feldman (Aug 16 2026 at 12:57):

hrm, this reveals some problems in how we're compiling to wasm that I wasn't aware of before

view this post on Zulip Richard Feldman (Aug 16 2026 at 12:57):

the challenge is that we have a design constraint of "compiled Roc code does not produce undefined behaivor as long as the host implementation is following the rules"

view this post on Zulip Richard Feldman (Aug 16 2026 at 12:58):

and having just gone down a rabbit hole of stack overflows in wasm, it seems like it is currently impossible to avoid undefined behavior in wasm without a serious runtime performance cost :half_frown:

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:01):

because:

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:02):

the obvious way to fix this is to emit explicit bounds checks around either every stack allocation or every memory address lookup, both of which would be crazy runtime costs to pay

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:03):

the less-obvious, roc-specific way to fix it would be to make sure we never treat null pointers as semantic; that is, never emit an "if this memory address is 0" check (and always detect such things in different ways) - which I think "just" means we miss out on some optimization opportunities

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:04):

(that is, only do that when building for wasm with stack growing towards 0)

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:06):

so in that world, if we want to maintain the guarantee that you can write whatever Roc code you want without introducing undefined behavior, the two options would have to be:

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:07):

so in both of those configurations, you get the "stack increments and decrements are cheap and don't require an explicit bounds check" and also "no undefined behavior from compiled Roc code" but you do of course get the other downsides mentioned there

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:09):

we could in theory say "hey just assume the 0 edge case will never come up, and it's okay if it's UB" but I really don't like the idea of opening the can of worms of "Roc programs actually can produce UB" - at that point we get into territory like "well now if we define integer overflow as UB, we can unlock even more compiler optimizations..." which is a can of worms I'd prefer not to open :sweat_smile:

view this post on Zulip Niclas Ahden (Aug 16 2026 at 13:34):

it will be a pointer to address 0 - which at runtime looks like a null pointer even though it's actually pointing to a valid address

Must null pointers be address 0? Could they be represented by something else so that this issue goes away?

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:36):

hm, actually that could work!

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:36):

like we could use the highest u32 to represent them

view this post on Zulip Richard Feldman (Aug 16 2026 at 13:37):

and then forbid growing the heap that high

view this post on Zulip Niclas Ahden (Aug 16 2026 at 13:37):

Yeah

view this post on Zulip Richard Feldman (Aug 16 2026 at 14:09):

ok so what if, for simplicity, we just said for now we only support growing towards 0 and we do that "redefine null" trick?

view this post on Zulip Richard Feldman (Aug 16 2026 at 14:55):

I totally understand the point about this meaning that static constants now have larger integer addresses, which can increase total binary size, but I'm wondering if that option is actively needed for your use case already, as opposed to something we could explore later while short-term having only the "grow towards zero" option with the UB removed :smile:

view this post on Zulip Richard Feldman (Aug 16 2026 at 14:55):

(and either way, to your point about both min and max memory making sense to have as CLI flags, that seems fine :+1:)

view this post on Zulip Niclas Ahden (Aug 16 2026 at 15:30):

I think that sounds like a reasonable thing to do now

if you put the stack at the beginning of the memory region and have stack addresses decrease with new allocations, then you get the above performance penalty, plus:

What performance penalty are you referring to here?

if that option is actively needed for your use case already, as opposed to something we could explore later while short-term having only the "grow towards zero" option with the UB removed

Indeed it's not needed, just a potential nice-to-have which I'd have to evaluate the value of anyway.

However, just to clarify, the current situation is workable so there's no stress to get something out quick. I'm doing this as "good enough for now":

## Stack size
# We are using a fixed stack size of 1 MiB, which lets us have roughly
# 10 000 levels of HTML nesting in the first render and 4 000 levels in
# subsequent re-renders. Ergo it _should_ not overflow for the vast majority
# of apps, but _could_ for severe outliers.
#
# We can't use `--stack-first`, because Roc does not currently expose that to
# us, which means that if we overflow the stack we'll be corrupting the app's
# static data section.
#
# To manage that danger we have two countermeasures:
#
# a) A canary memory band at the stack floor. The host scans it after every
#    dispatch and fails loudly if any word was overwritten. Nothing fires at
#    write time, so detection waits for the dispatch boundary, and a single
#    frame larger than the band can step over it unseen.
#
# b) A stack pointer check in `roc_alloc` which traps as soon as the pointer
#    is below the floor. Every allocation goes through the host allocator, also
#    the ones Roc code makes, and render recursion allocates on nearly every
#    level, so this fires mid-dispatch and catches frames of any size. Its
#    blind spot is recursion that never allocates, which is covered by a).
#
# In order to have these checks we must keep the hardcoded stack size in
# sync in two spots: the `--wasm-stack-size` flag here and `STACK_SIZE` in
# host/host.rs.
#
# The engine's native call stack is a separate limit that backstops both.

... which, while not being the best long-term solution, seems to work really well and according to my testing gives:

If we can get rid of the UB on top of this I'm super happy ;D

view this post on Zulip Richard Feldman (Aug 16 2026 at 16:21):

nice, yeah this design should fix both the UB and also mean you can replace that defensive scanning with just letting wasm trap normally for out-of-bounds memory reads (and then optionally investigating after the fact if it was a stack overflow, in case you want to handle that differently)

view this post on Zulip Niclas Ahden (Aug 16 2026 at 16:23):

Indeed, and regarding the perf penalty you mentioned, did you mean bundle size penalty perhaps? I can't think of any other penalty :thinking:

view this post on Zulip Richard Feldman (Aug 16 2026 at 16:23):

regarding what I meant about "the above performance potentially" - I think I previously had a bullet point above there which I ended up deleting because I realized it wasn't a necessary performance cost after all, so never mind that reference :smile:

view this post on Zulip Niclas Ahden (Aug 16 2026 at 16:24):

Cool!

view this post on Zulip Niclas Ahden (Aug 16 2026 at 16:24):

Then this is just essentially --stack-first with a fix for that 0 UB, which means we're aligned with what other wasm-people are doing :thumbs_up:

view this post on Zulip Richard Feldman (Aug 16 2026 at 17:42):

well except that I don't think anyone else is attempting to prevent the UB :sweat_smile:

view this post on Zulip Niclas Ahden (Aug 16 2026 at 19:10):

That's probably very true :sweat_smile:


Last updated: Sep 03 2026 at 15:16 UTC