Skip to content

feat(cranelift): emulate the rwasm stack limits in compiled code - #13

Merged
dmitry123 merged 3 commits into
v45-patchedfrom
feat/rwasm-stack-limits
Sep 23, 2026
Merged

dmitry123 merged 3 commits into
v45-patchedfrom
feat/rwasm-stack-limits

Conversation

@dmitry123

@dmitry123 dmitry123 commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Summary

The rwasm VM stops an execution at N_MAX_RECURSION_DEPTH frames or when the frames on the call chain exceed its value-stack window. Wasmtime only ran out of native stack, so a recursion 1024 deep or two nested frames of 5000 slots ran here and trapped StackOverflow on rwasm. This PR makes the Cranelift backend trap exactly where the rwasm VM does, so both engines agree at every boundary.

Stacked on #12 (bulk fuel, 45.0.0-rwasm.3); the rwasm counterpart is fluentlabs-xyz/rwasm#213, which pins 45.0.0-rwasm.3 and cannot build until this is released.

How it works

Config::rwasm_stack_limits(Some(RwasmStackLimits { max_call_depth, max_stack_slots, code_snippets })) makes compiled code keep two counters in VMStoreContext, packed into one u64 after execution_version (rwasm_stack: call depth in the high half, frame base in the low half; the offset test covers the new layout):

  • Function prologue: after the entry fuel charge, loads both counters and traps StackOverflow when base + params + height > max_stack_slots, like rwasm's StackCheck. height is the function's entry in the module's rwasm.frames custom section (RWASM_FRAMES_SECTION, one little-endian u32 per function, imports first), written by the rwasm translator, which alone knows the temporaries its lowering pushes. A module without the section, or with the wrong length, fails to compile on such an engine.
  • Call sites (call, call_indirect, call_ref): trap when depth >= max_call_depth (one compare of the packed word), then publish the callee's counters with one add and one store: depth plus one, and the callee's frame base (this function's parameters, locals and the operand slots below the arguments, computed from the validator's operand stack). Nothing is restored after the call: every reader runs right after the call site that wrote the counters, and the caller keeps its own in a register. For call_indirect the check sits after the load that traps on a null entry and before the signature compare, for call_ref after an explicit null check, the order rwasm traps in. Tail calls keep both counters, like ReturnCallInternal.
  • Snippet operators: with code_snippets, the i64 operators rwasm runs in a hidden CallInternal frame (rwasm_snippet_frames: one frame, two for div/rem which call the shared UDivMod64 core) check that frame's depth and height before the operator runs. The table mirrors rwasm's MSH_* constants; rwasm pins it to its snippet definitions.
  • Host functions: the embedder checks the import trampoline's frame itself (rwasm's trampoline has a frame of its own), reading the counters through Store::rwasm_stack_counters / Caller::rwasm_stack_counters, and resets them with Store::set_rwasm_stack_counters before each call into the guest.

Without rwasm_stack_limits nothing changes: the counters stay unused and no code is emitted.

Cost

Measured on Apple Silicon at OptLevel::Speed, best of 5, against the same build with the emulation switched off:

workload off on
recursive fib(30) through call (1.6M calls) 4.9-5.1 ms 5.2 ms
recursive fib(30) through call_indirect 5.1 ms 5.4 ms
10M iterations of i64.mul + i64.div_u 17.6 ms 18.2 ms

A call-only recursion pays 3-5% for the depth compare, the add and the store per call plus the load and compare per prologue; arithmetic loops are within noise. The first version, with two stores before and two after every call, cost about 30% on the same recursion.

Verification

@dmitry123

Copy link
Copy Markdown
Member Author

Applied the same fix to RwasmStackLimits (9648c93), on top of a merge of the updated #12 (6a40ea0):

  • RwasmStackLimits now derives Hash and serde. It is hashed into HashedEngineCompileEnv next to rwasm_bulk_fuel, recorded in the precompiled Metadata, and checked on load, including on cache hits.

  • New tests in engine/serialization.rs:

    • precompile_compatibility_key_accounts_for_rwasm_stack_limits: varies each field
    • cache_accounts_for_rwasm_stack_limits
    • precompiled_module_requires_the_same_rwasm_stack_limits: a 5×5 matrix using a module that carries rwasm.frames

    All three fail without the fix. One of the failing cases is an artifact compiled without the stack checks deserializing into an engine that enforces them.

Local results (aarch64-apple-darwin), cargo test -p wasmtime-rwasm -p wasmtime-environ-rwasm --lib:

  • wasmtime-rwasm: 157 passed, 1 ignored
  • wasmtime-environ-rwasm: 44 passed

@hedwig0x hedwig0x left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed stack-limit emulation and the cache/precompile compatibility fix. Local wasmtime-rwasm test suite passes (173/173).

Base automatically changed from fix/dynamic-bulk-fuel to v45-patched September 23, 2026 13:38
The rwasm VM stops an execution at `N_MAX_RECURSION_DEPTH` frames or when
the frames on the call chain exceed its value-stack window. Wasmtime only
ran out of native stack, so a recursion 1024 deep or two nested frames of
5000 slots ran here and trapped `StackOverflow` on rwasm.

`Config::rwasm_stack_limits` now makes Cranelift keep both counters in
`VMStoreContext` (`rwasm_call_depth`, `rwasm_stack_slots`, exposed through
`Store::rwasm_stack_counters`) and trap exactly where the rwasm VM does:

- a function prologue checks its frame base plus its parameters and the
  height the rwasm translator recorded in the module's `rwasm.frames`
  custom section, like rwasm's `StackCheck`;
- a call site traps when the call stack is full, publishes the callee's
  depth and frame base and restores its own after the call; for
  `call_indirect` and `call_ref` the depth check sits between the null
  check and the signature check, as on rwasm; tail calls keep both
  counters like `ReturnCallInternal`;
- with `code_snippets`, the `i64` operators rwasm runs in a hidden snippet
  frame (`rwasm_snippet_frames`) check that frame's depth and height
  before the operator runs.

Host functions are checked by the embedder, whose import trampoline has a
frame of its own on rwasm; it reads the counters from the `Caller`.
…call

Measured on a call-only recursion (fib(30), 1.6M calls) the emulation cost
about 30% with two stores before every call and two more after it. The
readers of the counters, a callee's prologue and the host trampolines, all
run right after the call site that wrote them, and a caller keeps its own
depth and base in registers, so the restore after the call is dropped.

The two counters are packed into one `u64` in `VMStoreContext`
(`rwasm_stack`: call depth in the high half, frame base in the low half):
a call site publishes both with one add and one store, and the depth check
is one compare of the whole word against the limit shifted into the high
half. Neither half can carry into the other, as both stay below their
limits. The window check compares the base against the room the frame
leaves instead of widening and adding. For `call_indirect` the check sits
after the load that traps on a null entry and before the signature
compare, so the trap order stays rwasm's without an extra null check.

Cost is now 3-5% on the call-only recursion, direct or indirect, and within
noise on arithmetic loops. `Store::rwasm_stack_counters` and the
`RwasmStackCounters` API are unchanged.
…k limits

`Config::rwasm_stack_limits` changes the generated code (the call-site
depth checks, the prologue frame checks and the snippet frames) but, like
the bulk fuel setting in #12, was part of neither
`HashedEngineCompileEnv` nor a precompiled artifact's metadata. An
artifact compiled without the stack checks could therefore be served from
the cache to, or deserialized into, an engine that enforces them.

`RwasmStackLimits` is now `Hash` and serializable, hashed into the
compatibility hash next to `rwasm_bulk_fuel`, recorded in `Metadata` and
checked on load. The tests mirror the bulk fuel ones over five settings
that vary each field, using a module that carries `rwasm.frames`; all
three fail without the fix.
@dmitry123
dmitry123 force-pushed the feat/rwasm-stack-limits branch from 9648c93 to 5583bd7 Compare September 23, 2026 13:38
@dmitry123
dmitry123 merged commit d0ea7f7 into v45-patched Sep 23, 2026
2 checks passed
@dmitry123
dmitry123 deleted the feat/rwasm-stack-limits branch September 23, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants