cranelift: elide the call_indirect signature check on uniform immutable funcref tables#13909
Draft
matthargett wants to merge 4 commits into
Draft
cranelift: elide the call_indirect signature check on uniform immutable funcref tables#13909matthargett wants to merge 4 commits into
matthargett wants to merge 4 commits into
Conversation
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
force-pushed
the
call-indirect-sig-elision
branch
from
July 20, 2026 21:42
2a617a7 to
1f31611
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
WebAssembly's
call_indirectthrough an untypedfuncreftable 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_imagehelper:finalize_table_initalready builds; andfinalize_table_initfolds 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
StaticMatchpath 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
table.setkeeps ittests/misc_testsuite/immutable-table-call-indirect.wastruntime 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 elidedcrates/environ/tests/table_mutability.rsunit tests for the image conditionsindirect-call-no-caching.watgets atable.setadded to its module so it keeps demonstrating the full non-cached dispatch sequence it was written to pin