Yash Nagda

Blog post

Week 9: GSoC Project Progress

Hours this week: 19

Total hours: 157

At the end of the eighth week, I was working on the following tasks:

  • Continue to work upon the optimizations to improve the performance of the mlir backend.
  • Complete the WasmSSA refactoring for scf dialects.
  • Complete the string-to-enum refactor for the LLVM dialect.
  • Push and merge all the open PRs for the certik/mlir.

At the end of the eighth week, the common lowering path could convert structured control-flow operations such as scf.if, scf.while, and scf.index_switch into LLVM dialect operations. The next task was to make the WasmSSA backend use the same LLVM-only boundary instead of keeping a separate path that directly handled scf operations.

This week, I focused on this scf lowering work. The main difficulty was not lowering an individual operation. It was handling the structural difference between scf, LLVM, and WebAssembly (WasmSSA). This difference caused many compilation failures while moving the WasmSSA backend to an LLVM-only input.

The main structure issue: nested SCF and Wasm versus flat LLVM blocks

scf and WasmSSA represent control flow using nested structures. An scf.if owns then and else regions, and an scf.while owns its condition and body regions. WasmSSA follows a similar model using nested block, loop, and if constructs. A branch in WasmSSA does not jump to an arbitrary label. It exits or repeats one of the surrounding nested constructs using a branch depth.

The LLVM dialect represents the same control flow differently. Inside an llvm.func, the control-flow blocks are siblings in one flat region. Operations such as llvm.br and llvm.cond_br connect these blocks using successor edges. Values that cross an edge are passed as successor operands and received as block arguments, similar to LLVM phi values. The original if, while, or switch nesting is no longer stored directly in the IR.

SCF / Wasm structure

scf.while {
  before {
    scf.condition
  }
  do {
    scf.yield
  }
}

LLVM dialect structure

^entry:
  llvm.br ^header(%initial)

^header(%value):
  llvm.cond_br %condition,
                ^body(%value),
                ^exit(%value)

^body(%body_value):
  llvm.br ^header(%next)

^exit(%result):
  llvm.br ^continuation(%result)

This created an important asymmetry in the pipeline. Lowering scf to LLVM is straightforward because a nested region can be expanded into blocks and branches. Lowering from LLVM to WasmSSA is harder because the nesting information has already been flattened. The WasmSSA backend has to inspect the LLVM control-flow graph, recognize the block pattern produced by the common lowering, and reconstruct the correct nested wasmssa.if, wasmssa.loop, or nested switch tree.

flowchart TB CF["Flat cf.br / cf.cond_br"] LIFT["CF to SCF lift
normalize control flow"] SCF["Nested scf.if / scf.while
scf.index_switch"] LOWER["Shared SCF to LLVM lowering"] LLVM["Flat LLVM CFG
llvm.br / llvm.cond_br"] REBUILD["WasmSSA CFG walker
recognize merges and backedges"] WASM["Nested wasmssa.if / loop / block"] CF --> LIFT --> SCF --> LOWER --> LLVM --> REBUILD --> WASM

The work follows the design discussed in issue #226: higher-level dialects should be lowered in one common place, and target backends should receive LLVM dialect operations only.

Lowering all SCF operations before WASMSSA

Earlier, the Wasm path used a keep_scf mode. Arithmetic and function operations were lowered to LLVM, but scf.if, scf.while, and scf.index_switch were kept for mlir_llvm_to_wasmssa.c to lower directly. This made the WasmSSA backend different from the x86 and AArch64 backends and left two implementations of structured-control-flow lowering to maintain.

The new path removes this exception. MLIR_LowerToLLVMDialectForWasm now lowers all scf and cf operations before calling the WasmSSA backend. A verifier checks that only llvm.* operations remain. The intended boundary is now:

high-level dialects
        -> shared lowering in mlir_lower_to_llvm.c
        -> LLVM dialect CFG only
        -> target backend

For WasmSSA, this meant that mlir_llvm_to_wasmssa.c needed a real multi-block CFG walker. The walker records whether a block is unseen, active, or already consumed so the same block is not emitted twice. It also keeps a stack of stop blocks. A stop can represent an if merge, a loop header reached by a backedge, or a block where phi-like values need to be bound.

Structural design issues faced during lowering

The main issues came from translating nested scf and WebAssembly structures through a flat LLVM control-flow graph:

  • Lost nesting information: Lowering scf to LLVM turns nested regions into sibling basic blocks, so the backend must recover which blocks belong to each structured construct.
  • Merge discovery: Early exits and nested conditions mean that the two branches of an if do not always reach one obvious merge block.
  • Loop recognition: A loop is represented only by its header, condition edges, backedge, and exit blocks after it has been flattened to LLVM CFG.
  • Structured branch targets: LLVM branches name arbitrary successor blocks, while WebAssembly branches refer to surrounding block and loop depths.
  • Loop-carried and result values: scf region arguments become LLVM successor operands and block arguments, which must be mapped back to structured loop and block results.
  • Nested control flow: An if inside a loop or another if becomes one connected flat graph, so reconstruction must work recursively instead of matching only immediate successors.
  • Multi-way control flow: A structured switch can become an LLVM switch or a chain of comparisons and branches, but WebAssembly needs a structured block or nested condition tree.
  • Consistent lowering boundary: Mixing surviving scf regions with already flattened LLVM branches creates an ambiguous intermediate form, so the backend must receive one consistent representation.
  • CFG ownership: The walker must know when a block belongs to the current construct, an enclosing construct, or the continuation, otherwise blocks can be emitted twice or attached at the wrong nesting level.

Based on these factors, it was difficult to implement the exact solution. As discussed with my mentors and with their guidance, a few designs are implemented in the cf to scf lift step. Hence, I am now proceeding with the implementation of this step based on the design present here.

The fchmod follow-up was merged

Alongside the SCF-lowering work, PR #227: Handle variable number of arguments in dialect lowering was merged. This completes the compiler-side change needed for removing the old fchmod() workaround from corec.

The merged design keeps the existing buffer-based variadic representation for functions defined inside the current compilation unit. External variadic functions such as open are lowered directly to llvm.call, with their arguments passed using the platform ABI. This avoids packing external arguments into tinyC variadic buffers and then decoding them again before the host call.

Outcome

Based on this week, although it was a difficult week, I was able to learn a lot about the compiler lowering process and the dialects involved here. I am now proceeding with the implementation of the reconstruction of the nested control flow through the logic defined in the cf to scf lift step.

Next Steps

  • Continue to work upon the optimizations to improve the performance of the mlir backend.
  • Complete the WasmSSA refactoring for scf dialects.
  • Complete the string-to-enum refactor for the LLVM dialect.

Acknowledgments

A huge thanks to my mentors, Ondřej Čertík, Pranav Goswami, Harshita Kalani & other contributors for helping me to work on this project.