cranelift: drop the call_indirect null check when no table slot can be null#13910
Draft
matthargett wants to merge 5 commits into
Draft
cranelift: drop the call_indirect null check when no table slot can be null#13910matthargett wants to merge 5 commits into
matthargett wants to merge 5 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.
…e null When a table's static image (immutable, fixed-size, precomputed from element segments) covers every slot and no covered slot is null, an in-bounds index can never load a null entry, so the null branch on the static-match path is dropped along with the signature check. Module::table_never_contains_null owns the whole proof — immutability, full coverage of the table's size, and non-null entries — rather than splitting it between the helper and its caller. Coverage matters: slots past the end of the image are null at runtime, so an image shorter than the table's size disqualifies it (the elem-covers-a-prefix and table-with-a-hole cases in tests/misc_testsuite/immutable-table- call-indirect.wast pin the runtime behavior for both). On native backends with signals-based traps the null check is already just trap metadata on the code-pointer load, so this mostly matters for configurations that emit explicit checks — interpreters (Pulley) and signals_based_traps(false) embeddings — where it removes a dispatched branch per call.
matthargett
force-pushed
the
call-indirect-null-elision
branch
from
July 20, 2026 21:43
871f584 to
b653355
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
After the table-entry load,
call_indirectchecks that the loaded function reference isn't null before calling through it. This PR drops that check when the table's static image proves no in-bounds slot can ever be null.The proof lives in a new
Module::table_never_contains_nullhelper and requires, on top of the static image from the sig-check PR (immutable table, fully precomputed contents, no element segments deferred to instantiation):The bounds check is unaffected: an out-of-bounds index still traps as before.
Where this matters
Native backends with signals-based traps already get the null check nearly for free — it's trap metadata on the code-pointer load, not an extra instruction — so this mostly changes configurations that emit explicit checks: interpreters (Pulley, the motivating case) and
signals_based_traps(false)embeddings, where it removes a dispatched branch per call.Tests
*.wastcoverage for the shapes that must keep the check: a uniform table with a null hole, element segments covering only a prefix of the table, a deferred null write over a slot the static image filled, andtable.setof null followed by a dispatch that must trap