Skip to content

Add StripRefiningCasts pass to eliminate redundant subtype casts and … - #9188

Open
gkdn wants to merge 4 commits into
WebAssembly:mainfrom
gkdn:strip-refining-casts
Open

gkdn wants to merge 4 commits into
WebAssembly:mainfrom
gkdn:strip-refining-casts

Conversation

@gkdn

@gkdn gkdn commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

…null assertions

In traps-never-happen mode, values flowing into typed slots (locals, globals, call parameters, function results, struct fields, array elements, control-flow branches and fallthroughs, etc.) and the inputs of ref.test often contain downcasts or non-null assertions where the operand already statically satisfies the required type.

Such casts can narrow the value beyond the slot type, and that narrower type is information that refining passes (GUFA, signature-refining, type-refining, local-subtyping, cfp-reftest, ...) can use to refine the slot itself or to resolve a ref.test. Instead of removing them in OptimizeInstructions, which runs throughout the pipeline, add a standalone StripRefiningCasts pass (--strip-refining-casts) meant to run late in the pipeline:

  • Uses SubtypingDiscoverer to find all value-flowing expression slots and removes ref.cast and ref.as_non_null when the uncast value is already a subtype of all types required of that slot.
  • Removes ref.cast and ref.as_non_null on ref.test inputs when the outcome of the test is not determined by the input's type (otherwise leaving the test for OptimizeInstructions to resolve).
  • Preserves descriptor casts whose descriptor operand has side effects, and preserves casts on arguments to call.without.effects (which must match the parameters of the actual target rather than those of the intrinsic import).

…null assertions

In traps-never-happen mode, values flowing into typed slots (call / call_ref / call_indirect arguments, returned values, stored locals/globals, struct and array field/element writes, new allocations, select arms) and the inputs of ref.test often contain downcasts or non-null assertions where the operand already statically satisfies the slot type.

Such casts can narrow the value beyond the slot type, and that narrower type is information that refining passes (GUFA, signature-refining, type-refining, local-subtyping, cfp-reftest, ...) can use to refine the slot itself or to resolve a ref.test. Instead of removing them in OptimizeInstructions, which runs throughout the pipeline, add a standalone StripRefiningCasts pass (--strip-refining-casts) meant to run late in the pipeline:

- Removes ref.cast and ref.as_non_null on call / call_ref / call_indirect parameters, function results, locals (non-tee), globals, struct.new / struct.set fields, array.new / array.new_fixed / array.set elements, and select arms when the uncast value is already a subtype of the slot type.
- Removes ref.cast and ref.as_non_null on ref.test inputs when the outcome of the test is not determined by the input's type (otherwise leaving the test for OptimizeInstructions to resolve).
- Preserves descriptor casts and casts on arguments to call.without.effects (which must match the parameters of the actual target rather than those of the intrinsic import).
@gkdn
gkdn requested a review from a team as a code owner October 1, 2026 20:12
@gkdn
gkdn requested review from tlively and removed request for a team October 1, 2026 20:12
Comment thread src/passes/StripRefiningCasts.cpp Outdated
}

void skipCastsOnOperands(ExpressionList& operands, Type params) {
if (params.size() != operands.size()) {

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.

How can this happen?

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.

Yeah, I think this can be an assertion.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
}

void visitGlobalSet(GlobalSet* curr) {
skipCasts(curr->value, getModule()->getGlobal(curr->name)->type);

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.

It looks like these duplicate logic from src/ir/subtype-exprs.h, e.g.

  void visitGlobalSet(GlobalSet* curr) {
    self()->noteSubtype(curr->value,
                        self()->getModule()->getGlobal(curr->name)->type);
  }

Can we instead use that code, implementing a noteSubtype callback that gives us the type of the slot, which is what we need?

@tlively tlively left a comment

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.

The suggested improvements can just be TODO comments.

Running scripts/experimental/coverage-diff.py shows some missing edge cases in the tests that would be worth fixing.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
Comment on lines +85 to +86
// Removing a descriptor cast would also remove the descriptor operand,
// along with its side effects.

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.

This can be worked around by using ChildLocalizer::getChildrenReplacement to get a Block containing the children, then appending cast->ref to the end of the block (where ChildLocalizer will have updated cast->ref to be a LocalGet of a scratch local if necessary). Then the cast can be replaced with the Block.

Comment thread src/passes/StripRefiningCasts.cpp Outdated

// Skips casts on |input| while the uncast value is a subtype of |slotType|.
void skipCasts(Expression*& input, Type slotType) {
while (1) {

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.

This loop could also look through "fallthrough expressions" like blocks, loops, br_if, local.tee, etc. using Properties::getImmediateFallthroughPtr.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
}
}

void visitSelect(Select* curr) {

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.

We could even have skipCasts look through both sides of a select (or If, or Try-Catch) and remove casts on all of the joined arms.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
}

void skipCastsOnOperands(ExpressionList& operands, Type params) {
if (params.size() != operands.size()) {

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.

Yeah, I think this can be an assertion.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
}

void visitCallRef(CallRef* curr) {
if (curr->target->type.isSignature()) {

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.

It doesn't look like this is covered by the tests.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
}

void visitCallIndirect(CallIndirect* curr) {
if (curr->heapType.isSignature()) {

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 also be an assertion.

@kripken kripken left a comment

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.

Nice!

lgtn with some last comments. Also

  • Please add this pass to scripts/fuzz_opt.py around line 2760, so it gets fuzzed.

Comment thread src/passes/StripRefiningCasts.cpp Outdated
for (auto& [slot, type] : requiredTypes) {
skipCasts(*slot, type);
}
requiredTypes.clear();

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.

No need for manual cleanup in a function-parallel pass.

Suggested change
requiredTypes.clear();

@kripken

kripken commented Oct 1, 2026

Copy link
Copy Markdown
Member

Really cool how SubtypingDiscoverer gives us all the core logic here!

(local.get $ns)
)
)
;; A local.tee's type is the type of the local, so its cast is also removed.

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.

This is not true in Binaryen IR. (We fix things up in the binary writer in cases where it could cause problems.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just double checking before taking action since agent claims otherwise and I don't know any better:

In Binaryen IR, local.tee actually does have the type of the local (FunctionValidator::visitLocalSet enforces curr->type == getFunction()->getLocalType(curr->index)). I think you might be thinking of br_if, which has the type of its value operand in Binaryen IR and gets fixed up in BinaryInstWriter::visitBreak.

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.

This is something we went back and forth on iirc, so maybe it is confusing, but we use the wasm spec type here. So the tee type is the type of the local (which is not ideal).

Code:

push(builder.makeLocalTee(local, curr.value, func->getLocalType(local)));

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 yes, I was confused. It's a little weird that we follow the spec for tee but not br_if.

Comment on lines +1055 to +1056
;; while br_if keeps its cast because the br_if's own result type is the
;; cast type.

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.

The result for br_if could be improved if we optimized through fallthrough expressions.

@tlively tlively left a comment

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.

LGTM if the fuzzer is happy.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants