Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 47 additions & 2 deletions crates/cranelift/src/func_environ.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1652,8 +1652,14 @@ impl FuncEnvironment<'_> {
.unwrap();

// A fixed-size table can't be resized, so its base address won't change
// and its base load is `readonly` and `can_move`.
let is_static = Some(table.limits.min) == table.limits.max;
// and its base load is `readonly` and `can_move`. That holds when the
// table's type pins `min == max`, and also when translation proved the
// table can never be mutated or grown: its size is then `min` forever,
// so the bound is a compile-time constant and the per-call bound load
// disappears. (Imported and exported tables are never "immutable" —
// the host can grow them — so they only qualify via `min == max`.)
let is_static =
Some(table.limits.min) == table.limits.max || self.module.table_is_immutable(index);
let mut base_flags = ir::MemFlagsData::trusted();
if is_static {
base_flags = base_flags.with_readonly().with_can_move();
Expand Down Expand Up @@ -2161,6 +2167,30 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
.map(Some)
}

/// Try to prove that the runtime signature check at a `call_indirect`
/// site through an untyped `funcref` table is redundant: the table's
/// contents are static for the lifetime of every instance
/// ([`Module::static_funcref_image`]) and every non-null entry has
/// the same module-interned signature as the type declared at the
/// call site. Null slots are fine — with the signature check gone, a
/// null entry still traps on the funcref null check.
fn try_elide_sig_check_for_immutable_table(
&self,
table_index: TableIndex,
ty_index: TypeIndex,
) -> bool {
let module = self.env.module;
let Some(image) = module.static_funcref_image(table_index) else {
return false;
};
let expected_ty = module.types[ty_index].unwrap_module_type_index();
image
.iter()
.copied()
.filter(|f| !f.is_reserved_value())
.all(|f| module.functions[f].signature.unwrap_module_type_index() == expected_ty)
}

fn check_and_load_code_and_callee_vmctx(
&mut self,
table_index: TableIndex,
Expand Down Expand Up @@ -2218,6 +2248,21 @@ impl<'a, 'func, 'module_env> Call<'a, 'func, 'module_env> {
// table of typed functions and that type matches `ty_index`, then
// there's no need to perform a typecheck.
match table.ref_type.heap_type {
// An untyped `funcref` table ordinarily needs a runtime
// signature check. But when the table's contents are static
// for the lifetime of every instance and every entry has the
// signature this call site expects, the check can never fail
// and is elided the same way as for typed-funcref tables
// below. Null slots are still caught: `may_be_null` is
// inherited from the table type, so the null check remains.
WasmHeapType::Func
if self.try_elide_sig_check_for_immutable_table(table_index, ty_index) =>
{
return CheckIndirectCallTypeSignature::StaticMatch {
may_be_null: table.ref_type.nullable,
};
}

// Functions do not have a statically known type in the table, a
// typecheck is required. Fall through to below to perform the
// actual typecheck.
Expand Down
2 changes: 2 additions & 0 deletions crates/environ/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ cranelift-bitset = { workspace = true, features = ['enable-serde'] }
cranelift-bforest = { workspace = true }
hashbrown = { workspace = true, features = ["default-hasher"] }
wasmparser = { workspace = true, features = ['validate', 'serde', 'features'] }
rayon = { workspace = true, optional = true }
indexmap = { workspace = true, features = ["serde"] }
serde = { workspace = true }
serde_derive = { workspace = true }
Expand Down Expand Up @@ -83,6 +84,7 @@ compile = [
"dep:wasmprinter",
]
stack-switching = []
parallel = ["dep:rayon", "std"]
threads = ['std']
wmemcheck = ['std']
std = [
Expand Down
224 changes: 224 additions & 0 deletions crates/environ/src/compile/module_environ.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> {
self.translate_payload(payload?)?;
}

analyze_table_mutability(&mut self.result)?;

Ok(self.result)
}

Expand Down Expand Up @@ -1559,6 +1561,22 @@ impl ModuleTranslation<'_> {
let _ = segments.next();
}
self.table_initialization.segments = segments.try_collect().panic_on_oom();

// Every segment left over here is applied at instantiation time
// rather than folded into the precomputed image, so the targeted
// tables' runtime contents are not fully described by their
// image. Record that so `Module::static_funcref_image` refuses
// them — an optimization drawing conclusions from the image of
// such a table would miss the deferred writes.
let deferred: Vec<TableIndex> = self
.table_initialization
.segments
.iter()
.map(|segment| segment.table_index)
.collect();
for index in deferred {
self.module.mark_table_has_deferred_segments(index);
}
}

/// Helper function to ratchet the `startup` function for this module as
Expand All @@ -1582,3 +1600,209 @@ impl ModuleTranslation<'_> {
self.module.startup = ModuleStartup::IfMemoriesNeedInit(ty);
}
}

/// Populate `Module::mutated_tables`: record every table whose contents or
/// size can change after instantiation.
///
/// Imported and exported tables are pre-marked up front — the embedder, or
/// another module importing the export, can mutate them through the public
/// API without any instruction in this module executing. Each defined
/// non-exported table is then marked if any instruction in the code
/// section can write to it (`table.set`, `table.fill`, `table.grow`,
/// `table.copy` as the destination, `table.init`, or a
/// shared-everything-threads atomic table write). Active element segments
/// are part of a table's initial state, not a mutation, and `elem.drop`
/// drops a passive segment without writing to any table.
///
/// Cost: one extra decode pass over the code section. It is skipped
/// entirely when the module has no tables or when every table is already
/// pre-marked (e.g. the single funcref table is exported, as Emscripten
/// output typically is). With the `parallel` feature the bodies are
/// scanned on the rayon thread pool; serially otherwise, stopping early
/// once every table is marked. As a data point, forcing the walk over an
/// 833 KiB Emscripten-built sqlite3 module measures ~4 ms serially on an
/// Apple M4 (about 70% of a serial `validate_all` of the same bytes) and
/// ~0.6 ms with `parallel`; in a default build that module never pays
/// either cost, since its exported table takes the skip path.
fn analyze_table_mutability(translation: &mut ModuleTranslation<'_>) -> Result<()> {
let num_tables = translation.module.tables.len();
if num_tables == 0 {
return Ok(());
}

for i in 0..translation.module.num_imported_tables {
translation
.module
.mark_table_mutated(TableIndex::from_u32(i as u32));
}
let exported: Vec<TableIndex> = translation
.module
.exports
.values()
.filter_map(|entity| match entity {
EntityIndex::Table(index) => Some(*index),
_ => None,
})
.collect();
for index in exported {
translation.module.mark_table_mutated(index);
}

let all_marked = |module: &Module| {
(0..num_tables as u32).all(|i| !module.table_is_immutable(TableIndex::from_u32(i)))
};
if all_marked(&translation.module) {
return Ok(());
}

let module = &mut translation.module;
let bodies = &translation.function_body_inputs;

#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let bodies: Vec<_> = bodies.values().collect();
let found = bodies
.into_par_iter()
.map(|body_data| table_mutations_in_body(&body_data.body, num_tables))
.collect::<Result<Vec<_>>>()?;
for index in found.into_iter().flatten() {
module.mark_table_mutated(index);
}
}

#[cfg(not(feature = "parallel"))]
{
for (_, body_data) in bodies.iter() {
for index in table_mutations_in_body(&body_data.body, num_tables)? {
module.mark_table_mutated(index);
}
if all_marked(module) {
break;
}
}
}

Ok(())
}

/// Decode one function body and return the tables its instructions can
/// write to, bounded by `num_tables`.
fn table_mutations_in_body(body: &FunctionBody<'_>, num_tables: usize) -> Result<Vec<TableIndex>> {
let mut scan = TableMutationScan {
num_tables,
found: Vec::new(),
};
let mut reader = body.get_operators_reader()?;
while !reader.eof() {
reader.visit_operator(&mut scan)?;
}
Ok(scan.found)
}

/// Operator visitor for `table_mutations_in_body`: records the destination
/// table of every table-writing operator and ignores everything else.
///
/// Bodies have not been validated yet when this runs, so a decoded table
/// index can be out of range. Indices at or above `num_tables` are dropped
/// here — validation rejects such a module later — rather than sizing any
/// per-table structure from unvalidated input.
struct TableMutationScan {
num_tables: usize,
found: Vec<TableIndex>,
}

impl TableMutationScan {
fn mark(&mut self, table: u32) {
if (table as usize) < self.num_tables {
self.found.push(TableIndex::from_u32(table));
}
}
}

macro_rules! table_mutation_scan {
($(@$proposal:ident $op:ident $({ $($arg:ident: $argty:ty),* })? => $visit:ident ($($ann:tt)*))*) => {
$(table_mutation_scan!(@one $visit $({ $($arg: $argty),* })?);)*
};

// The operators that can write to a table mark their destination.
// `table.copy` writes only `dst_table`; its source is read-only, and
// wrongly marking it would forbid downstream optimizations on a
// read-only table. `table.atomic.get` is likewise a read. The
// remaining atomic table operators write (`rmw.cmpxchg` conditionally,
// which still counts as a write here). This scan decodes them like any
// other operator; no module containing them can currently be compiled
// (the cranelift translator rejects shared-everything-threads with
// `wasm_unsupported!`), but matching them now keeps this analysis
// sound if that proposal is ever implemented.
(@one visit_table_set { $($arg:ident: $argty:ty),* }) => {
fn visit_table_set(&mut self, table: u32) {
self.mark(table);
}
};
(@one visit_table_fill { $($arg:ident: $argty:ty),* }) => {
fn visit_table_fill(&mut self, table: u32) {
self.mark(table);
}
};
(@one visit_table_grow { $($arg:ident: $argty:ty),* }) => {
fn visit_table_grow(&mut self, table: u32) {
self.mark(table);
}
};
(@one visit_table_init { $($arg:ident: $argty:ty),* }) => {
fn visit_table_init(&mut self, _elem_index: u32, table: u32) {
self.mark(table);
}
};
(@one visit_table_copy { $($arg:ident: $argty:ty),* }) => {
fn visit_table_copy(&mut self, dst_table: u32, _src_table: u32) {
self.mark(dst_table);
}
};
(@one visit_table_atomic_set { $($arg:ident: $argty:ty),* }) => {
fn visit_table_atomic_set(&mut self, _ordering: wasmparser::Ordering, table_index: u32) {
self.mark(table_index);
}
};
(@one visit_table_atomic_rmw_xchg { $($arg:ident: $argty:ty),* }) => {
fn visit_table_atomic_rmw_xchg(
&mut self,
_ordering: wasmparser::Ordering,
table_index: u32,
) {
self.mark(table_index);
}
};
(@one visit_table_atomic_rmw_cmpxchg { $($arg:ident: $argty:ty),* }) => {
fn visit_table_atomic_rmw_cmpxchg(
&mut self,
_ordering: wasmparser::Ordering,
table_index: u32,
) {
self.mark(table_index);
}
};

// Every other operator cannot write to a table.
(@one $visit:ident) => {
fn $visit(&mut self) {}
};
(@one $visit:ident { $($arg:ident: $argty:ty),* }) => {
fn $visit(&mut self, $(_: $argty),*) {}
};
}

impl<'a> wasmparser::VisitOperator<'a> for TableMutationScan {
type Output = ();

fn simd_visitor(&mut self) -> Option<&mut dyn wasmparser::VisitSimdOperator<'a, Output = ()>> {
Some(self)
}

wasmparser::for_each_visit_operator!(table_mutation_scan);
}

impl<'a> wasmparser::VisitSimdOperator<'a> for TableMutationScan {
wasmparser::for_each_visit_simd_operator!(table_mutation_scan);
}
Loading
Loading