Skip to content

Intrinsic: dead.if.unused#8268

Open
kripken wants to merge 51 commits intoWebAssembly:mainfrom
kripken:callsIfMoved
Open

Intrinsic: dead.if.unused#8268
kripken wants to merge 51 commits intoWebAssembly:mainfrom
kripken:callsIfMoved

Conversation

@kripken
Copy link
Member

@kripken kripken commented Feb 5, 2026

Based on discussion in

#7574 (comment)

the @binaryen.dead.if.unused code annotation has the meaning that
if the result is unused (dropped), then the code can be considered
dead (no side effects, removable).

This can be used on a function to affect all calls to it, or on specific
call instructions. The optimizer then finds relevant dropped calls and
can remove them (in Vacuum).

Bikeshedding welcome on the name.

@kripken kripken requested a review from tlively February 5, 2026 16:44
src/wasm.h Outdated
// Toolchain hint: If this expression's result is unused, then the entire
// thing can be considered dead and removable.
// TODO: link to spec somewhere
std::optional<std::monostate> deadIfUnused;
Copy link
Member

Choose a reason for hiding this comment

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

Is it really better to use a std::optional rather than bool here? It doesn't look like we take advantage of the uniformity of using std::optional in any way.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it's just nice for consistency. Each hint may be present or not, and handling that property uniformly with the same C++ mechanism seems clearer?

Copy link
Member

Choose a reason for hiding this comment

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

If any of the use sites had to change, I would agree. But I'm pretty sure making this a bool is a strict simplification, even if it is obviously different from the other hints. I think "std::optional if there is associated data and otherwise bool" is still a pretty simple guideline.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fair enough, done.

Comment on lines 143 to 145
auto iter = annotations.find(target);
if (iter != annotations.end()) {
auto& annotation = iter->second;
Copy link
Member

Choose a reason for hiding this comment

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

It looks like it would be useful to have a func->getAnnotation(curr) helper of some sort to avoid mucking around with map lookups at every callsite. It could even return an empty annotations object when there is no annotation to avoid all branching logic in the caller.

It also seems somewhat magical that querying with a null expression gets the function annotations. I think ideally we would have a separate helper for that.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a nicer helper now.

};

// Look for an annotation on the call.
if (checkDeadIfUnused(getFunction(), call)) {
Copy link
Member

Choose a reason for hiding this comment

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

I'm imagining that this could be something like this:

Suggested change
if (checkDeadIfUnused(getFunction(), call)) {
if (getFunction()->getAnnotations(call).deadIfUnused) {

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, that's a good idea... I'll do some experimenting with a nicer API along those lines.

Copy link
Member Author

Choose a reason for hiding this comment

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

(done)

Comment on lines 157 to 158
// Check on the called function, if it exists (it may not if the IR is
// still being built up).
Copy link
Member

Choose a reason for hiding this comment

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

When are we Vacuuming IR that is not yet constructed?

Copy link
Member Author

Choose a reason for hiding this comment

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

In wasm2js we apparently have situations where we optimize "partial" code, before the module is complete. We used to do that in asm2wasm back in the day (compile one function before later functions were even parsed) but we got rid of asm2wasm.

Copy link
Member

Choose a reason for hiding this comment

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

Do you think it would be possible to have wasm2js stop doing that?

Copy link
Member Author

Choose a reason for hiding this comment

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

It might not be trivial. Looks like the place is processStandaloneFunction which tests use to just handle a function outside of a module. The wasm2js testing stuff is... not simple, unfortunately.

// Check on the called function, if it exists (it may not if the IR is
// still being built up).
if (auto* target = getModule()->getFunctionOrNull(call->target)) {
if (checkDeadIfUnused(target, nullptr)) {
Copy link
Member

Choose a reason for hiding this comment

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

And I'm imagining something like this here:

Suggested change
if (checkDeadIfUnused(target, nullptr)) {
if (target->getFuncAnnotations().deadIfUnused) {

Copy link
Member

Choose a reason for hiding this comment

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

Should we check round-tripping of the function-level annotation as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added.

@@ -0,0 +1,88 @@
;; RUN: wasm-opt -all --vacuum %s -S -o - | filecheck %s
Copy link
Member

Choose a reason for hiding this comment

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

Can we autogenerate the output for this one?

Copy link
Member Author

Choose a reason for hiding this comment

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

It looked into this a little but it would be a huge refactoring of the update script. The issue is that module-level things like functions are all tracked by name, but this would not be a named thing, so a very different regex is needed, and different tracking to match it up to the right thing in the output. I did some experimentation, but it got very messy... Though maybe you know that script better and have an idea?

Copy link
Member

Choose a reason for hiding this comment

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

Oh I see. That's unfortunate. I can take a look at updating the script to handle annotations. We'll definitely want that for e.g. type annotations in the future.

@dschuff
Copy link
Member

dschuff commented Feb 6, 2026

Regarding the name: do the semantics match any of the existing GCC attributes of const, pure, or reproducible? If so, maybe matching the name would be useful?

@kripken
Copy link
Member Author

kripken commented Feb 6, 2026

@dschuff Sadly I don't think they match those. gcc's pure etc are defined as variations of no observable effects on the state of the program other than to return a value, but the property here is conditional: these functions do have effects. They can only be considered effect-free if dropped.

Concretely, this is valid in gcc:

x = pure();
foo();
bar(x);

=>

foo();
bar(pure()); // pure function pushed past foo(), which it cannot interact with

But that is not valid if pure were instead dead if unused.

@tlively
Copy link
Member

tlively commented Feb 6, 2026

Maybe removable.if.unused to be more precise? It's not totally clear from the name whether dead.if.unused allows unconditionalizing calls, while removable.if.unused at least suggests that removing the call is the only thing allowed.

@kripken
Copy link
Member Author

kripken commented Feb 6, 2026

@tlively I think more than removability is possible, though. The full meaning is "has no effects if the value is not used". Perhaps effectless.if.dropped, but that feels a little verbose. pure.if.dropped might make sense but I worried it could be mixed up with gcc's pure. So I was thinking dead.if.dropped is clear enough, but yeah, maybe we can do better?

To clarify what I mean by more than removability being possible: imagine some code cannot be removed for technical, type-system or IR structure reasons. It might still be useful to know it has no effects. Not a great example, but:

(drop
  (block $block (result (ref $A))
    (br_if $block
      (local.get $value)
      (local.get $condition)
    )
    (call $dead.if.unused)
  )
)
(call $foo)

The dead.if.unused call here is dropped, but it can't be removed by itself - we need to do more optimization work (it is dropped through a block, and that block even has another branch). We do have optimizations that do it, of course, but imagine we didn't, so that chunk of code sticks around. We can at least move it past the (call $foo) at the end, if we know it has no effects, and maybe that is useful somewhere.

@tlively
Copy link
Member

tlively commented Feb 6, 2026

IIUC, we cannot move it past the call $foo in that example because that would unconditionalize its execution. This is different than a const or pure call, which would be able to be moved.

@kripken
Copy link
Member Author

kripken commented Feb 6, 2026

Ah, do you mean because the call might trap or throw? Fair enough. So amend my example to replace (call $foo) with (global.set $g (i32.const 42))

@tlively
Copy link
Member

tlively commented Feb 6, 2026

No, we couldn't move a call with this annotation past a global.set, either. The called function might trap if that global.set has been executed but not trap if it has not been executed. The assertion communicated by this annotation really is limited to "if the value is not observably used, then the call can be removed" without implying that any other special optimizations would be allowed.

@kripken
Copy link
Member Author

kripken commented Feb 7, 2026

Ok, after discussion offline I agree @tlively 's approach is better here. So I think renaming to removable.if.unused is a good idea (but will wait to do it on further discussion to avoid churn).

This also means that the optimization in this PR is the only thing the intrinsic allows: literally removing it from its current position, in Vacuum, and nothing else.

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