Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
----
Expand Down
1 change: 1 addition & 0 deletions src/passes/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ set(passes_SOURCES
RemoveImports.cpp
RemoveMemoryInit.cpp
RemoveNonJSOps.cpp
RelaxNullability.cpp
RemoveRelaxedSIMD.cpp
RemoveUnusedBrs.cpp
RemoveUnusedNames.cpp
Expand Down
254 changes: 254 additions & 0 deletions src/passes/RelaxNullability.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/*
* 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. 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.
// 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-refining-casts to do so.
//
// This pass strictly removes information so it should come near the end of
// Binaryen's pipeline.

#include <utility>
#include <variant>

#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<Function*>& 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);
}
Comment thread
stevenfontanella marked this conversation as resolved.
Resolved

[[nodiscard]] bool makeNullable(Type& t) {
if (t.isNonNullableRef()) {
t = t.with(Nullability::Nullable);
return true;
}

return false;
}

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) {

Check warning on line 80 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

One-sided branch in diff

Branch `results.size() > 1` -> missing TRUE branch (True: 0x, False: 12x)
Fatal() << "TODO: Tuples not supported yet";
return;
}

Check warning on line 83 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Lines 81-83 not executed by tests (0x)
if (results.size() == 1 && results[0].isNonNullableRef()) {
Builder builder(*module);
insertRefAsNonNull(returnVal, builder);
}
}

struct InsertRefAsNonNull
: PostWalker<InsertRefAsNonNull,
UnifiedExpressionVisitor<InsertRefAsNonNull>> {
struct Typer : ChildTyper<Typer> {
Typer(Module& module, Function* func) : ChildTyper(module, func) {}
Comment thread
kripken marked this conversation as resolved.
Resolved

void note(Expression** currp, Constraints constraints) {
if (constraints.size() > 1) {

Check warning on line 97 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

One-sided branch in diff

Branch `constraints.size() > 1` -> missing TRUE branch (True: 0x, False: 25x)
Fatal() << "TODO: Tuples not supported yet";
return;
}

Check warning on line 100 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Lines 98-100 not executed by tests (0x)

std::visit(
overloaded{
[&](const Type& type) {
if (type.isNonNullableRef() && (*currp)->type.isNullableRef()) {

Check warning on line 105 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

One-sided branch in diff

Branch `(*currp)->type.isNullableRef()` -> missing FALSE branch (True: 8x, False: 0x)
*currp = Builder(wasm).makeRefAs(RefAsOp::RefAsNonNull, *currp);
}
},
[](Index) { /* no nullability constraint */ },
[&](const VarRef& var) {
std::visit(overloaded{
[](Index) { /* no nullability constraint */ },
[&](Nullability nullability) {

Check warning on line 113 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Lines 111-113 not executed by tests (0x)
// A polymorphic expression demanded a non-null. This
// can't actually happen today.
if (nullability == Nullability::NonNullable) {
*currp = Builder(wasm).makeRefAs(
RefAsOp::RefAsNonNull, *currp);
}
},
},
var.null);
}},

Check warning on line 123 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Lines 116-123 not executed by tests (0x)
constraints[0]);
}

void noteUnknown() {

Check warning on line 127 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Line 127 not executed by tests (0x)
// 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.
}

Check warning on line 130 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Line 130 not executed by tests (0x)

Type getLabelType(Name) {

Check warning on line 132 in src/passes/RelaxNullability.cpp

View workflow job for this annotation

GitHub Actions / coverage

Uncovered diff lines

Line 132 not executed by tests (0x)
// Labeled blocks are skipped. See `InsertRefAsNonNull::visitExpression`.
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);
}
};

// Pass implementing the bulk of function-parallel work after global rewrites
// are done.
struct LocalsToNullable : WalkerPass<PostWalker<LocalsToNullable>> {
using Super = WalkerPass<PostWalker<LocalsToNullable>>;

const std::unordered_set<Function*>& referencedFuncs;

// true iff any expression types were changed.
// false if only dead local definitions were changed.
bool changed = false;

LocalsToNullable(const std::unordered_set<Function*>& referencedFuncs)
: referencedFuncs(referencedFuncs) {}

std::unique_ptr<Pass> create() override {
return std::make_unique<LocalsToNullable>(referencedFuncs);
}

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be removed. The only thing that matters here is if the function is imported (being referenced does not matter for locals), and doWalkFunction is not called if we are imported (there is no code to walk).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would require some changes, since in visitLocalGet + visitLocalSet we unconditionally make the types of local.get and local.tee nullable even though they could refer to params that were not relaxed (if we remove this method). I called this out as a TODO on line 57 in isRelaxable. Does it sound ok to leave this for a later PR? I can add this to the checklist in the issue #9352.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see, this is for params too. Sounds good but maybe add a comment here about params being the reason we need to check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

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

for (Index i = 0; i < func->vars.size(); ++i) {
std::ignore = makeNullable(func->vars[i]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, wow. I've never seen std::ignore before. TIL!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, me too...

}

// 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);
}

void visitLocalGet(LocalGet* curr) { changed |= makeNullable(curr->type); }

void visitLocalSet(LocalSet* curr) {
if (curr->isTee()) {
changed |= makeNullable(curr->type);
}
}
};

struct RelaxNullability : Pass {
bool requiresNonNullableLocalFixups() override { return false; }

void run(Module* module) override {
auto referencedFuncs = ModuleUtils::getReferencedFuncs(*module);
relaxFunctionParams(module, referencedFuncs);

PassRunner runner(getPassRunner());
runner.add(std::make_unique<LocalsToNullable>(referencedFuncs));
runner.run();
}

private:
void
relaxFunctionParams(Module* module,
Comment thread
stevenfontanella marked this conversation as resolved.
Resolved
const std::unordered_set<Function*>& referencedFuncs) {
for (auto& func : module->functions) {
if (!isRelaxable(func.get(), referencedFuncs)) {
continue;
}

std::vector<Type> relaxedParams;
bool changed = false;
for (Type t : func->getParams()) {
changed |= makeNullable(t);
relaxedParams.push_back(t);
}

if (changed) {
func->setParams(std::move(relaxedParams));
}
}
}
};

} // anonymous namespace

Pass* createRelaxNullabilityPass() { return new RelaxNullability(); }

} // namespace wasm
6 changes: 5 additions & 1 deletion src/passes/pass.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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",
createRemoveRelaxedSIMDPass);
Expand Down Expand Up @@ -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");
}
Expand Down
1 change: 1 addition & 0 deletions src/passes/passes.h
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Pass* createPrintFunctionMapPass();
Pass* createPropagateGlobalsGloballyPass();
Pass* createRandomizeBranchHintsPass();
Pass* createRemoveNonJSOpsPass();
Pass* createRelaxNullabilityPass();
Pass* createRemoveRelaxedSIMDPass();
Pass* createRemoveEmptyFunctionExportsPass();
Pass* createRemoveExportsPass();
Expand Down
5 changes: 5 additions & 0 deletions test/lit/help/wasm-metadce.test
Original file line number Diff line number Diff line change
Expand Up @@ -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,
;; 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
;; CHECK-EMPTY:
Expand Down
5 changes: 5 additions & 0 deletions test/lit/help/wasm-opt.test
Original file line number Diff line number Diff line change
Expand Up @@ -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,
;; 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
;; CHECK-EMPTY:
Expand Down
5 changes: 5 additions & 0 deletions test/lit/help/wasm2js.test
Original file line number Diff line number Diff line change
Expand Up @@ -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,
;; 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
;; CHECK-EMPTY:
Expand Down
Loading
Loading