Repository navigation
Description
To generate Wasm from RyuJIT we need a way to map our more general control flow to the limited control flow constructs available in Wasm.
Wasm represents control flow by having branches target specific blocks, where the block types are block and loop (with some syntactic sugar ones that decompose to block). Branches either travel to the start of a loop block or to the end of a non-loop block.
As a result, Wasm constrains control flow as follows:
- Any forward branches must be contained by the
blockthey target.
At minimum, this means that before you generate a forward branch you need to know its destination and wrap that branch in ablockso you can target it with your branch. You need to ensure that block ends at the appropriate place. - Any backward branches must be contained by the
loopthey target.
At minimum, this means that you must know the location of all the backward branches in a method along with their targets, to ensure that you open aloopblock at the backward branch target. You don't necessarily need to end that block in a particular place, since branches target its start instead of its end.
For control flow we can't represent using block and loop natively, we have to generate one or more loops with a dispatcher switch (br_table) at the top that performs a forward branch to the actual destination. This would turn a backward branch into a combination of backward-branch-to-dispatcher and forward-branch-to-destination, and require the use of a temporary local to select the destination. This is similar to the way roslyn-generated MoveNext methods work.
Preliminary Plan
We assume for now that finallys / filters / catches will be funclets. NAOT-LLVM does not use funclets for catches. If we end up doing the same, likely not a big deal to cope with this, as catches still only reachable via exceptions.
Convert all irreducible “normal” flow into proper loops
- Find sccs #120534 is one way we could do this, using a dispatch block. Note there are lots of really ugly CFGs out there, especially OSR + Async cases. Expect Async2 will give us similar wonky flow.
- We will need a “no exceptional flow” loop finding algorithm; should be a relatively simple tweak on what we have now
- We may want to do a similar dispatch trick for finally cloning; right now we only clone along one path out of the try; this would let us handle all paths without more code duplication.
- Run SCC fixing late, then use loop-aware RPO to get a good block ordering
- Also stress mode that runs SCC fixing earlier (like the PR above) and in normal jitting
- We may find cases where fixing the SCCs improves codegen, so might want to be able to run it as part of jitting for other targets too (though likely with a different fixing approach; see notes below about optimized modifications).
Tile this block ordering with WASM control flow structures
- In the short term we can just dump this out as text / wasm “assembly”
- Optionally leverage dominance/postdominance to try and maximize use of if/else
- LLVM/Wasm seems to be fairly aggressive in their use of select to eliminate branches, even doing costly seeming speculative evaluation. Will probably just rely on our current if-conversion here. May want to prioritize enabling if-conversion in loops?
Evaluate if more "optimized" approaches to resolving irreducible flow/SCCs make sense
- Defer until clearly needed, eg once we can profile certain key apps.
- Some prior art: Janssen and Corporaal; Ramsey; Unger and Mueller.
Work so far
- [Wasm RyuJIT] Wasm control flow basics #121417
- [Wasm RyuJIT] Implement Wasm DFS and Loop Finding #121457
- Wasm irreducible loop transformation #121728
- [Wasm RyuJit] Control flow codegen #121973
- [Wasm RyuJit] Slightly smaller code for switches #122198
- [Wasm RyuJit] Codegen labels #122332
- [Wasm RyuJit] Fixes for some issues in codegen bringup tests #122483 (revised how codegen labels work)
- Wasm various fixes #122627 (enable SCC transform in LIR)
- [Wasm RyuJit] throw helper / null check preliminaries #123053
- [Wasm RyuJit] Fix emission of block and label instructions #123522
- [RyuJit Wasm] fix off by one error for br_table #123565
- JIT: remove anticipated demand for throw helpers #123781
- [Wasm Ryujit] Flow cannot fall through into a throw helper #123908
- Wasm loop aware rpo compute last block in loop quickly #125101
- [Wasm RyuJit] eh flow #125594
- JIT: yet another pass of unreachable block removal #126223
- [Wasm RyuJit] transform multi-entry try regions #126374
- [Wasm RyuJit] fix funclet prolog and epilog codegen #126663
- Wasm R2R: implement support for resume after catch #129391
Above seems to be holding up pretty well, but there are still quite a few loose ends to tie up
- Once we finalize the EH model we'll need to make adjustments, if any of the things that are traditionally funclets end up "inline" in the method (and right now that seems likely)
- Similar to above we bail out now if there is non-funclet code only reachable via EH. Once we show how this becomes reachable, we'll need to adjust assumptions in the code (handled via [Wasm RyuJit] eh flow #125594)
- We need to run the final ordering after LSRA and probably before codegen, but codegen may be able to introduce new blocks. So we need to figure out how to place the actual final layout or else make sure codegen does not in fact create new blocks (or does so in a way that does not invalidate the up front nesting computations). (handled via JIT: remove anticipated demand for throw helpers #123781)
- If we need contiguous try regions (maybe?) we will need a variant of loop-aware RPO that is also try-aware. This seems viable provided loops and trys do not overlap in strange ways. It's not clear if we can guarantee this. But given that we'll need to make the EH state external then perhaps contiguous trys are less of a concern? (handled via [Wasm RyuJit] eh flow #125594)
- [done] We actually need to reorder the blocks and tie in the interval data somehow, so the wasm control flow finding can be done up front and then the emission is interleaved with block codegen
- [done] Handle the "needs branch inversion case"
- Decide if we're ok with current switch lowering or want something possibly a bit smaller.
- Streamline computation of loop extents when building Loop-aware RPO
- [done] Seems like we now expand throw helpers early enough that they will not cause us problems
- [done] Proper integration with the new Wasm target.
Remaining Work
Codegen
- proper
catch_refandthrow_refWasm emission (Wasm R2R: implement support for resume after catch #129391)- need the proper tag value for the exception the runtime will throw
- need to drop the exception for the non-rethrow cases
- block sig for the block that wraps the
try_tableshowing it pushes the exnref.
- funclet prolog/epilog and in-body register assignments ([Wasm RyuJit] fix funclet prolog and epilog codegen #126663)
- epilog should be empty
- prolog just establishing SP & FP, and exception object for filter/typed catch
- ensure stack is walkable by the runtime ([Wasm RyuJit] EH, Unwind, and unwindable frames #127043)
- set up
fp[0]in methods/funclets with EH or GC - figure out what this points to (function index)
- set up
- set up Virtual IP (at say
fp[4]) and assign suitable values in codegen ([Wasm RyuJit] EH, Unwind, and unwindable frames #127043)- encode EH table offsets via virtual IP
- resolve whether various things that need the IP can share easily: EH clause determination, GC info indexing, continuation ID for catch resumption
- eh info emission ([Wasm RyuJit] EH, Unwind, and unwindable frames #127043)
- in-codegen finally invocations (direct call, fixups) ([Wasm RyuJit] codegen for callfinally #128348)
JIT host
- funclet extraction as separate Wasm functions crossgen2: Extract funclets in WasmObjectWriter #128736
- these will share EH/GC info with their parent method, so also virtual IP
- JIT will likely still want to produce one data stream for method, but we can consider alternatives
- host will need to carve this up, and resolve fixups properly
- fixups so JIT code and data can refer to funclets Add Wasm EH R2R infrastructure #129197
- fixups so code can refer to the unwind exception tag Add Wasm EH R2R infrastructure #129197
Runtime
- stack walking for prejitted frames Use Virtual IPs in R2R format #128423
- funclet invocations (finally/fault/filter/catch) Add Wasm EH R2R infrastructure #129197, Add support for generating pointers to funclets, and calling funclets by index #128222
- (not EH specific, but) global-sp establishing wrappers for native helpers [Wasm] Write barriers #128225
Activity
[-]Basic Control Flow[/-][+][Wasm RyuJIT] Basic Control Flow[/+]AndyAyersMS commentedon Oct 31, 2025
More General Finally Cloning
Some high-level notes on what we need to do to eliminate all finally clauses from jitted code.
Multiple Continuations
The current finally cloner picks one path out of a try/finally and clones the finally along that path. If there are multiple paths then the remaining paths continue to invoke the finally that is shared with the exceptional path. A canonical example is something like this:
where after the finally executes (normally) the method may return or print goodbye.
Typically the "fall through" path at the end of the try is the one chosen for cloning. So we transform the code to something that can't really be expressed in C#, but is something like:
One option we could pursue is to clone for each path. This may involve a fair amount of code duplication:
Another is to create a shared normally-executed finally, and track where it should go afterwards via a state variable. This is conceptually similar to what we plan to do for irreducible loops:
Finally with nested EH
The original work for finally cloning was done long before the JIT had the ability to duplicate/clone EH. Now that we have that ability, we should be able to handle cases where the finally itself contains EH regions. Given that these finallys are likely quite large the shared normally-executed case seems sensible.
Finally nested within EH
Likewise the current phase does not clone finallys that are within some other handler. This is the flip side of the condition above, and should also be something we can now handle.
Latent introduction of finally clauses
If the goal is to eliminate all finally clauses from the generated code, we may need to run this phase more than once. RuntimeAsync or similar transformations may want to introduce try/finally to ensure that certain post-dominating actions happen even if exceptions are thrown.
SingleAccretion commentedon Oct 31, 2025
Is this for performance or correctness? I don't think it is needed for correctness, since you can still express finallys as funclets by spilling all finally-live variables to memory (among other approaches).
AndyAyersMS commentedon Nov 1, 2025
More of a thought exercise at this point... just curious how much work is behind doing something like this. It's more than I thought. I'd forgotten about the nesting/nested limitation.
[Wasm RyuJIT] Wasm control flow basics
[Wasm RyuJIT] Implement Wasm DFS and Loop Finding
[Wasm RyuJIT] Wasm control flow basics (#121417)
[Wasm RyuJIT] Implement Wasm DFS and Loop Finding (#121457)
Wasm irreducible loop transformation (#121728)
[Wasm RyuJit] Control flow codegen
[Wasm RyuJit] Control flow codegen
[Wasm RyuJit] Control flow codegen (#121973)
AndyAyersMS commentedon Dec 4, 2025
Nesting of Loops and Trys
We plan to use the Wasm
try_tableinstruction to regain control mid-method just after (or perhaps just before) a .NETcatchexecutes in the method during EH second-pass unwind. Thetry_tablemust properly nest with any loops, and we know of cases like #95748 (comment) where such nesting is potentially problematic (and there are likely more).The actual .NET EH Processing will rely on some externalized bit of EH state that the method will maintain as control flow moves in and out of EH regions. So there is no need for a
try_tableto map 1-1 to .NETtry/catch.A possible solution is to find try regions that intersect loops and split them. The catch clauses for the Wasm protected regions will be stylized, just looking for whatever host type we'll use to signal unwinding, so duplication of these catches shouldn't cause much code bloat. But this gets messy if the catch resumes inside the loop instead of exiting the loop as in the above example, as the split off try we put outside the loop must transfer control back into the middle of the loop. And if there are multiple outside-loop inside-try blocks we might need one split off try for each.
Another possibility is to wrap the entire method in a single
try_tableand have the catch block dispatch to the appropriate post (or pre) catch block in control flow, depending on the EH state (and if pre-catch, also fetching the exception object from external storage). These "edges from start" will then naturally create multiple-entry loops for catches that resume in loops. And these can be handled by the SCC transformations we've already implemented. If a catch resumes in a deeply nested loop this will cause a lot of control flow churn to properly direct the flow from all the way outside the nest, but hopefully such cases are rare.If there are no try/catches then no wrapping is needed. If an exception happens when such a method is active then the only code in the method that can execute is found in filters/finallys (which will be funclets), so there are no in-method resume points.
Using one wrapping
try_tablealso means that .NET try regions need not be lexically contiguous in the method, which also simplifies things somewhat.AndyAyersMS commentedon Dec 5, 2025
If we use a single wrapping
try_tablewe may also need to enter .NET trys in the middle (if a catch is in a try and resumes inside it). Typically this entails more flow tunnelling like OSR does, but if we do this expansion late (like async2 does) perhaps having multiple try entry points is ok, provided we properly set the external EH state.dotnet-policy-service commentedon Feb 10, 2026
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.
AndyAyersMS commentedon Mar 10, 2026
I don't think this is going to be viable because of structured control flow limitations. While can expand the extent of the
try_tableto encompass multiple try regions the post-catch continuation labels must be on the wasm control flow stack, eg for a try with two continuations we'd need something like this:but it is entirely possible c1 needs to be in the protected region (it could be the continuation from some inner try catch that is inside some outer try).
So continuations may both need to be outside a protected region (to be reachable via forward branches) and inside a protected region (so the right unwinding happens).
We might be able to rig up something with loops that allows the outside part to branch back to the above the try_table and then tunnel through it (similar to the multi-entry loop dispatch).
But it seems simpler just to have one try_table per try/catch. We can flatten this to one per mutual protect try/catch.
This pushes the complexity back onto the code layout algorithm. Instead of just having a loop-aware RPO, we must also have a try-aware RPO, where each try region now remains contiguous. Since a try may begin inside loop and end outside it, we cannot have both try regions contiguous and loop bodies contiguous, something has to give. Since try region extent is significant for EH, and loop
endcan generally be pushed later in control flow, the try constraint prevails.This means that the loop region
endnow must extend down past the last block in the try. We can do this provided there is no block in the try that is not dominated by the loop header, so we can always branch from within the loop to wherever that block is without having to arrange for tunnelling through the loop header. This seems like it should be true for the most part, since try regions are single-entry (so the try entry dominates all blocks in the try) -- with the possible exception of runtime async, which breaks this invariant.AndyAyersMS commentedon Mar 10, 2026
Assuming we do this then EH will look something like the following:
So note that no frame popping happens in the second pass until the native exception is thrown; all the funclets execute with all the same frames on the shadow stack as they were at the time the exception was raised.
Also the native exception will be caught and rethrown by each active try/catch that is not going to handle the exception.
For funclets:
Each method with EH will have a frame pointer that can be found during unwinding; it is either the value at $sp[0] (if that value is a stack address), or $sp (if a stack address).
The initial $sp should be captured upon entering the native code to the runtime as part of raising an exception (since all exceptions are raised via calls) and saved to the global __stack_pointer so any native code does not clobber the managed frames.
AndyAyersMS commentedon Mar 11, 2026
Runtime async will introduce multiple entries into trys. Given the above, if we now have one
try_tableper try/catch, for Wasm we will need to tunnel these entries through the trys enclosing the side entries via yet another control variable. Possibly we can reuse the control variable runtime async already uses, or perhaps more simply, add this as a mode of the runtime async transformation.AndyAyersMS commentedon Mar 18, 2026
#125594 contains the core of the implementation for EH in codegen.
Still to do in codegen:
catch_refandthrow_refWasm emissiontry_tableshowing it pushes the exnref.fp[0]in methods/funclets with EH or GCfp[4]) and assign suitable values in codegen ([Wasm RyuJit] EH, Unwind, and unwindable frames #127043)In jit host:
In runtime:
[Wasm RyuJit] eh flow (#125594)
AndyAyersMS commentedon Jun 19, 2026
Think we can call this one done.
Specific failures/bugs will be tracked separately.