Skip to content

cranelift: drop the call_indirect null check when no table slot can be null#13910

Draft
matthargett wants to merge 5 commits into
bytecodealliance:mainfrom
rebeckerspecialties:call-indirect-null-elision
Draft

cranelift: drop the call_indirect null check when no table slot can be null#13910
matthargett wants to merge 5 commits into
bytecodealliance:mainfrom
rebeckerspecialties:call-indirect-null-elision

Conversation

@matthargett

Copy link
Copy Markdown
Contributor

Stacked on #13445 and #13909 — only the last commit here is new. It builds on the sig-check PR because the null-slot analysis extends the same Module::static_funcref_image proof, and the elision is wired through the same StaticMatch path.

What this does

After the table-entry load, call_indirect checks 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_null helper 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 image covers every slot of the table's size — slots past the end of the image are null at runtime, so an image shorter than the table disqualifies it (the table's size is fixed here, since a static image implies the table can never grow); and
  • no covered slot is null.

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

  • new disas test: fully-covered uniform table, dispatch path reduced to bounds check + lazy-init + load + call, no null-handling block
  • runtime *.wast coverage 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, and table.set of null followed by a dispatch that must trap

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.
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