Repository navigation
Conversation
|
I guess this won't help with the fixups necessary in #9289 because those are fixing up pops that have been moved out of leftmost-leaf position. But this direction makes sense to me! |
|
Meanwhile my fuzzer found and fixed another nested pop bug in RemoveUnusedBrs. I won't bother uploading the fix since this will make it obsolete soon. |
|
Nice idea! I only skimmed the code but I like this approach. Will there be cases that some blocks needlessly have names (without targeting branches)? Do we have a pass that removes those unnecessary names, or all the optimization passes that create nested pops ensure they only create unnamed blocks? |
The very common case is |
|
Ok, fuzzed and manually checked, this is ready for review. |
There was a problem hiding this comment.
Code Review
This pull request refactors the handling of pop instructions in WebAssembly exception handling. Previously, various optimization passes attempted to manually fix up pop instructions when they were moved into blocks, often by hoisting them into local variables. The new approach simplifies this by allowing pop instructions to reside within unnamed blocks, which is valid in the binary format, and updating the IR builder and validation logic to support this. The changes remove the now-unnecessary EHUtils::handleBlockNestedPops utility and associated state tracking from multiple optimization passes, significantly cleaning up the codebase.
| break; | ||
| } | ||
| auto* block = scope.getBlock(); | ||
| if (!block || block->name || !scope.exprStack.empty()) { |
There was a problem hiding this comment.
In Binaryen, Name objects should be checked using the .is() method rather than directly as a boolean, to ensure consistency and avoid potential compilation or implicit conversion issues. Using block->name.is() is also consistent with how it is checked in src/ir/eh-utils.cpp in this same pull request.
if (!block || block->name.is() || !scope.exprStack.empty()) {There was a problem hiding this comment.
Hmm, the current code is consistent with other places in this file, but we don't have consistency in other files, that is true. Separately from this PR, we should consider which way to standardize on.
There was a problem hiding this comment.
I definitely tend not to use .is().
tlively
left a comment
There was a problem hiding this comment.
I had Gemini look for stale comments in the tests:
- test/lit/passes/code-folding-eh-legacy.wast:376-377 (";; ... so we need a fixup for the pop.")
- test/lit/passes/dae2.wast:3432-3433 (";; ... This is invalid, so we must fix it up.")
- test/lit/passes/dce-eh-legacy.wast:199-200 (";; ... which needs to be extracted out of the block at the end.")
- test/lit/passes/constraint-analysis-eh.wast:63-64 (";; ... This adds a block we need to fix up EH for.")
- test/lit/passes/directize_all-features.wast:2048-2049 (";; ... We emit a block with the pop, and must handle that properly.")
And in the code:
- src/ir/eh-utils.h:35-38 ("// ... fixes up 'pop's nested in blocks, which are currently not supported without block param types ...") — should qualify that this now only applies to named blocks.
- src/ir/eh-utils.cpp:124-130 ("// Change code like ... (catch $e ... (block (pop i32)))") — the example diagram still shows an unnamed block, which is no longer modified by handleBlockNestedPop().
- src/wasm/wasm-ir-builder.cpp:52-55 ("// ... generates synthetic Pop Expressions and performs fixups for pops within nested blocks (handleBlockNestedPops).") — fixups are only for named blocks now.
- src/wasm/wasm-ir-builder.cpp:164-167 ("// ... help us determine later whether we need to run the nested pop fixup.") — the synthetic block created here is unnamed, so it no longer triggers a nested pop fixup.
- src/wasm-ir-builder.h:538-542 ("// ... possible that the pop is inside the synthetic block and we should run the fixup ...") — same as above; synthetic blocks are unnamed and never need fixup now.
It also noticed that we could remove the nested pop fixup in DAE2.
There was a problem hiding this comment.
It looks like none of these tests actually require a fixup anymore. We should add some named blocks to continue testing the fixup.
There was a problem hiding this comment.
Ah, I thought that wasn't possible, but I realize a manual named block achieves the goal... good point, added now.
| EHUtils::handleBlockNestedPops(curr, *getModule()); | ||
| } | ||
| } | ||
| void ReFinalize::visitFunction(Function* curr) {} |
There was a problem hiding this comment.
| void ReFinalize::visitFunction(Function* curr) {} |
This is already the default implementation.
| if (!scope.getCatch() || scope.exprStack.size() != 1 || | ||
| !scope.exprStack[0]->is<Pop>()) { | ||
| // type as the Pop we have already made, and move it into the current scope if | ||
| // we have entered unnamed blocks since the catch began. |
There was a problem hiding this comment.
I don't think we have any way of knowing whether a block will be named in advance. Consider this function:
(func $oops
try
catch $i32
block
pop ;; block looks unnamed, so we move the generated pop inside it
drop
br 0 ;; synthesizes a name for the block
end
end
)Do we still run the pop fixup later? If so, it's probably fine to move the pops speculatively and let them get fixed up later. We'll end up with a scratch local either way. It might be worth calling that out in the comments, though.
There was a problem hiding this comment.
Good point, yes, the block is currently unnamed but may get named later. The function-level fixups that happen afterwards will handle that case (line 1192). I'll add a comment to clarify.
The |
Did Gemini run on the wrong branch? That text is updated in this PR: |
|
The code suggestions look like they are on valid code... but I disagree with them, after reading through them all. I think "fix up pops in blocks" is clear enough, we don't need to add "named" there, that is an internal detail. Though the example should have a named block, I fixed that. |
I don't think that is correct! I undid it here: The trampolines are named. |
Huh, possibly. Sorry for the noise :( |
The named trampolines are only created to wrap blocks that were also named in the input, so they should never contain pops. |
|
Sounds good about the trampolines in DAE2, I didn't realize they only happened in that situation. I fixed that. |
| break; | ||
| } | ||
| auto* block = scope.getBlock(); | ||
| if (!block || block->name || !scope.exprStack.empty()) { |
There was a problem hiding this comment.
We should check that the block's declared input type is a supertype of the pop's type, too.
There was a problem hiding this comment.
Wait, we don't require that, do we? This is valid:
(catch $e-i32
(throw $e-i32
(block (result i32)
(pop i32) ;; pop is within an *un*named block, which is valid
)
)
)The block has no input type.
There was a problem hiding this comment.
It's valid when parsed into IR, but only because IR doesn't know about block input types. If pop were a real instruction with type [] -> [] (like a nop, which is how we emit it), then this would not be valid WAT. It's probably fine if you don't want to change this here, but I might end up changing it in the PR that optimizes block inputs. Can you add a test case demonstrating the current behavior?
There was a problem hiding this comment.
Ah, requiring the block input parameters would make round-tripping through text tricky. We'd have to start printing input parameters on blocks containing pops from Print.cpp. Let's definitely not require this here, then.
| // we have entered unnamed blocks since the catch began. (Note that the block | ||
| // may receive a name later, if we encounter a break to it, if we process | ||
| // that after the pop. In that case, the fixups that run later will have work | ||
| // to do.) |
There was a problem hiding this comment.
There's a lot of overlap between pushing pops down into unnamed blocks and pushing block input scratch local gets down into unnamed blocks. In both cases, the unnamed blocks must have the proper input types for the code to be valid. In both cases, we're taking a synthetic instruction we've already generated and moving it into a new block.
One difference is that pop can appear in test source code when we round-trip WAT, so in addition to generating it in the right place, we also have to validate its position in the input. It's unclear to me whether we also want to preserve pop positions in the IR. For example, consider this WAT:
try
catch $i32
block (param i32) (result i32) ;; A
pop
block (param i32) (result i32) ;; B
end
end
endFor block input scratch locals, we would always want to move the local.get into the most deeply nested block possible. If we do the same for pop, the parsed pop would be at the beginning block B above, even though the WAT has it at the beginning of block A.
On one hand, parsing pop with its position matching the input gives us more control over how the WAT gets parsed when we write tests.
On the other hand, parsing pop in the innermost valid location is always optimal and matches what we want to do for block input scratch locals. This would also let us share almost all the code for the scratch local placement and pop placement. It wouldn't regress tests that are supposed to directly show the structure of the popped IR because such tests do not have blocks with input parameters to begin with.
If we always parse pop in the innermost valid location, and we eventually share that code with the code for optimizing scratch local placement, then we're still left with makePop being responsible for validation of the input pop. Validating that the input pop is also in the optimal location would be complicated because it would require reading ahead to see if there is a more optimal location after the pop. I think it makes most sense to allow the input to contain a pop as the first child of any unnamed nested block, not just the innermost one.
I don't think this all affects this PR much, but the PR to optimize block input scratch locals will probably replace most of this new code, so I want to make sure we're aligned on direction.
There was a problem hiding this comment.
I agree this might get complicated later, but I hoped we can deprecate legacy EH before that... but I guess that did not take into account your plans for block input scratch locals...
I'd be ok changing this later to allow pops in more places, if that is helpful.
| (func $stacky-named-block | ||
| try | ||
| catch $e | ||
| ;; Add a named block here, forcing a fixup using a local to capture the pop. |
There was a problem hiding this comment.
The scratch local is added when IRBuilder sees the block input parameter, not because the fixup runs.
It would be good to add a test where the block is not named but is targeted by a later branch. That case will definitely need the fixup, at least once we stop creating block input parameters in cases where there is already a pop available.
There was a problem hiding this comment.
Thanks, this is tricky as the two fixups look very similar... but in the last commits I think I figured out how to test both.
| pop i32 | ||
| nop | ||
| drop | ||
| br 0 |
There was a problem hiding this comment.
Please add a version of this test without the br as well.
Uploading a mostly-AI draft to get feedback if we think this is a good direction.
If so I can edit and refine before asking for actual review.
This makes us accept multiple blocks around a pop:
This is fine in the binary format, since such blocks vanish. It may look a
little weird in the text format, but I think it is worth allowing this to validate,
as it lets us remove almost all the calls to
handleBlockNestedPop():almost all that our passes do is to emit unnamed blocks. So this removes
a large amount of code, and a constant source of friction when writing new
passes.
I suspect this is easier now than before, since in the past we used to
emit named blocks more often. At some point we made the parser do
more work to avoid that. A nice side effect of that is that it makes this
PR possible.
Thoughts @aheejin @tlively ?