Skip to content

[Wasm RyuJIT] Basic Control Flow #121178

Closed
Closed

Description

@kg

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 block they target.
    At minimum, this means that before you generate a forward branch you need to know its destination and wrap that branch in a block so 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 loop they 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 a loop block 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

Work so far


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

JIT host

Runtime

Activity

  1. added
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Oct 29, 2025
  2. changed the title [-]Basic Control Flow[/-] [+][Wasm RyuJIT] Basic Control Flow[/+] on Oct 29, 2025
  3. added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    and removed
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Oct 29, 2025
  4. added this to the 11.0.0 milestone on Oct 29, 2025
  5. AndyAyersMS commented on Oct 31, 2025

    @AndyAyersMS
    Member

    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:

    void Foo(int p)
    {
        try
        {
            if (p == 0)
            {
                return;
            }
        }
        finally
        {
             Console.WriteLine("Hello, World!");
        }
    
        Console.WriteLine("Goodbye, World!");
    }

    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:

    // clone just one path
    
    void Foo(int p)
    {
        try
        {
            if (p == 0)
            {
                goto CallFinally;
            }
    
            goto ClonedFinally;
        }
        finally
        {
             Console.WriteLine("Hello, World!");
        }
    
    ClonedFinally:
        Console.WriteLine("Hello, World!");
        Console.WriteLine("Goodbye, World!");
        goto Return;
    
    CallFinally:
        // invoke finally shared with EH path
    
    Return:
        return;
    
    }

    One option we could pursue is to clone for each path. This may involve a fair amount of code duplication:

    // clone along all paths
    
    void Foo(int p)
    {
        try
        {
            if (p == 0)
            {
                goto ReturnPath;
            }
        }
        fault
        {
             Console.WriteLine("Hello, World!");
        }
    
        Console.WriteLine("Hello, World!");
        Console.WriteLine("Goodbye, World!");
        return;
    
    ReturnPath:
    
        Console.WriteLine("Hello, World!");
        return;
    
    }

    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:

    // shared clone
    
    void Foo(int p)
    {
       int state = -1;
        try
        {
            if (p == 0)
            {
                state = 0;
                goto AfterTry;
            }
            state = 1;
        }
        fault
        {
             Console.WriteLine("Hello, World!");
        }
    
    AfterTry:
    
        // Cloned finally
        Console.WriteLine("Hello, World!");
    
        // Jump to proper continuation
        switch (state)
        {
           case 0: goto Return;
           case 1: goto Goodbye;
           default: FailFast();
        }
    
    Goodbye:
    
        Console.WriteLine("Goodbye, World!");
    
    Return:
        return;
    }

    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.

  6. SingleAccretion commented on Oct 31, 2025

    @SingleAccretion
    Contributor

    what we need to do to eliminate all finally clauses from jitted code.

    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).

  7. AndyAyersMS commented on Nov 1, 2025

    @AndyAyersMS
    Member

    what we need to do to eliminate all finally clauses from jitted code.

    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).

    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.

  8. AndyAyersMS commented on Dec 4, 2025

    @AndyAyersMS
    Member

    Nesting of Loops and Trys

    We plan to use the Wasm try_table instruction to regain control mid-method just after (or perhaps just before) a .NET catch executes in the method during EH second-pass unwind. The try_table must 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_table to map 1-1 to .NET try/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_table and 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_table also means that .NET try regions need not be lexically contiguous in the method, which also simplifies things somewhat.

  9. AndyAyersMS commented on Dec 5, 2025

    @AndyAyersMS
    Member

    If we use a single wrapping try_table we 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.

  10. dotnet-policy-service commented on Feb 10, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
    See info in area-owners.md if you want to be subscribed.

  11. AndyAyersMS commented on Mar 10, 2026

    @AndyAyersMS
    Member

    Another possibility is to wrap the entire method in a single try_table and have the catch block dispatch to the appropriate post (or pre) catch block in control flow, depending on the EH state

    I don't think this is going to be viable because of structured control flow limitations. While can expand the extent of the try_table to 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:

    begin // push label c0
      begin // push label c1
        begin // push label continuation dispatch
          try_table () catch <tag> 0
            protected region
            br 2 // normal continuation
          end
        end
          // reachable only via exception 
          // code for continuation dispatch
          load EH state var
          br_table 0 1
        end
        // code for c1
    end
    // code for c0
    
    

    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 end can generally be pushed later in control flow, the try constraint prevails.

    This means that the loop region end now 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.

  12. AndyAyersMS commented on Mar 10, 2026

    @AndyAyersMS
    Member

    Assuming we do this then EH will look something like the following:

    1. An exception is raised (by calling a native method, see below)
    2. First pass. Runtime walks the native (aka shadow) stack looking for the catch handler. For each managed frame on the stack, the runtime will
      • Determine the frame pointer
      • Read the Virtual IP slot from the frame
      • Use the Virtual IP to search the EH table to find an EH clause, if any
      • If the clause has a filter, invoke the filter funclet, else if a catch, check the catch type
      • If this clause is not handling, find the enclosing clause in this method (if any) and repeat until a handling clause is found or there are no more enclosing clauses
      • If no handler is found in this frame, set this frame's "EH Continuation" slot to the unhandled value -1
      • If unwinding finds an interpreter frame on the native stack, run unwinding for those frames specially (interpreter frames are not linked on the native stack); resume with shadow stack walking if no handler is found and there are more prejitted frames on the stack below the interpreter frame
    3. Second pass. Runtime walks the native stack and unwinds
      • execute all the finally funclets in all clauses in all frames down to the catching frame.
        • if an exception happens here...
      • execute the catch funclet. This funclet will set the "EH Continuation" slot in the catching frame to the proper continuation before it returns (for "native EH" the funclet returns the continuation and the runtime deals with it -- we could do that as well and have the runtime update the continuation slot with this value).
        • if an exception happens here...
      • the runtime will throw a native (javascript) exception to actually pop the non-catching frames off the stack
      • this will be caught in succession by all the active try/catches try_tables
      • for those try_tables that are not supposed to handle the exception, the EH continuation slot will hold -1 or, in the catching frame, be out of the valid range for that try_table. These will all rethrow the native exception.
      • the handling try will then use the EH continuation value to transfer control to the proper continuation.

    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:

    • the eh table entry will have the function pointer index of the funclet
    • filter funclets will have signature (int (sp), int (fp), int (ex)) -> (int)
    • catch funclets will have signature (int (sp), int(fp), int(ex)) -> (void)
    • finally funclets will have signature (int (sp), int (fp)) -> (void)

    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.

  13. AndyAyersMS commented on Mar 11, 2026

    @AndyAyersMS
    Member

    Runtime async will introduce multiple entries into trys. Given the above, if we now have one try_table per 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.

  14. AndyAyersMS commented on Mar 18, 2026

    @AndyAyersMS
    Member

    #125594 contains the core of the implementation for EH in codegen.

    Still to do in codegen:

    In jit host:

    In runtime:

    • stack walking for prejitted frames
    • setting continuation control var in pass 1 for frames that are not catching the exception (we may be able to avoid this via codegen)
    • funclet invocations (finally/fault/filter/catch)
    • (not EH specific, but) global-sp establishing wrappers for native helpers
  15. added a commit that references this issue on Mar 20, 2026
  16. AndyAyersMS commented on Jun 19, 2026

    @AndyAyersMS
    Member

    Think we can call this one done.

    Specific failures/bugs will be tracked separately.

  17. locked and limited conversation to collaborators on Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

arch-wasmWebAssembly architecturearea-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions