Skip to content

cranelift: elide the call_indirect signature check on uniform immutable funcref tables#13909

Draft
matthargett wants to merge 4 commits into
bytecodealliance:mainfrom
rebeckerspecialties:call-indirect-sig-elision
Draft

cranelift: elide the call_indirect signature check on uniform immutable funcref tables#13909
matthargett wants to merge 4 commits into
bytecodealliance:mainfrom
rebeckerspecialties:call-indirect-sig-elision

Conversation

@matthargett

@matthargett matthargett commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Stacked on #13445 — the first three commits here are that PR (the table-mutability analysis and the constant-bound change). Only the last commit is new; happy to rebase once #13445 lands.

What this does

WebAssembly's call_indirect through an untyped funcref table performs a runtime signature check on every call: load the callee's type id, compare it against the type the call site declared, trap on mismatch. This PR drops that check when it can be decided at compile time that it never fails: every function the table can ever contain has exactly the signature the call site expects.

That's only knowable when the table's contents are fixed for the lifetime of every instance, so the proof has three parts, all owned by a new Module::static_funcref_image helper:

  1. the table is immutable in the cranelift: per-table mutability tracking + call_indirect elisions on immutable funcref tables #13445 sense — defined in this module, not exported, and no instruction can write to it;
  2. its initial contents were fully precomputed from element segments into the static image that finalize_table_init already builds; and
  3. no element segment was deferred to instantiation time. finalize_table_init folds segments into the image only up to the first one it can't apply statically (a dynamic offset or expressions-form elements); anything after that is applied when the instance is created, so the image alone doesn't describe the table's runtime contents. This is the soundness bug from the earlier combined PR mentioned in the cranelift: per-table mutability tracking + call_indirect elisions on immutable funcref tables #13445 thread: without this condition, a deferred entry with a different signature would be reachable through an elided check. Regression tests for it are at all three levels (runtime *.wast, the analysis unit tests, and the condition living inside the helper so no call site can skip it).

When the proof holds, the call site takes the same StaticMatch path that typed-funcref tables already use. Null slots keep their check — a null entry still traps as before.

Who has this table shape

Uniform-signature tables are rare in big mixed C/C++ modules (an Emscripten sqlite3 build has ~25 signatures across ~620 table entries) but common in single-language toolchain output: in my benchmark corpus, 5 of the 8 table-bearing modules are uniform — both AssemblyScript builds, both Porffor builds (Porffor routes every indirect call through one calling-convention signature by design), and the xmrsplayer Rust library. tests/disas/readonly-funcrefs.wat — the tracking test for exactly this never-written-table pattern (#8195) — now shows the check gone. Note that I brought AssemblyScript and Porffor into the solve matrix for this feature by request of the wasmtime team in the chat.

On native backends this removes a load, compare, and trap per dispatch. On Pulley it removes interpreted dispatches per call, which is the motivating deployment scenario for me (App Store platforms, where JIT isn't an option).

Tests

  • new disas pair: the uniform immutable table loses the check; the identical module with one table.set keeps it
  • tests/misc_testsuite/immutable-table-call-indirect.wast runtime coverage, including the deferred-segment cases (wrong-signature deferred entry still traps; deferred null write still traps) and mutated-table counterparts where nothing may be elided
  • crates/environ/tests/table_mutability.rs unit tests for the image conditions
  • indirect-call-no-caching.wat gets a table.set added to its module so it keeps demonstrating the full non-cached dispatch sequence it was written to pin

Add a serialized `mutated_tables` set on `Module` populated during
translation: imported and exported tables are pre-marked (the embedder,
or another module importing the export, can write them through the
public API), then one decode pass over the code section marks the
destination of every table-writing instruction — table.set, table.fill,
table.grow, table.copy (destination only), table.init, and the
shared-everything-threads atomic table writes. No module containing the
atomic forms can currently be compiled (the cranelift translator
rejects that proposal), but the scan decodes every operator, so
matching them now keeps the analysis from going silently stale if the
proposal is implemented. Active element segments are initial state, not
mutation; elem.drop writes no table.

The pass is skipped when a module has no tables (10 of the 18 modules
in the benchmark corpus that motivated this) or when every table is
pre-marked (e.g. Emscripten output, which exports its function table).
Bodies are scanned with the `VisitOperator` API; with the new
`parallel` feature (wired into wasmtime's `parallel-compilation`) they
run on the rayon pool, and the serial fallback stops early once every
table is marked. Forcing the walk over an 833 KiB Emscripten sqlite3
module measures ~4 ms serial / ~0.6 ms parallel; in a default build
that module takes the skip path instead.

Consumers arrive separately, starting with a constant table bound for
tables that can never grow. `Module::table_is_immutable` documents the
contract they may rely on; because they use it for correctness, a false
positive here is a soundness bug, not a missed optimization. Bodies are
unvalidated when the scan runs, so out-of-range destination indices are
dropped rather than sizing any per-table structure from unvalidated
input (the module is rejected later by real validation).
`make_table_base_bound` already treats `min == max` tables as static.
Extend that to tables translation proved immutable: their size is `min`
forever, so the per-call `current_elements` load disappears and the
bounds check compares against a constant. Imported and exported tables
never qualify through the new path (the host can grow them); they are
pre-marked mutated by the analysis, so only the `min == max` rule can
apply to them, as before.

The new disas test pins the shape for a `min < max` table that nothing
grows. The three regenerated goldens show the same folding kicking in
for table-init startup functions: their bound loads and spectre guards
constant-fold away since the tables they initialize can't have been
resized before startup runs.
…tables

Two wast files pinning runtime behavior for the table shapes the
immutable-table optimizations care about, on both sides of the line.

Immutable side: mixed-signature table with a null hole, uniform table
fully initialized, uniform table with a hole, element segments covering
only a prefix, a populated min<max table nothing grows (indices between
the covered prefix and min hit null; indices in [min, max) are out of
bounds), an empty declared-growable table, and tables whose element
segments are only partially applicable as a static image (an
expressions-form segment defers to instantiation) — a deferred entry
with a different signature must still trip the signature check, and a
deferred null write must still trip the null check, even though neither
is visible in the compile-time image.

Mutated side (where the optimizations must not fire): table.grow then
dispatch into the grown region succeeds and the bound tracks the grown
size; table.set of a null entry then dispatch traps; and an exported
table written by a second module through its import — the value change
is observed at a constant-index call site, and wrong-signature and null
writes still trap.

These are pure behavior tests: they hold on any correct configuration,
with or without the optimizations, on every engine config the wast
runner covers.
…le tables

When an untyped funcref table's contents are provably static for the
lifetime of every instance and every non-null entry has the signature
the call site expects, the runtime signature check can never fail and
is dropped the same way as for typed-funcref tables — removing the
sig-id load, compare, and trap from every dispatch through the table.
Null slots keep their check: may_be_null is inherited from the table
type, so a null entry still traps.

Module::static_funcref_image owns the whole proof. It refuses tables
that are mutable, imported, or exported, tables without a precomputed
image — and tables targeted by element segments that
finalize_table_init could not fold statically (dynamic offset or
expressions-form elements). Those segments are applied at
instantiation, after the image, so the image alone does not describe
such a table's runtime contents; without this last condition a
deferred entry with a different signature would dodge an elided check.
The new deferred-segment cases in immutable-table-call-indirect.wast
and crates/environ/tests/table_mutability.rs pin exactly that.

Uniform-signature tables are rare in large mixed C/C++ modules but
common in single-language toolchain output: AssemblyScript and Porffor
(which funnels every indirect call through one calling-convention
signature by design) both produce them, as do small Rust libraries.

Two pre-existing expectations change. readonly-funcrefs.wat — the
tracking test for exactly this never-written-funcref-table pattern
(bytecodealliance#8195) — now shows the signature check gone. indirect-call-no-caching
gets a table.set mutator added to its module so it keeps demonstrating
the full non-cached dispatch sequence it exists to pin, rather than
being silently rewritten by an unrelated optimization. The new disas
pair pins both sides directly: the uniform immutable table loses the
check; the identical module with one table.set keeps it.
@matthargett
matthargett force-pushed the call-indirect-sig-elision branch from 2a617a7 to 1f31611 Compare July 20, 2026 21:42
@github-actions github-actions Bot added the wasmtime:api Related to the API of the `wasmtime` crate itself label Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wasmtime:api Related to the API of the `wasmtime` crate itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant