Skip to content

[IRBuilder] Tunnel block inputs into nested scopes - #9337

Open
tlively wants to merge 13 commits into
mainfrom
ir-builder-tunnel-params
Open

tlively wants to merge 13 commits into
mainfrom
ir-builder-tunnel-params

Conversation

@tlively

@tlively tlively commented Nov 1, 2026

Copy link
Copy Markdown
Member

When entering a nested control flow scope whose input parameters are scratch local.gets from the enclosing scope's inputs, reuse the enclosing scope's scratch locals directly instead of allocating new scratch locals. Share the movement logic in pushScope with moving legacy EH pops into nested unnamed blocks.

Instead of using a single tuple-typed scratch local for multivalue control flow inputs, allocate one scalar scratch local per input value. This avoids eagerly packaging scalar inputs into a tuple on scope entry. It also avoids allocating extra tuple scratch locals to extract elements when inputs are consumed inside the scope.

Scalar input locals do have some downsides. Scopes that pass all of their inputs through untouched must now repackage them with tuple.make. Multivalue loop trampolines in fixLoopWithInput also still need a tuple scratch local to unpack branch values into the scalar input locals. However, real parameterized scopes usually consume their inputs as individual values. Scalar input locals can also be coalesced directly by standard local passes without tuple optimization, and they will allow individual parameters to be tunneled across nested scopes with different arities in a future commit.
When entering a nested control flow scope whose input parameters are scratch local.gets from the enclosing scope's inputs, reuse the enclosing scope's scratch locals directly instead of allocating new scratch locals. Share the movement logic in pushScope with moving legacy EH pops into nested unnamed blocks.
@tlively
tlively requested a review from a team as a code owner November 1, 2026 07:30
@tlively
tlively requested review from aheejin and removed request for a team November 1, 2026 07:30

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes input parameter handling in IRBuilder::pushScope by allowing pops to move into unnamed blocks and reusing scratch locals from enclosing scopes instead of allocating new ones. It also simplifies pop validation in IRBuilder::makePop and adds comprehensive tests for block tunneling and misplaced pop parse errors. The review feedback points out a critical correctness bug in pushScope where isInputGet fails to verify that the LocalGet index matches the parent local's index, which could trigger an assertion failure or cause incorrect optimization. Additionally, the reviewer suggests wrapping the parameter matching loop in an if (hasInput) block to prevent potential out-of-bounds indexing of Type::none when hasInput is false.

Comment thread src/wasm/wasm-ir-builder.cpp Resolved

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Google Antigravity Code Review

I have reviewed the changes in this Pull Request and left inline feedback.

Comment thread src/wasm/wasm-ir-builder.cpp Resolved
Comment thread src/wasm/wasm-ir-builder.cpp Resolved
Base automatically changed from ir-builder-input-locals to main November 3, 2026 19:39
@aheejin

aheejin commented Nov 4, 2026

Copy link
Copy Markdown
Member

It looks this also contains changes in #9336. Are you going to rebase?

# Conflicts:
#	src/wasm/wasm-ir-builder.cpp
#	test/lit/control-flow-input.wast
#	test/lit/control-flow-input.wast.wasm
@tlively

tlively commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

Yes, merged latest changes now.

@tlively

tlively commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Friendly ping @aheejin

@aheejin aheejin 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.

  1. Can we split the pop handling code and the scratch local tunneling code? They don't share much other than pushSynthetic at the end, and to me merging the two code paths makes it harder to read.

  2. Currently this code is accepted without an error:

(module
  (tag $e (param i64))
  (func $test
    try
    catch $e
      block (param i32)  ;; This should error out because the tag type is i64
        drop
      end
    end
  )
)
  1. Can we have a test case that tests double tunneling too, so that we can confirm we only create a single scratch local for the below?
(func $block-double-tunnel (result i32)
  i32.const 0
  block (param i32) (result i32)
    block (param i32) (result i32)
      block (param i32) (result i32)
      end                                                                                                                                                                                                                         
    end                                                                                                                                                                                                                           
  end                                                                                                                                                                                                                             
)    

@tlively

tlively commented Nov 11, 2026

Copy link
Copy Markdown
Member Author
  1. Can we split the pop handling code and the scratch local tunneling code? They don't share much other than pushSynthetic at the end, and to me merging the two code paths makes it harder to read.

Yes, this makes things much simpler. Good idea.

  1. Currently this code is accepted without an error:
    (module
    (tag $e (param i64))
    (func $test
    try
    catch $e
    block (param i32) ;; This should error out because the tag type is i64
    drop
    end
    end
    )
    )

Good catch, fixed.

  1. Can we have a test case that tests double tunneling too, so that we can confirm we only create a single scratch local for the below?

(func $block-double-tunnel (result i32)
i32.const 0
block (param i32) (result i32)
block (param i32) (result i32)
block (param i32) (result i32)
end
end
end
)

Done, and added some other interesting test cases as well.

@tlively
tlively requested a review from aheejin November 13, 2026 00:27

@aheejin aheejin 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.

In the PR description

Share the movement logic in pushScope with moving legacy EH pops into nested unnamed blocks.

doesn't seem to be true anymore given that now we separated the paths.

Comment on lines +904 to +906
// Tunnel catch pops directly into unnamed blocks. Allow this even when the
// block has no input type so we can round-trip WAT without the printer
// needing to synthesize block input types, as well as when the block's input

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.

so we can round-trip WAT without the printer needing to synthesize block input types

What does this mean? Was there any case the printer needed to synthesize block input types? Were we doing this before?

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.

My initial idea was to require unnamed blocks surrounding pops to have parameters like this:

(catch $i32
  (block (param i32)
    (pop i32)
  )
)

The problem with requiring this is that the Printer would then have to print the (param i32) on the unnamed block so the printed WAT could be parsed. We don't want that extra complexity in the printer, and we do want to be able to round-trip through WAT, so we need to allow pops without the params on the surrounding unnamed blocks.

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.

(catch $i32
  (block (param i32)
    (pop i32)
  )
)

It would be helpful to have this example in the comment.

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.

Comment thread src/wasm/wasm-ir-builder.cpp Outdated
Comment on lines +938 to +940
// Earlier inputs that allocate new scratch locals (e.g. due to type
// widening) are copied before this scope starts, which safely reorders
// scratch `local.get`s past any subsequent reused inputs.

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 don't understand what this sentence means. Can you elaborate or give an example?

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.

See $block-tunnel-subtype-mixed in control-flow-input.wast. I've tried to clarify the comment.

Comment thread src/wasm/wasm-ir-builder.cpp Outdated
auto expectedType = pop->type;
if (!Type::isSubType(expectedType, type)) {
auto expectedType = scope.exprStack[0]->type;
if (type != expectedType) {

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.

Do we disallow subtypes? We were allowing them, no?

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.

Yes, we were allowing them previously. We can continue allowing them now that pop has a separate control flow path, so I've restored the previous behavior.

Comment on lines -1184 to -1215
;; CHECK-TEXT: (func $pop-can-be-supertype (type $0)
;; CHECK-TEXT-NEXT: (try
;; CHECK-TEXT-NEXT: (do
;; CHECK-TEXT-NEXT: )
;; CHECK-TEXT-NEXT: (catch $e-eqref
;; CHECK-TEXT-NEXT: (drop
;; CHECK-TEXT-NEXT: (pop eqref)
;; CHECK-TEXT-NEXT: )
;; CHECK-TEXT-NEXT: )
;; CHECK-TEXT-NEXT: )
;; CHECK-TEXT-NEXT: )
;; CHECK-BIN: (func $pop-can-be-supertype (type $0)
;; CHECK-BIN-NEXT: (try
;; CHECK-BIN-NEXT: (do
;; CHECK-BIN-NEXT: )
;; CHECK-BIN-NEXT: (catch $e-eqref
;; CHECK-BIN-NEXT: (drop
;; CHECK-BIN-NEXT: (pop eqref)
;; CHECK-BIN-NEXT: )
;; CHECK-BIN-NEXT: )
;; CHECK-BIN-NEXT: )
;; CHECK-BIN-NEXT: )
(func $pop-can-be-supertype
(try
(do)
(catch $e-eqref
(drop
(pop anyref) ;; pop can be supertype
)
)
)
)

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.

Why was this deleted? I guess this is related to disallowing subtypes in makePop, but I'm not sure why we disallow them. And if we have a reason to disallow, I think we should note this behavior change in the PR description. Or even better, a separate PR.

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.

Yep, restored.

Comment on lines +141 to +183
;; CHECK: 8:11: error: pop instructions may only appear at the beginning of catch blocks
(module
(tag $e (param i64))
(func $in-mismatched-param-block
(try
(do)
(catch $e
(block (param i32)
(pop i64)
)
)
)
)
)

;; CHECK: local.set's value type must be correct
(module
(tag $e (param i64))
(func $implicit-mismatched-param-block
(try
(do)
(catch $e
(block (param i32)
(drop)
)
)
)
)
)

;; CHECK: block with value and last element with value must match types
(module
(tag $e (param eqref))
(func $implicit-supertype-param-block (result eqref)
(try (result eqref)
(do
(ref.null eq)
)
(catch $e
(block (param anyref) (result eqref))
)
)
)

@aheejin aheejin Nov 13, 2026 •

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 CHECK error messages for these three cases are misleading because, in the first case, the real reason is the type mismatch (i32 != i64), and the second and the third cases involve scratch locals that users aren't aware of.

I get why the messages are printed this way, and I'm not suggesting to fix them here, but just noting them. (I'm also not sure if these are worth fixing for the sake of error messages, if fixing these involves some tricky code restructuring. Anyway, we shouldn't fix them here in this PR)

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.

Yep, agreed. I've added TODOs about better error messages.

@tlively
tlively requested a review from aheejin November 13, 2026 18:47
Comment thread src/wasm/wasm-ir-builder.cpp Outdated
Comment on lines +938 to +943
// Earlier inputs that allocated new scratch locals (e.g. due to type
// widening) had their values copied to those new scratch locals before
// the start of this scope, and have already had local.gets of those new
// scratch locals pushed in this scope. It is still safe to reuse this
// later input's scratch local because local.gets can always be safely
// reordered.

@aheejin aheejin Nov 13, 2026 •

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.

Maybe it's only me, but I still wouldn't have understood this comment without you pointing to the example. I think one reason I feel this is confusing is, this comment is describing a specific case (like $block-tunnel-subtype-mixed) where earlier inputs were widened and thus using new scratch locals and the later locals were reused, but this sounds like that's what the code below is doing in general. But the code below works in all general cases, including when earlier inputs are reused and instead later inputs get new scratch locals, etc. So it would've been helpful if this sentence had started like "Even in the case earlier inputs that ~"

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.

Makes sense. Clarified further.

@aheejin aheejin 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 % comment suggestions

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Google Antigravity Code Review

I have reviewed the changes in this Pull Request and left inline feedback.

bool hasPop =
getScope().exprStack.size() == 1 && getScope().exprStack[0]->is<Pop>();
if (isUnnamedBlock && hasPop &&
(scope.inputType == Type::none ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL | Confidence: 5/5 | BUG]

Unconditionally tunneling the Pop into an unnamed block when scope.inputType == Type::none assumes that any unnamed block at the beginning of a catch is meant to consume or contain the Pop. In WebAssembly, a block with Type::none input ([] -> [...]) does not pop any value from the value stack; values on the stack prior to entering the block remain on the outer stack and are preserved across the block.

If a catch block begins with an unnamed block that does not contain a pop instruction (such as an unnamed block performing computation or side-effects before the exception payload is used or dropped, e.g., (catch $e (block (nop)) (drop))):

  1. pushScope removes Pop from catchScope.exprStack and pushes it into the unnamed block's exprStack.
  2. When the block ends, Pop is included in Block::list as an unused, un-dropped non-final expression, violating Binaryen IR invariants ("non-final block elements returning a value must be dropped") and failing validation.
  3. The enclosing catch scope loses Pop from its stack, causing subsequent operations in the catch scope that consume the payload (e.g., drop or arithmetic) to fail with a parse or type error (e.g. popping from an empty stack).

Moving the Pop eagerly when scope.inputType == Type::none is premature because pushScope runs before any inner instructions are visited. Tunneling in pushScope should only occur when the block explicitly declares input parameters matching the pop (scope.inputType != Type::none), whereas handling (pop ...) within blocks that have no input parameters should be handled lazily when makePop is called.

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.

Hmm, this is a good point actually. This perfectly valid wat fails to parse:

(module
  (tag $i32 (param i32))
  (func (result i32)
    (try (result i32)
      (do
        (i32.const 0)
      )
      (catch $i32
        (block ;; synthetic pop moves into this block
          (nop)
        )
        ;; but it should have stayed in the outer scope
        ;; and been hoisted to here with a scratch local
      )
    )
  )
)

I guess I should fix this before landing 😢

@aheejin aheejin Nov 14, 2026 •

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.

Can we fix this without just removing this pop part in this PR and restoring what #9292 did, meaning, can we tunnel pops unconditionally in pushScope without hitting this bug?

If not we can just remove the pop handling part in this PR. Even if we end up with something else with pop, I think it's still a good idea to extract the pop thing out of this PR, because, at this point, tunneling scratch locals is just separate thing from tunneling pops. Whichever way, we can land this now without the pop thing.

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.

I was going to fix it by requiring the unnamed blocks to have input parameters and fixing the Printer to emit those input parameters as well, but you're right that we can also just leave the pop handling the way it was before this PR. I'll do that.

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.

2 participants