From 2cdf6ff5849fd8c3360c0770964c22cdee5e9c67 Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Wed, 28 Oct 2026 22:49:58 +0000 Subject: [PATCH 1/6] Add RelaxNullability pass --- src/passes/CMakeLists.txt | 1 + src/passes/RelaxNullability.cpp | 251 ++++++++++++++++ src/passes/pass.cpp | 6 +- src/passes/passes.h | 1 + test/lit/help/wasm-metadce.test | 5 + test/lit/help/wasm-opt.test | 5 + test/lit/help/wasm2js.test | 5 + test/lit/passes/relax-nullability.wast | 389 +++++++++++++++++++++++++ 8 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 src/passes/RelaxNullability.cpp create mode 100644 test/lit/passes/relax-nullability.wast diff --git a/src/passes/CMakeLists.txt b/src/passes/CMakeLists.txt index 04225148596..369825560cd 100644 --- a/src/passes/CMakeLists.txt +++ b/src/passes/CMakeLists.txt @@ -120,6 +120,7 @@ set(passes_SOURCES RemoveImports.cpp RemoveMemoryInit.cpp RemoveNonJSOps.cpp + RelaxNullability.cpp RemoveRelaxedSIMD.cpp RemoveUnusedBrs.cpp RemoveUnusedNames.cpp diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp new file mode 100644 index 00000000000..0ca80e44c59 --- /dev/null +++ b/src/passes/RelaxNullability.cpp @@ -0,0 +1,251 @@ +/* + * Copyright 2026 WebAssembly Community Group participants + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Unconditionally make all ref params, locals, and struct fields (todo) +// nullable when --traps-never-happen is enabled. This helps reduce code size +// by +// 1. Eliminating `ref.as_non_null`s - All `ref.as_non_null`s can be eliminated, +// except on the boundary of things that couldn't be relaxed e.g. +// imported function params. +// 2. Allowing more locals to share a declaration group, since they can no +// longer differ by nullability. +// +// It may also improve type merging by causing more function signatures to be +// identical. +// +// This pass does not strip redundant ref.as_non_nulls after definitions have +// been relaxed. Follow with --strip-refinining-casts to do so. +// +// This pass strictly removes information so it should come near the end of +// Binaryen's pipeline. + +#include + +#include "pass.h" +#include "wasm-builder.h" +#include "wasm.h" + +#include "ir/child-typer.h" +#include "ir/find_all.h" +#include "ir/module-utils.h" +#include "ir/utils.h" + +namespace wasm { + +namespace { + +bool isRelaxable(Function* func, + const std::unordered_set& referencedFuncs) { + // The signatures of imported and exported functions aren't changed to + // preserve the API boundary. Exported functions are implicitly 'referenced' + // (i.e. they may be the target of a call_ref even if they never appear in a + // ref.func), so no need to check that a second time. + // + // TODO: Relax private referenced funcs + bodies of public referenced funcs. + return !func->imported() && !referencedFuncs.contains(func); +} + +std::pair toNullable(Type t) { + if (t.isNonNullableRef()) { + return {true, t.with(Nullability::Nullable)}; + } else { + return {false, t}; + } +} + +void insertRefAsNonNull(Expression** curr, Builder builder) { + // unreachable also doesn't need a change + if (!(*curr)->type.isNullableRef()) { + return; + } + *curr = builder.makeRefAs(RefAsOp::RefAsNonNull, *curr); +} + +void fixupReturn(Expression** returnVal, Module* module, Function* func) { + auto results = func->getResults(); + if (results.size() > 1) { + Fatal() << "TODO: Tuples not supported yet"; + return; + } + if (results.size() == 1 && results[0].isNonNullableRef()) { + insertRefAsNonNull(returnVal, Builder(*module)); + } +} + +struct LocalsToNullable : PostWalker { + // true iff any expression types were changed. + // false if only dead local definitions were changed. + bool changed = false; + + void doWalkFunction(Function* func) { + for (Index i = 0; i < func->vars.size(); ++i) { + auto [_, nullable] = toNullable(func->vars[i]); + func->vars[i] = nullable; + } + + // TODO: could consider doing this only if locals changed + // but a function param could have changed and we can't tell that easily + // from here. + PostWalker::doWalkFunction(func); + } + + void visitLocalGet(LocalGet* curr) { + auto [changed, nullable] = toNullable(curr->type); + curr->type = nullable; + this->changed |= changed; + } + + void visitLocalSet(LocalSet* curr) { + if (curr->isTee()) { + auto [changed, nullable] = toNullable(curr->type); + curr->type = nullable; + this->changed |= changed; + } + } +}; + +struct InsertRefAsNonNull + : PostWalker> { + struct Typer : ChildTyper { + Typer(Module& module, Function* func) : ChildTyper(module, func) {} + + void note(Expression** currp, Constraints constraints) { + if (constraints.size() > 1) { + Fatal() << "TODO: Tuples not supported yet"; + return; + } + + std::visit( + overloaded{ + [&](const Type& type) { + if (type.isNonNullableRef() && (*currp)->type.isNullableRef()) { + *currp = Builder(wasm).makeRefAs(RefAsOp::RefAsNonNull, *currp); + } + }, + [](Index) { /* no nullability constraint */ }, + [&](const VarRef& var) { + std::visit(overloaded{ + [](Index) { /* no nullability constraint */ }, + // A polymorphic expression demanded a non-null. This + // can't actually happen today. + [&](Nullability nullability) { + if (nullability == Nullability::NonNullable) { + *currp = Builder(wasm).makeRefAs( + RefAsOp::RefAsNonNull, *currp); + } + }, + }, + var.null); + }}, + constraints[0]); + } + + void noteUnknown() { + // This is an unreachable with a type immediate e.g. struct.set. + // In this case we don't need to insert any ref.as_non_null. + } + + Type getLabelType(Name) { WASM_UNREACHABLE("unexpected branch"); } + } typer; + + InsertRefAsNonNull(Module& module, Function* func) : typer(module, func) {} + + void visitExpression(Expression* curr) { + // Types for labeled blocks will be handled by ReFinalize. Skip these + // so we don't need to implement `getLabelType`. + if (Properties::isBranch(curr) || + Properties::isControlFlowStructure(curr)) { + return; + } + + typer.visit(curr); + } +}; + +struct Runner : Pass { + const std::unordered_set& referencedFuncs; + + Runner(const std::unordered_set& referencedFuncs) + : referencedFuncs(referencedFuncs) {} + + std::unique_ptr create() override { + return std::make_unique(*this); + } + + bool isFunctionParallel() override { return true; } + + bool runsOnFunction(Function* func) override { + return isRelaxable(func, referencedFuncs); + } + + void runOnFunction(Module* module, Function* func) override { + // 1. Make all local definitions nullable and update gets and sets. + // 2. Refinalize to fixup block types + // 3. Insert ref.as_non_null where a parent demands it, e.g. return values, + // calls to unrelaxed imports, unrelaxed struct.set. + // + // Refinalize is not needed after step 3 because a block never demands a + // non-null and thus step 3 makes no difference to block types. + LocalsToNullable localsToNullable; + localsToNullable.walkFunction(func); + + if (!localsToNullable.changed) { + return; + } + + ReFinalize().walkFunctionInModule(func, module); + InsertRefAsNonNull(*module, func).walkFunctionInModule(func, module); + fixupReturn(&func->body, module, func); + } +}; + +struct RelaxNullability : Pass { + bool requiresNonNullableLocalFixups() override { return false; } + + void run(Module* module) override { + auto referencedFuncs = ModuleUtils::getReferencedFuncs(*module); + relaxFunctionParams(module, referencedFuncs); + + PassRunner runner(module); + runner.add(std::make_unique(referencedFuncs)); + runner.run(); + } + +private: + void + relaxFunctionParams(Module* module, + const std::unordered_set& referencedFuncs) { + for (auto& func : module->functions) { + if (!isRelaxable(func.get(), referencedFuncs)) { + continue; + } + + std::vector relaxedParams; + for (Type t : func->getParams()) { + auto [_, nullable] = toNullable(t); + relaxedParams.push_back(nullable); + } + func->setParams(std::move(relaxedParams)); + } + } +}; + +} // anonymous namespace + +Pass* createRelaxNullabilityPass() { return new RelaxNullability(); } + +} // namespace wasm diff --git a/src/passes/pass.cpp b/src/passes/pass.cpp index f811e9ae8d8..fadc432cdc6 100644 --- a/src/passes/pass.cpp +++ b/src/passes/pass.cpp @@ -433,6 +433,10 @@ void PassRegistry::registerPasses() { registerPass("remove-non-js-ops", "removes operations incompatible with js", createRemoveNonJSOpsPass); + registerPass("relax-nullability", + "make private function params and locals nullable, keeping " + "ref.as_non_null only where demanded (traps-never-happen)", + createRelaxNullabilityPass); registerPass("remove-relaxed-simd", "replaces relaxed SIMD instructions with unreachable", createRemoveRelaxedSIMDPass); @@ -759,7 +763,7 @@ void PassRunner::addDefaultFunctionOptimizationPasses() { addIfNoDWARFIssues( "remove-unused-brs"); // coalesce-locals opens opportunities addIfNoDWARFIssues( - "remove-unused-names"); // remove-unused-brs opens opportunities + "remove-unused-names"); // remove-unused-brs opens opportunities if (options.optimizeLevel >= 3 || options.shrinkLevel >= 1) { addIfNoDWARFIssues("constraint-analysis"); } diff --git a/src/passes/passes.h b/src/passes/passes.h index 4cd3d94fc54..627a18209e6 100644 --- a/src/passes/passes.h +++ b/src/passes/passes.h @@ -142,6 +142,7 @@ Pass* createPrintFunctionMapPass(); Pass* createPropagateGlobalsGloballyPass(); Pass* createRandomizeBranchHintsPass(); Pass* createRemoveNonJSOpsPass(); +Pass* createRelaxNullabilityPass(); Pass* createRemoveRelaxedSIMDPass(); Pass* createRemoveEmptyFunctionExportsPass(); Pass* createRemoveExportsPass(); diff --git a/test/lit/help/wasm-metadce.test b/test/lit/help/wasm-metadce.test index c7594291b00..d6f9e9a59e5 100644 --- a/test/lit/help/wasm-metadce.test +++ b/test/lit/help/wasm-metadce.test @@ -407,6 +407,11 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: +;; CHECK-NEXT: --relax-nullability make private function params and +;; CHECK-NEXT: locals nullable, keeping +;; CHECK-NEXT: ref.as_non_null only where +;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions ;; CHECK-EMPTY: diff --git a/test/lit/help/wasm-opt.test b/test/lit/help/wasm-opt.test index ae7e0c08394..ddacf7d3bbc 100644 --- a/test/lit/help/wasm-opt.test +++ b/test/lit/help/wasm-opt.test @@ -452,6 +452,11 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: +;; CHECK-NEXT: --relax-nullability make private function params and +;; CHECK-NEXT: locals nullable, keeping +;; CHECK-NEXT: ref.as_non_null only where +;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions ;; CHECK-EMPTY: diff --git a/test/lit/help/wasm2js.test b/test/lit/help/wasm2js.test index e382b2ef410..ad29d6c84f7 100644 --- a/test/lit/help/wasm2js.test +++ b/test/lit/help/wasm2js.test @@ -371,6 +371,11 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: +;; CHECK-NEXT: --relax-nullability make private function params and +;; CHECK-NEXT: locals nullable, keeping +;; CHECK-NEXT: ref.as_non_null only where +;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions ;; CHECK-EMPTY: diff --git a/test/lit/passes/relax-nullability.wast b/test/lit/passes/relax-nullability.wast new file mode 100644 index 00000000000..922294f6713 --- /dev/null +++ b/test/lit/passes/relax-nullability.wast @@ -0,0 +1,389 @@ +;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. + +;; RUN: foreach %s %t wasm-opt -tnh -all --relax-nullability -S -o - | filecheck %s + +;; Locals, params, and blocks are relaxed. +(module + ;; CHECK: (type $struct (struct)) + (type $struct (struct)) + + ;; CHECK: (type $1 (func (result (ref any)))) + + ;; CHECK: (type $2 (func (param anyref (ref null $struct)) (result (ref any)))) + + ;; CHECK: (type $3 (func (result anyref))) + + ;; CHECK: (type $4 (func (param i32) (result (ref any)))) + + ;; CHECK: (type $5 (func (param i32 anyref anyref) (result (ref any)))) + + ;; CHECK: (type $6 (func (param anyref) (result (ref any)))) + + ;; CHECK: (type $7 (func (param anyref))) + + ;; CHECK: (func $relax-sig (type $2) (param $ref anyref) (param $1 (ref null $struct)) (result (ref any)) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $relax-sig (param $ref (ref any)) (param (ref $struct)) (result (ref any)) + ;; Parameters and results relaxed to nullable + (local.get $ref) + ) + + ;; CHECK: (func $relax-locals (type $3) (result anyref) + ;; CHECK-NEXT: (local $a anyref) + ;; CHECK-NEXT: (local $s (ref null $struct)) + ;; CHECK-NEXT: (local $i32 i32) + ;; CHECK-NEXT: (local.set $s + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (local.set $a + ;; CHECK-NEXT: (local.get $s) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (local.get $a) + ;; CHECK-NEXT: ) + (func $relax-locals (result anyref) + ;; Locals relaxed to nullable + (local $a (ref any)) + (local $s (ref $struct)) + (local $i32 i32) + (local.set $s (struct.new $struct)) + (local.set $a (local.get $s)) + (local.get $a) + ) + + ;; CHECK: (func $relax-local-tee (type $1) (result (ref any)) + ;; CHECK-NEXT: (local $s (ref null $struct)) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.tee $s + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $relax-local-tee (result (ref any)) + (local $s (ref $struct)) + (local.tee $s (struct.new $struct)) + ) + + ;; CHECK: (func $relax-control-flow (type $4) (param $cond i32) (result (ref any)) + ;; CHECK-NEXT: (local $l anyref) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (block (result anyref) + ;; CHECK-NEXT: (local.set $l + ;; CHECK-NEXT: (ref.i31 + ;; CHECK-NEXT: (i32.const 1) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (if (result anyref) + ;; CHECK-NEXT: (local.get $cond) + ;; CHECK-NEXT: (then + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (else + ;; CHECK-NEXT: (local.get $l) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $relax-control-flow (param $cond i32) (result (ref any)) + ;; Control flow re-finalization + (local $l (ref any)) + (local.set $l (ref.i31 (i32.const 1))) + (if (result (ref any)) + (local.get $cond) + (then + (struct.new $struct) + ) + (else + (local.get $l) + ) + ) + ) + + ;; CHECK: (func $relax-block-loop (type $1) (result (ref any)) + ;; CHECK-NEXT: (local $l (ref null $struct)) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (block (result (ref null $struct)) + ;; CHECK-NEXT: (local.set $l + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (block (result (ref null $struct)) + ;; CHECK-NEXT: (loop (result (ref null $struct)) + ;; CHECK-NEXT: (local.get $l) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $relax-block-loop (result (ref any)) + ;; Blocks and loops + (local $l (ref $struct)) + (local.set $l (struct.new $struct)) + (block (result (ref $struct)) + (loop (result (ref $struct)) + (local.get $l) + ) + ) + ) + + ;; CHECK: (func $relax-select (type $5) (param $cond i32) (param $a anyref) (param $b anyref) (result (ref any)) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (select (result anyref) + ;; CHECK-NEXT: (local.get $a) + ;; CHECK-NEXT: (local.get $b) + ;; CHECK-NEXT: (local.get $cond) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $relax-select (param $cond i32) (param $a (ref any)) (param $b (ref any)) (result (ref any)) + ;; Select re-finalization + (select (result (ref any)) + (local.get $a) + (local.get $b) + (local.get $cond) + ) + ) + + ;; CHECK: (func $caller (type $6) (param $x anyref) (result (ref any)) + ;; CHECK-NEXT: (call $relax-sig + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $caller (param $x (ref any)) (result (ref any)) + ;; Internal call between relaxed functions: no casts needed + (call $relax-sig (local.get $x) (struct.new $struct)) + ) + + ;; CHECK: (func $preserves-existing-cast (type $7) (param $ref anyref) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (call $relax-sig + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (struct.new_default $struct) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $preserves-existing-cast (param $ref anyref) + ;; Existing casts in caller are preserved even when callee is relaxed to accept nullable + (drop (call $relax-sig (ref.as_non_null (local.get $ref)) (struct.new $struct))) + ) +) + +;; ref.as_non_null is inserted where needed: {array/struct}.{new/set}, +;; global.set, throw for non-nullable tag params, calls to imported and +;; exported functions, and functions that may be the target of a +;; call_ref/call_indirect. +(module + (type $struct (struct)) + ;; CHECK: (type $shared-sig (func (param (ref any)) (result (ref any)))) + + ;; CHECK: (type $1 (func (param anyref))) + + ;; CHECK: (type $struct-with-nonnull (struct (field (mut (ref any))))) + (type $struct-with-nonnull (struct (field (mut (ref any))))) + + (type $shared-sig (func (param (ref any)) (result (ref any)))) + + ;; Imported function sharing signature with an unrelaxable function (cannot be relaxed) + ;; CHECK: (type $3 (func)) + + ;; CHECK: (type $4 (func (param (ref any)))) + + ;; CHECK: (type $5 (func (param anyref) (result (ref any)))) + + ;; CHECK: (import "" "" (func $imported-not-relaxed (type $shared-sig) (param (ref any)) (result (ref any)))) + (import "" "" (func $imported-not-relaxed (type $shared-sig) (param (ref any)) (result (ref any)))) + + ;; CHECK: (global $g (mut (ref any)) (ref.i31 + ;; CHECK-NEXT: (i32.const 0) + ;; CHECK-NEXT: )) + (global $g (mut (ref any)) (ref.i31 (i32.const 0))) + + ;; CHECK: (table $0 1 1 funcref) + + ;; CHECK: (elem $0 (i32.const 0) $referenced-via-table-not-relaxed) + + ;; CHECK: (elem declare func $referenced-not-relaxed) + + ;; CHECK: (tag $tag (type $4) (param (ref any))) + (tag $tag (param (ref any))) + + (table 1 1 funcref) + + ;; CHECK: (export "exported-not-relaxed" (func $exported-not-relaxed)) + + ;; CHECK: (func $exported-not-relaxed (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + (func $exported-not-relaxed (export "exported-not-relaxed") (param $ref (ref any)) (result (ref any)) + (local.get $ref) + ) + + ;; CHECK: (func $referenced-not-relaxed (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + (func $referenced-not-relaxed (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; Function referenced via ref.func: cannot be relaxed + (local.get $ref) + ) + + ;; CHECK: (func $referenced-via-table-not-relaxed (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + (func $referenced-via-table-not-relaxed (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; Function referenced via elem declare: cannot be relaxed + (local.get $ref) + ) + + (elem (i32.const 0) $referenced-via-table-not-relaxed) + + ;; CHECK: (func $takes-ref (type $3) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (ref.func $referenced-not-relaxed) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $takes-ref + (drop (ref.func $referenced-not-relaxed)) + ) + + ;; CHECK: (func $shares-type (type $5) (param $ref anyref) (result (ref any)) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $shares-type (type $shared-sig) (param $ref (ref any)) (result (ref any)) + ;; Shares a signature with $referenced, but still relaxable by giving this + ;; a new heaptype rather than referencing the existing public heaptype. + (local.get $ref) + ) + + ;; CHECK: (func $calls-insert-nonnull (type $1) (param $x anyref) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (call $imported-not-relaxed + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (call $exported-not-relaxed + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (call $referenced-not-relaxed + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (call $referenced-via-table-not-relaxed + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $calls-insert-nonnull (param $x (ref any)) + ;; Calling unrelaxed functions from relaxed context inserts ref.as_non_null + (drop (call $imported-not-relaxed (local.get $x))) + (drop (call $exported-not-relaxed (local.get $x))) + (drop (call $referenced-not-relaxed (local.get $x))) + (drop (call $referenced-via-table-not-relaxed (local.get $x))) + ) + + ;; CHECK: (func $throw-inserts-nonnull (type $1) (param $x anyref) + ;; CHECK-NEXT: (throw $tag + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $throw-inserts-nonnull (param $x (ref any)) + (throw $tag (local.get $x)) + ) + + ;; CHECK: (func $struct-inserts-nonnull (type $1) (param $x anyref) + ;; CHECK-NEXT: (local $s (ref null $struct-with-nonnull)) + ;; CHECK-NEXT: (local.set $s + ;; CHECK-NEXT: (struct.new $struct-with-nonnull + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (struct.set $struct-with-nonnull 0 + ;; CHECK-NEXT: (local.get $s) + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $x) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $struct-inserts-nonnull (param $x (ref any)) + (local $s (ref $struct-with-nonnull)) + (local.set $s (struct.new $struct-with-nonnull (local.get $x))) + (struct.set $struct-with-nonnull 0 (local.get $s) (local.get $x)) + ) + + ;; CHECK: (func $global-set-inserts-nonnull (type $1) (param $ref anyref) + ;; CHECK-NEXT: (global.set $g + ;; CHECK-NEXT: (ref.as_non_null + ;; CHECK-NEXT: (local.get $ref) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $global-set-inserts-nonnull (param $ref (ref any)) + (global.set $g (local.get $ref)) + ) + + ;; CHECK: (func $call-unreachable (type $3) + ;; CHECK-NEXT: (block ;; (replaces unreachable CallRef we can't emit) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (ref.i31 + ;; CHECK-NEXT: (i32.const 1) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (drop + ;; CHECK-NEXT: (unreachable) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: (unreachable) + ;; CHECK-NEXT: ) + ;; CHECK-NEXT: ) + (func $call-unreachable + ;; no need to relax a call to unreachable even though the signature + ;; demands it. + (call_ref $shared-sig (ref.i31 (i32.const 1)) (unreachable)) + ) +) + +;; subtyping +(module + ;; CHECK: (type $super (sub (func (param (ref any))))) + (type $super (sub (func (param (ref any))))) + (type $sub (sub $super (func (param (ref any))))) + + ;; CHECK: (type $1 (func (param anyref))) + + ;; CHECK: (export "super-func" (func $super-func)) + + ;; CHECK: (func $super-func (type $super) (param $0 (ref any)) + ;; CHECK-NEXT: ) + (func $super-func (export "super-func") (type $super) (param (ref any)) + ;; Referenced, not relaxed. + ) + + ;; CHECK: (func $sub-func (type $1) (param $0 anyref) + ;; CHECK-NEXT: ) + (func $sub-func (type $sub) (param (ref any)) + ;; Although $super-func is referenced, $sub-func is not and we don't need + ;; to preserve the subtyping relationship. Assign it a brand new relaxed + ;; type that doesn't participate in subtyping. + ) +) From 9cd5b46471a7c71b37fb00cf4a44459a6053754f Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Thu, 5 Nov 2026 21:18:59 +0000 Subject: [PATCH 2/6] PR updates --- src/passes/RelaxNullability.cpp | 67 ++++++++++++++------------ test/lit/passes/relax-nullability.wast | 2 +- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp index 0ca80e44c59..8868393b147 100644 --- a/src/passes/RelaxNullability.cpp +++ b/src/passes/RelaxNullability.cpp @@ -15,19 +15,18 @@ */ // Unconditionally make all ref params, locals, and struct fields (todo) -// nullable when --traps-never-happen is enabled. This helps reduce code size -// by -// 1. Eliminating `ref.as_non_null`s - All `ref.as_non_null`s can be eliminated, -// except on the boundary of things that couldn't be relaxed e.g. -// imported function params. +// nullable. This helps reduce code size by +// 1. Eliminating `ref.as_non_null`s - When --traps-never-happen, all +// `ref.as_non_null`s can be eliminated, except on the boundary of things +// that couldn't be relaxed e.g. imported function params. This is done +// by --strip-refining-casts. // 2. Allowing more locals to share a declaration group, since they can no // longer differ by nullability. -// -// It may also improve type merging by causing more function signatures to be -// identical. +// 3. Exposing more opportunities for type merging by causing more function +// signatures and struct/arrays to be identical. // // This pass does not strip redundant ref.as_non_nulls after definitions have -// been relaxed. Follow with --strip-refinining-casts to do so. +// been relaxed. Follow with --strip-refining-casts to do so. // // This pass strictly removes information so it should come near the end of // Binaryen's pipeline. @@ -58,12 +57,13 @@ bool isRelaxable(Function* func, return !func->imported() && !referencedFuncs.contains(func); } -std::pair toNullable(Type t) { +[[nodiscard]] bool makeNullable(Type& t) { if (t.isNonNullableRef()) { - return {true, t.with(Nullability::Nullable)}; - } else { - return {false, t}; + t = t.with(Nullability::Nullable); + return true; } + + return false; } void insertRefAsNonNull(Expression** curr, Builder builder) { @@ -92,8 +92,7 @@ struct LocalsToNullable : PostWalker { void doWalkFunction(Function* func) { for (Index i = 0; i < func->vars.size(); ++i) { - auto [_, nullable] = toNullable(func->vars[i]); - func->vars[i] = nullable; + bool _ = makeNullable(func->vars[i]); } // TODO: could consider doing this only if locals changed @@ -102,17 +101,11 @@ struct LocalsToNullable : PostWalker { PostWalker::doWalkFunction(func); } - void visitLocalGet(LocalGet* curr) { - auto [changed, nullable] = toNullable(curr->type); - curr->type = nullable; - this->changed |= changed; - } + void visitLocalGet(LocalGet* curr) { changed |= makeNullable(curr->type); } void visitLocalSet(LocalSet* curr) { if (curr->isTee()) { - auto [changed, nullable] = toNullable(curr->type); - curr->type = nullable; - this->changed |= changed; + changed |= makeNullable(curr->type); } } }; @@ -159,7 +152,10 @@ struct InsertRefAsNonNull // In this case we don't need to insert any ref.as_non_null. } - Type getLabelType(Name) { WASM_UNREACHABLE("unexpected branch"); } + Type getLabelType(Name) { + // Labeled blocks are skipped. See `InsertRefAsNonNull::visitExpression`. + WASM_UNREACHABLE("unexpected branch"); + } } typer; InsertRefAsNonNull(Module& module, Function* func) : typer(module, func) {} @@ -176,14 +172,17 @@ struct InsertRefAsNonNull } }; -struct Runner : Pass { +// Pass implementing the bulk of function-parallel work after global rewrites +// are done. +struct FunctionNullabilityRelaxer : Pass { const std::unordered_set& referencedFuncs; - Runner(const std::unordered_set& referencedFuncs) + FunctionNullabilityRelaxer( + const std::unordered_set& referencedFuncs) : referencedFuncs(referencedFuncs) {} std::unique_ptr create() override { - return std::make_unique(*this); + return std::make_unique(*this); } bool isFunctionParallel() override { return true; } @@ -220,8 +219,8 @@ struct RelaxNullability : Pass { auto referencedFuncs = ModuleUtils::getReferencedFuncs(*module); relaxFunctionParams(module, referencedFuncs); - PassRunner runner(module); - runner.add(std::make_unique(referencedFuncs)); + PassRunner runner(getPassRunner()); + runner.add(std::make_unique(referencedFuncs)); runner.run(); } @@ -235,11 +234,15 @@ struct RelaxNullability : Pass { } std::vector relaxedParams; + bool changed = false; for (Type t : func->getParams()) { - auto [_, nullable] = toNullable(t); - relaxedParams.push_back(nullable); + changed |= makeNullable(t); + relaxedParams.push_back(t); + } + + if (changed) { + func->setParams(std::move(relaxedParams)); } - func->setParams(std::move(relaxedParams)); } } }; diff --git a/test/lit/passes/relax-nullability.wast b/test/lit/passes/relax-nullability.wast index 922294f6713..2a409d54e57 100644 --- a/test/lit/passes/relax-nullability.wast +++ b/test/lit/passes/relax-nullability.wast @@ -1,6 +1,6 @@ ;; NOTE: Assertions have been generated by update_lit_checks.py --all-items and should not be edited. -;; RUN: foreach %s %t wasm-opt -tnh -all --relax-nullability -S -o - | filecheck %s +;; RUN: foreach %s %t wasm-opt -all --relax-nullability -S -o - | filecheck %s ;; Locals, params, and blocks are relaxed. (module From 8d3efcbee5cd92d0ae6c141e015ccbdbfba29d23 Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Thu, 5 Nov 2026 23:22:33 +0000 Subject: [PATCH 3/6] Update help + fix unused warning --- src/passes/RelaxNullability.cpp | 3 ++- src/passes/pass.cpp | 4 ++-- test/lit/help/wasm-metadce.test | 8 ++++---- test/lit/help/wasm-opt.test | 8 ++++---- test/lit/help/wasm2js.test | 8 ++++---- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp index 8868393b147..825bd218723 100644 --- a/src/passes/RelaxNullability.cpp +++ b/src/passes/RelaxNullability.cpp @@ -31,6 +31,7 @@ // This pass strictly removes information so it should come near the end of // Binaryen's pipeline. +#include #include #include "pass.h" @@ -92,7 +93,7 @@ struct LocalsToNullable : PostWalker { void doWalkFunction(Function* func) { for (Index i = 0; i < func->vars.size(); ++i) { - bool _ = makeNullable(func->vars[i]); + std::ignore = makeNullable(func->vars[i]); } // TODO: could consider doing this only if locals changed diff --git a/src/passes/pass.cpp b/src/passes/pass.cpp index fadc432cdc6..e4d41ceb901 100644 --- a/src/passes/pass.cpp +++ b/src/passes/pass.cpp @@ -434,8 +434,8 @@ void PassRegistry::registerPasses() { "removes operations incompatible with js", createRemoveNonJSOpsPass); registerPass("relax-nullability", - "make private function params and locals nullable, keeping " - "ref.as_non_null only where demanded (traps-never-happen)", + "make private function params, locals, struct fields, tags, and " + "globals nullable, inserting ref.as_non_null where demanded", createRelaxNullabilityPass); registerPass("remove-relaxed-simd", "replaces relaxed SIMD instructions with unreachable", diff --git a/test/lit/help/wasm-metadce.test b/test/lit/help/wasm-metadce.test index d6f9e9a59e5..4a836ce396e 100644 --- a/test/lit/help/wasm-metadce.test +++ b/test/lit/help/wasm-metadce.test @@ -407,10 +407,10 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: -;; CHECK-NEXT: --relax-nullability make private function params and -;; CHECK-NEXT: locals nullable, keeping -;; CHECK-NEXT: ref.as_non_null only where -;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-NEXT: --relax-nullability make private function params, +;; CHECK-NEXT: locals, struct fields, tags, and +;; CHECK-NEXT: globals nullable, inserting +;; CHECK-NEXT: ref.as_non_null where demanded ;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions diff --git a/test/lit/help/wasm-opt.test b/test/lit/help/wasm-opt.test index ddacf7d3bbc..ea565c58a7c 100644 --- a/test/lit/help/wasm-opt.test +++ b/test/lit/help/wasm-opt.test @@ -452,10 +452,10 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: -;; CHECK-NEXT: --relax-nullability make private function params and -;; CHECK-NEXT: locals nullable, keeping -;; CHECK-NEXT: ref.as_non_null only where -;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-NEXT: --relax-nullability make private function params, +;; CHECK-NEXT: locals, struct fields, tags, and +;; CHECK-NEXT: globals nullable, inserting +;; CHECK-NEXT: ref.as_non_null where demanded ;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions diff --git a/test/lit/help/wasm2js.test b/test/lit/help/wasm2js.test index ad29d6c84f7..36cb0258a4a 100644 --- a/test/lit/help/wasm2js.test +++ b/test/lit/help/wasm2js.test @@ -371,10 +371,10 @@ ;; CHECK-NEXT: --propagate-globals-globally propagate global values to other ;; CHECK-NEXT: globals (useful for tests) ;; CHECK-EMPTY: -;; CHECK-NEXT: --relax-nullability make private function params and -;; CHECK-NEXT: locals nullable, keeping -;; CHECK-NEXT: ref.as_non_null only where -;; CHECK-NEXT: demanded (traps-never-happen) +;; CHECK-NEXT: --relax-nullability make private function params, +;; CHECK-NEXT: locals, struct fields, tags, and +;; CHECK-NEXT: globals nullable, inserting +;; CHECK-NEXT: ref.as_non_null where demanded ;; CHECK-EMPTY: ;; CHECK-NEXT: --remove-empty-function-exports removes exports of empty ;; CHECK-NEXT: functions From ce1ac45cf5110cf4bfa04c137bd4ecbe4455f155 Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Thu, 5 Nov 2026 23:31:52 +0000 Subject: [PATCH 4/6] Changelog + comment fix --- CHANGELOG.md | 6 ++++++ src/passes/RelaxNullability.cpp | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8368abb7cff..e3095102aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ Current Trunk For unpacked types, call `struct/array.get()` with only three arguments; for signed packed types, call `struct/array.get_s()`; for unsigned packed types, call `struct/array.get_u()`. +- Add a new `--relax-nullability` pass to force all locals and params to be + nullable. This exposes opportunities for type merging since function + signatures (and later structs/arrays) may no longer differ by nullability. It + also removes most `ref.as_non_null` instructions when paired with + `--traps-never-happen` and `--strip-refining-casts`. The pass currently + panics on tuples and can't be used with multivalue. v133 ---- diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp index 825bd218723..8fcdc57ff04 100644 --- a/src/passes/RelaxNullability.cpp +++ b/src/passes/RelaxNullability.cpp @@ -134,9 +134,9 @@ struct InsertRefAsNonNull [&](const VarRef& var) { std::visit(overloaded{ [](Index) { /* no nullability constraint */ }, - // A polymorphic expression demanded a non-null. This - // can't actually happen today. [&](Nullability nullability) { + // A polymorphic expression demanded a non-null. This + // can't actually happen today. if (nullability == Nullability::NonNullable) { *currp = Builder(wasm).makeRefAs( RefAsOp::RefAsNonNull, *currp); From 6cbf4e1477e5e722e94d816ed24169afb55a1e3c Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Fri, 6 Nov 2026 00:28:45 +0000 Subject: [PATCH 5/6] Refactor visitor --- src/passes/RelaxNullability.cpp | 69 +++++++++++++++------------------ 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp index 8fcdc57ff04..839a80115f8 100644 --- a/src/passes/RelaxNullability.cpp +++ b/src/passes/RelaxNullability.cpp @@ -86,31 +86,6 @@ void fixupReturn(Expression** returnVal, Module* module, Function* func) { } } -struct LocalsToNullable : PostWalker { - // true iff any expression types were changed. - // false if only dead local definitions were changed. - bool changed = false; - - void doWalkFunction(Function* func) { - for (Index i = 0; i < func->vars.size(); ++i) { - std::ignore = makeNullable(func->vars[i]); - } - - // TODO: could consider doing this only if locals changed - // but a function param could have changed and we can't tell that easily - // from here. - PostWalker::doWalkFunction(func); - } - - void visitLocalGet(LocalGet* curr) { changed |= makeNullable(curr->type); } - - void visitLocalSet(LocalSet* curr) { - if (curr->isTee()) { - changed |= makeNullable(curr->type); - } - } -}; - struct InsertRefAsNonNull : PostWalker> { @@ -175,15 +150,20 @@ struct InsertRefAsNonNull // Pass implementing the bulk of function-parallel work after global rewrites // are done. -struct FunctionNullabilityRelaxer : Pass { +struct LocalsToNullable : WalkerPass> { + using Super = WalkerPass>; + const std::unordered_set& referencedFuncs; - FunctionNullabilityRelaxer( - const std::unordered_set& referencedFuncs) + // true iff any expression types were changed. + // false if only dead local definitions were changed. + bool changed = false; + + LocalsToNullable(const std::unordered_set& referencedFuncs) : referencedFuncs(referencedFuncs) {} std::unique_ptr create() override { - return std::make_unique(*this); + return std::make_unique(referencedFuncs); } bool isFunctionParallel() override { return true; } @@ -192,7 +172,7 @@ struct FunctionNullabilityRelaxer : Pass { return isRelaxable(func, referencedFuncs); } - void runOnFunction(Module* module, Function* func) override { + void doWalkFunction(Function* func) { // 1. Make all local definitions nullable and update gets and sets. // 2. Refinalize to fixup block types // 3. Insert ref.as_non_null where a parent demands it, e.g. return values, @@ -200,16 +180,31 @@ struct FunctionNullabilityRelaxer : Pass { // // Refinalize is not needed after step 3 because a block never demands a // non-null and thus step 3 makes no difference to block types. - LocalsToNullable localsToNullable; - localsToNullable.walkFunction(func); - if (!localsToNullable.changed) { + for (Index i = 0; i < func->vars.size(); ++i) { + std::ignore = makeNullable(func->vars[i]); + } + + // TODO: could consider doing this only if locals changed + // but a function param could have changed and we can't tell that easily + // from here. + Super::doWalkFunction(func); + + if (!changed) { return; } + ReFinalize().walkFunctionInModule(func, getModule()); + InsertRefAsNonNull(*getModule(), func) + .walkFunctionInModule(func, getModule()); + fixupReturn(&func->body, getModule(), func); + } - ReFinalize().walkFunctionInModule(func, module); - InsertRefAsNonNull(*module, func).walkFunctionInModule(func, module); - fixupReturn(&func->body, module, func); + void visitLocalGet(LocalGet* curr) { changed |= makeNullable(curr->type); } + + void visitLocalSet(LocalSet* curr) { + if (curr->isTee()) { + changed |= makeNullable(curr->type); + } } }; @@ -221,7 +216,7 @@ struct RelaxNullability : Pass { relaxFunctionParams(module, referencedFuncs); PassRunner runner(getPassRunner()); - runner.add(std::make_unique(referencedFuncs)); + runner.add(std::make_unique(referencedFuncs)); runner.run(); } From 2dd3c7fe1ca2b81345517dcfd85a1a83aa87bc61 Mon Sep 17 00:00:00 2001 From: stevenfontanella Date: Tue, 10 Nov 2026 23:32:41 +0000 Subject: [PATCH 6/6] PR updates --- src/passes/RelaxNullability.cpp | 8 ++++++-- test/lit/passes/relax-nullability.wast | 4 ---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/passes/RelaxNullability.cpp b/src/passes/RelaxNullability.cpp index 839a80115f8..e328df15798 100644 --- a/src/passes/RelaxNullability.cpp +++ b/src/passes/RelaxNullability.cpp @@ -67,7 +67,7 @@ bool isRelaxable(Function* func, return false; } -void insertRefAsNonNull(Expression** curr, Builder builder) { +void insertRefAsNonNull(Expression** curr, Builder& builder) { // unreachable also doesn't need a change if (!(*curr)->type.isNullableRef()) { return; @@ -82,7 +82,8 @@ void fixupReturn(Expression** returnVal, Module* module, Function* func) { return; } if (results.size() == 1 && results[0].isNonNullableRef()) { - insertRefAsNonNull(returnVal, Builder(*module)); + Builder builder(*module); + insertRefAsNonNull(returnVal, builder); } } @@ -169,6 +170,9 @@ struct LocalsToNullable : WalkerPass> { bool isFunctionParallel() override { return true; } bool runsOnFunction(Function* func) override { + // If we didn't relax the params, don't update local.gets / local.tee which + // might touch unrelaxed params. + // TODO: Relax locals even if the params weren't relaxed. return isRelaxable(func, referencedFuncs); } diff --git a/test/lit/passes/relax-nullability.wast b/test/lit/passes/relax-nullability.wast index 2a409d54e57..5069fc18c2a 100644 --- a/test/lit/passes/relax-nullability.wast +++ b/test/lit/passes/relax-nullability.wast @@ -173,10 +173,6 @@ ) ) -;; ref.as_non_null is inserted where needed: {array/struct}.{new/set}, -;; global.set, throw for non-nullable tag params, calls to imported and -;; exported functions, and functions that may be the target of a -;; call_ref/call_indirect. (module (type $struct (struct)) ;; CHECK: (type $shared-sig (func (param (ref any)) (result (ref any))))