Yash Nagda

Blog post

GSOC Report: Enhancing MLIR Backend for LFortran

Student: Yash Nagda
Project: Enhancing MLIR Backend for LFortran
Main codebases: lfortran/lfortran and certik/mlir
Organisation: Fortran-lang
Mentors: Ondřej Čertík, Pranav Goswami, Harshita Kalani
Weekly Blogs: https://yashnagda17.github.io/blogs/

This report is a summary of the work done during my 12-week GSoC internship. The project started with the goal of creating a stable MLIR backend for the LFortran compiler. During these weeks, I explored different backend designs and prototyped uses of the MLIR C API on LFortran branches. I then worked on the standalone certik/mlir repository to improve its APIs, lowering pipeline, native backends, and self-hosting support.

The project is not finished yet. None of the LFortran changes were integrated into LFortran; they remain branch-local prototypes that need more ASR coverage, testing, and review. However, the internship established the main backend designs, produced two substantial LFortran prototypes, strengthened the standalone MLIR implementation, and identified the most important correctness and performance work that remains.

This intern was a medium term project, with requirements of 175 hours across the 12 weeks. I have spent roughly 216 hours on this project.

Project goal

Before this work, LFortran already had an experimental MLIR backend with the following flow:

flowchart LR A["Fortran source"] --> B["AST"] B --> C["ASR"] C --> D["LLVM dialect MLIR"] D --> E["LLVM IR"] E --> F["Machine code"]

The backend used upstream MLIR C++ APIs directly. This made the path dependent on the installed MLIR version, and it also made it difficult to separate LFortran-specific lowering from upstream MLIR changes.

The goal of this project was to explore a more stable design with the following properties:

  • a small C API as the compiler-facing interface
  • a native C implementation for a lighter and faster default path
  • an upstream MLIR implementation behind the same interface for compatibility and comparison
  • clear intermediate stages that can be printed and tested
  • one common LLVM-dialect boundary before target-specific code generation
  • a design that can be expanded without rewriting the complete backend whenever ASR or MLIR changes

Work completed in brief

During the internship, I completed the following main tasks:

  • studied FIR, HLFIR, Clang IR, standard MLIR dialects, and direct LLVM-dialect lowering
  • tried direct Final ASR lowering, direct Initial ASR lowering, and a custom ASR dialect
  • designed three different ASR dialect representations before selecting a regioned design
  • generated ASR dialect schema and APIs automatically from ASR.asdl
  • prototyped the mlir-new path across the LFortran evaluator, CLI, CMake, LLVM IR, and object-file flow
  • placed native and upstream implementations behind the same LFortran-facing API in the prototype branches
  • expanded the x86-64 ELF backend from 125 to 220 passing tinyC tests out of 226
  • implemented a Linux x86-64 tinyC self-host path from linked Wasm to a standalone ELF binary
  • moved higher-level dialect lowering into one shared LLVM-lowering stage
  • replaced selected hot string comparisons with typed operation and type identities
  • implemented the first direct flat LLVM CFG to structured WasmSSA reconstruction pipeline
  • added compile-time and generated-code benchmarks
  • found and fixed several parser, LLVM lowering, WasmSSA, Wasm linker, AArch64, x64, and Mach-O issues
  • added focused regression tests and a per-file self-host comparison flow

1. Designs tried for the LFortran backend

The first part of the internship was focused on deciding where MLIR should enter the LFortran pipeline. LFortran already has a typed ASR and many ASR passes, so there was no single obvious answer.

flowchart TB D1["Design 1: Final ASR -> LLVM dialect"] D2["Design 2: Initial ASR -> standard dialects"] D3["Design 3: FIR / HLFIR study"] D4["Design 4: Custom ASR dialect"] D1 --> P1["mlir1, mlir8-llvm-lowering"] D2 --> P2["mlir2, early mlir-new"] D3 --> P3["Reference study only"] D4 --> P4["mlir7 ASR dialect prototype"]

Design 1: Final ASR directly to LLVM dialect

The first design was:

Fortran -> AST -> Initial ASR -> ASR passes -> Final ASR
        -> LLVM dialect MLIR -> LLVM IR -> object file

This was first prototyped on the mlir1 branch. The main benefit is that the existing ASR passes have already made most high-level decisions. The backend only needs to translate the simplified ASR into LLVM-dialect operations.

This design is smaller and is useful when the main aim is code generation. However, the MLIR pipeline cannot see the original ASR structure or perform higher-level transformations on it. It also makes the mapping from ASR to LLVM inside one visitor itself.

After receiving feedbacks from mentors, I returned to this direction in Week 4 and prepared the more complete mlir8-llvm-lowering prototype. This branch uses Initial ASR for its supported subset, but it reuses the main idea of emitting LLVM-dialect MLIR directly through the new C API and demonstrate how the existing LFortran LLVM module and object-file flow could be reused.

Design 2: Initial ASR to standard high-level dialects

The second design was:

Initial ASR -> func / memref / arith / scf / cf
            -> LLVM dialect MLIR -> LLVM IR

This was explored on the mlir2 and early mlir-new branches. It keeps more lowering decisions inside MLIR and gives clear stages for loops, memory, arithmetic, functions, and control flow.

The difficulty is that ASR contains a large amount of Fortran-specific information. Lowering directly to generic dialects can lose this information too early. It also means that changes in the ASR tree can affect many later lowering decisions.

Design 3: Lower ASR through FIR or HLFIR

FIR and HLFIR were alternate dialect designs to study because they are used by Flang. HLFIR exists because lowering directly from the Flang frontend representation to FIR required too many decisions in one stage. The HLFIR design keeps more Fortran semantics visible before bufferization and lower-level conversion.

I tried mapping LFortran ASR concepts to FIR and HLFIR, but this was not a good direct match:

  • LFortran ASR and Flang’s frontend representation have different nodes, types, symbol tables, and semantic information representations logics.
  • Some ASR nodes, such as ArrayPhysicalCast, do not have a direct HLFIR representation.
  • FIR is already low enough that some ASR structure is lost.
  • Translating ASR to HLFIR would require another large compatibility layer which LFortran would still need to maintain.

FIR and HLFIR were very useful references, especially for understanding why compiler decisions should be separated into stages, but reusing them directly would not be straightforward, as they are different from the ASR of LFortran. Hence, I decided to not go with this design.

Design 4: A custom ASR dialect

The next design was to introduce an explicit ASR dialect:

Initial ASR -> asr.* dialect -> high-level MLIR
            -> LLVM dialect MLIR -> LLVM IR

The ASR dialect provides an inspectable boundary between LFortran’s semantic tree and the general MLIR ecosystem. I tried three syntax and storage approaches.

This part aligns with the Fortran-level intermediate dialect described in the proposal, but the final experiment used schema-driven asr.* operations generated from ASR.asdl instead of the tentative hand-written lf.* operation list.

Approach 1: Store every ASR field directly

The first version tried to map one ASR constructor to one asr.* operation and stored every field directly. This preserved information but the output contained numeric operation ids, pointer-like references, and many internal fields. The result was not readable enough to use as a debugging IR. This made it difficult for user-handling, hence alternate approaches were tried.

"asr.variable"() {
  asr.op_kind = 221,
  asr.f.parent_symtab = 0,
  asr.f.name = "a",
  asr.f.dependencies = -1923627856,
  asr.f.intent = 0,
  asr.f.symbolic_value = 0,
  asr.f.value = 0,
  asr.f.storage = 0,
  asr.f.type = memref<3xi4>,
  asr.f.type_declaration = "",
  asr.f.abi = 0,
  asr.f.access = 0,
  asr.f.presence = 0,
  asr.f.value_attr = false,
  asr.f.target_attr = false,
  asr.f.contiguous_attr = false,
  asr.f.bindc_name = "",
  asr.f.is_volatile = false,
  asr.f.is_protected = false,
  asr.f.pass_attr = 0,
  asr.f.self_argument = "",
  asr.f.codims = 0,
  asr.f.array_len = 3
}

Approach 2: Print only the compact logical structure

The second version used symbols such as @x and printed a clean body:

asr.do_loop @i = 1 to 10 {
  asr.assign @i + 5 to @x : !asr.integer<4>
}
asr.print @x

This was readable, but it removed information such as ABI, intent, presence, storage, type declarations, and other fields which can be required during later lowering. So, here, we needed a better approach, something like a mixture of both approaches. Here, we ideally want to have a readable IR, but also want to preserve the information that can be required during later lowering.

Approach 3: Separate declarations, metadata, and body

The selected ASR dialect design separated a scope into three regions:

asr.program @print_01 {
  asr.symtab {
    // variables, types, ABI, intent, and other declarations
  }
  asr.metadata {
    // larger optional information used by later lowering
  }
  asr.body {
    // readable executable logic
  }
}

This gave a better balance between readability and information preservation. The body stays readable, while the symbol table and metadata regions retain the information needed later. Symbol references such as @x connect the readable body to the complete declaration. For logical debugging, user can only focus on the body, while the symbol and metadat regions can be used for information dump for variables, which can be useful for further lowering by the compiler.

The implementation was also designed to autogenerate with ASR. A generator-script is used to process src/libasr/ASR.asdl and produces the operation schema, ids, creation helpers, layout information, printer tables, and lowering dispatch. A separate storage policy file decides whether each field is an attribute, result type, operand, optional operand, variadic operand, region, optional region, symbol reference, or omitted field.

The resulting version-0 prototype is available on the mlir7 branch. It contains the ASR visitor, native creation and verification, pretty-printing, selected lowering handlers, prototype builds, and CLI flags to debug the ASR dialect.

Design decision after the experiments

These experiments showed that the ASR dialect and the direct path are useful for different reasons:

  • the ASR dialect is useful for preserving semantics, debugging ASR structure, and future high-level MLIR work
  • direct LLVM-dialect emission is smaller and gives a faster route to an executable backend for a limited subset

Both of the approaches have their own advantage. So, during these initial weeks of the internship, a basic version to prototype the ASR dialect was created. Although it is not used anywhere later as of now, as majority of the work was shifted to the standalone MLIR repository, this serves as a good point for the future, when the mlir repository is ready to be used for the LFortran backend. This enables to create a ASR Entry point from LFortran, which can be a separate boundary from the ASR of LFortran. This dialect can serve as an intermediate between the MLIR ecosystem and the ASR of LFortran.

2. LFortran backend prototypes

Two LFortran backend prototypes were prepared during the project. Both are available as development branches, but neither was integrated into LFortran.

ASR dialect prototype based on Initial ASR lowering

The mlir7 branch prototypes the following pipeline structure:

flowchart TB
  A["Initial ASR"] --> B["ASRToAsrDialectVisitor"]
  B --> C["ASR dialect verification and printing"]
  C --> D["ASR_DialectLowerToHighMLIR"]
  D --> E["LLVM dialect lowering"]
  E --> F["LLVM IR"]
  F --> G["Existing LFortran object-file flow"]

The main work included:

  • registering certik/mlir under src/mlir/upstream
  • adding hosted corec platform overrides for libc, memory, and platform setup
  • generating dialect lowering logic from ASR.asdl
  • implementing explicit storage policies for ASR fields
  • implementing native ASR dialect creation, verification, and pretty-printing
  • adding the ASR-to-dialect C++ visitor
  • adding selected ASR dialect to high-level MLIR lowering handlers
  • adding --backend=mlir-new and stage printing flags

The design is implemented only as a branch-local prototype, and its semantic lowering coverage is still limited. It can be treated as a version-0 structural experiment and a basis for future ASR dialect work, not as an integrated LFortran backend.

Direct LLVM-dialect prototype on from Initial ASR to LLVM dialect

The second prototype on the mlir8-llvm-lowering branch explores this path:

flowchart TB
  A["Fortran"] --> B["Initial ASR"]
  B --> C["LlvmEmitter in asr_to_mlir_new.cpp"]
  C --> D["LLVM-dialect MLIR through the C API"]
  D --> E["Native or upstream print/translate implementation"]
  E --> F["LLVM IR text"]
  F --> G["llvm::Module"]
  G --> H["Object file / executable"]

The main architectural point is that the prototype makes LFortran code make API calls to the MlirNewApi vtable. In this branch, both implementations are made into the same compiler build:

  • mlir-new-native uses the native C implementation and LFortran-owned C lowering/translation hooks
  • mlir-new-upstream uses the upstream MLIR C++ implementation with prefixed symbols
  • mlir-new is an alias for the native path

Both paths use the same ASR visitor. This makes it possible to compare the native and upstream results without maintaining two LFortran emitters. The upstream submodule stays as a certik/mlir checkout; LFortran-specific hooks and hosted platform code stay under src/mlir/lfortran/.

The current direct emitter supports a small subset of operations: integer scalars, fixed-size rank-1 integer arrays, integer constants and arithmetic, integer comparisons, logical and/or, assignments, array element loads and stores, do loops, while loops, and integer output through print or default-unit write. It does not yet represent full LFortran. The branch provides a prototype for continued backend development and stage comparison, but it has not been integrated into LFortran.

3. Work completed in the standalone MLIR repository

After briefly preparing the LFortran designs, most of the remaining work was done in the standalone mlir repository. This work targets the C API and the lower-level compilation pipeline which LFortran can reuse. This became the larger part of the internship. The main aim was to make the complete path reliable enough that later LFortran work can depend on a tested compiler stack instead of only a small API prototype.

Expanding the x86-64 ELF backend

PR #217 expanded the x86-64 ELF backend from 125 passing tests to 220 passing tests out of 226.

I first prepared an independent x86-64 backend fixes on the x861 branch. By that time, the same initial infrastructure was merged in upstream, so my first branch was not used directly. It still helped me understand the backend, and the later ELF work continued from the upstream implementation instead of keeping two versions.

The main fixes were:

  • correct short-circuit control flow for llvm.cond_br, including edge values
  • addresses of function symbols and indirect calls through function pointers
  • reachability of address-taken functions
  • pointer-global initialization from addressof initializer regions
  • llvm.intr.sqrt lowering using SSE instructions
  • platform_init() from the ELF _start path so malloc and free can use the buddy heap
  • consistent main to app_main handling in the tinyC test runner

The remaining fixes were Wasm-only, multi-translation-unit Wasm, host-libc, or special unity-runner cases. This made 220/226 the correct supported ELF set at that point.

This work also established the backend structure which was reused later. The ELF backend collects data and code symbols first, emits only the functions reachable from the selected entry point, lays out the final sections, and patches relocations after addresses are known. Conditional branches which carry successor operands use small landing blocks so the edge values are copied only on the selected path. Direct calls keep the normal E8 rel32 path, while indirect calls load the function address and call through a register. These details were important for the later self-host flow because the compiler uses function tables, callbacks, pointer globals, dynamic allocation, and floating-point operations together.

Linux x86-64 tinyC self-hosting from linked Wasm

The next task was to make this path work:

flowchart LR
  A["Linked Wasm"] --> B["wasmstack"]
  B --> C["wasmssa"]
  C --> D["LLVM dialect"]
  D --> E["x86-64"]
  E --> F["ELF"]

The driver compiles platform, buddy allocator, and WASI helpers into separate modules and splices the needed globals and functions into the lifted LLVM module. The old fixed-size symbol picker was replaced with module walks over matching llvm.func and llvm.mlir.global operations, and helpers are selected so only the runtime pieces for the chosen output remain. The x86 backend then lays out data, resolves the entry point, initializes Wasm linear memory, bridges argc and argv, initializes the host platform, emits reachable functions, and writes a standalone ELF binary without a dynamic linker. The ELF backend is also available when the compiler itself was built as Wasm, so a self-hosted tinyC can lift linked Wasm and still emit a native Linux executable.

Startup was split between native and Wasm input. A native ELF program follows host initialization. A Wasm-lifted program also reserves linear memory, builds the Wasm view of command-line arguments, initializes the host platform and buddy heap, and selects the entry point in the order app_main, main, wasi_start, and _start. Small adapters connect remaining Wasm runtime helper calls to the host functions spliced into the module.

This work exposed runtime bugs which direct backend tests had not found:

  • a Wasm i32 linear-memory offset with its high bit set was sign-extended into an invalid host pointer
  • args_sizes_get wrote host-sized values into four-byte Wasm slots
  • the startup path could select the wrong entry function
  • the Wasm-lifted ELF crt0 entered user code before buddy-heap initialization
  • helper functions, globals, and function-pointer fields needed additional parser, emitter, and linking support

After these fixes, the Week 6 self-host benchmark recorded latency of self host as follows:

Compiler speed:
tinyc built with clang -O3 -flto compiling tinyc : 23.593 s
clang -O0 compiling tinyc                         :  8.363 s
ratio                                             :  2.82x

Generated-code quality:
tinyc built by clang -O0 self-compilation         :  63.884 s
tinyc built by the tinyC ELF backend              : 198.722 s
ratio                                             :   3.11x

The detailed timing recorded in issue #225 showed that the tinyc_stage1_elf pipeline was roughly 3x slower than the Clang -O0 bootstrap path and roughly 8x slower than optimized Clang builds.

Timing result Approximate value
Complete ELF self-host run 200 s
LLVM optimization passes 100 s
ELF backend emission 30 s
Wasm backend emission 30 s

The path worked, but this split showed that LLVM optimization was the largest time-consuming step, followed by ELF and Wasm emission. This gave a clear order for later performance work.

Wasm-lifting optimizations tried and reverted

After the stable ELF path worked, I tried six speedups for the slow wasmstack -> wasmssa step:

  1. cache function and type signatures once per module
  2. look up calls by function index instead of scanning names
  3. store types as integer arrays instead of hex strings
  4. convert Wasm straight to WasmSSA, skipping wasmstack
  5. reuse one unknown source location per lifted function
  6. preallocate operand and control-flow stacks

These helped on small examples, but compiling the full linked compiler hit missing strtol and alloca support, then crashed while lifting Wasm or writing ELF. All six changes were thus not pushed and pursued further to focus on other issues first. However, they might remain a design direction for the future.

External variadic functions and platform handling

PR #227 fixed the handling of external variadic functions. Internal tinyC variadic functions continue using the existing buffer representation, while external functions such as open(path, flags, mode) pass arguments directly through llvm.call using the platform ABI.

This allowed the old fchmod() workaround to move out of the WASI adapter and kept platform-specific file behavior in the platform layer. The earlier platform-side investigation was also recorded in corec PR #39. That work was merged during Week 8.

The related driver work also separated the Mach-O and ELF Wasm-lift paths. Linux uses the hosted syscall/platform implementation, while macOS keeps the required variadic libc behavior in its platform layer. This avoided adding another target-specific exception to the common lowering or WASI adapter.

Typed MLIR API instead of repeated strings

PR #228 adds support to have a typed ENUM representations instead of strings in the backend, for llvm dialect. It improves performance by replacing repeated string comparisons to typed operation and type identities.

The main operation flow is:

flowchart LR
  A["Text parser / upstream MLIR operation"] --> B["MLIR_OpType"]
  B --> C["MLIR_GetOpType()"]
  C --> D["Lowering passes and target backends"]
  D --> E["Text name only when printed or constructed"]

The native implementation stores the enum directly. The upstream implementation maps upstream operations into the same representation. Four LLVM optimization passes and the x86 backend were migrated first, giving a small reviewable path before changing every backend.

The work also added structured handling for integer and floating-point literals, including values wider than 64 bits, and fixed pointer address spaces in llvm.mlir.addressof.

After review, the type design was generalized. Instead of an specific API only for the LLVM dialect, a type now has a general kind together with the dialect which owns it. Common literal behavior was moved into a shared implementation used by both native and upstream APIs, and helpers which exposed internal details were removed from the API. So, external modules can’t work on the internal details of the types and operations directly, they can only access information through the API.

The operation and type paths now have the following intended shape:

flowchart LR
  A["MLIR text or upstream operation"] --> B["Parser / upstream adapter"]
  B --> C["Typed operation, type, and literal identity"]
  C --> D["Common lowering passes and target backends"]
  D --> E["Textual names only for printing and diagnostics"]

It gives the native C implementation and upstream MLIR implementation one common representation at the point used by passes and target backends. It also makes unsupported cases visible as missing enum or type handling instead of allowing slightly different string logic in each backend.

Parser and emitter gaps found during API testing

PR #229 contains smaller fixes found while testing other tasks:

  • registered arith.extf and arith.truncf operations
  • function-pointer and pointer-to-pointer struct fields
  • inline function-pointer declarations, including void-returning functions
  • enum constants in global array initializers
  • global array length inference from initializer values

These changes were needed by the self-host task and tests are also registered for the same.

All code is converted to LLVM Dialect before lowering to target backends

The main lowering design was tracked in issue #226 and PR #232.

Earlier, AArch64 and WasmSSA could receive a mixture of scf, arith, and llvm operations. This duplicated lowering rules in each backend. The new design is:

flowchart TB
  A["scf / arith / cf"] --> B["Shared lowering in mlir_lower_to_llvm.c"]
  B --> C["Verified LLVM dialect CFG"]
  C --> D["x86-64, AArch64, WasmSSA, or another target backend"]

This keeps each platform backend responsible for target code generation instead of also converting unrelated high-level dialects.

The shared lowering was expanded to cover scf.if, scf.while, scf.index_switch, cf branches, arith.index_cast, and the arithmetic operations required by the runtime sources. AArch64-side scf and arith cases were then removed, and the target backends were modified to lower LLVM terminators such as llvm.br, llvm.cond_br, and llvm.switch.

The early implementation used local pattern recognition for if, loop, and switch shapes. That was useful for proving the path, but the full compiler graphs required a real normalization pipeline. The difficult part was WasmSSA. SCF and WebAssembly use nested if, loop, and block structures. LLVM uses basic blocks connected by arbitrary branches. Once SCF is lowered to LLVM, the original nesting structure is gone and must be reconstructed from the CFG.

The direct LLVM-to-WasmSSA pipeline was implemented in stages:

flowchart LR
  M4["CFG snapshot"] --> M5["M5 return normalization"]
  M5 --> M6["M6 reduce-form repair"]
  M6 --> M7["M7 cycle normalization"]
  M7 --> M8["M8 branch planning"]
  M8 --> M9["M9 emission"]
  • CFG snapshot: collect blocks, terminators, successor slots, edge operands, reachability, and dense block ids without changing the source IR
  • M5 return normalization: combine same return operations in a block as a single exit operation
  • M6 reduce-form repair: record values defined in loops and used outside them
  • M7 cycle normalization: find strongly connected components and normalize loop entries, backedges, latches, exits, and loop-carried values
  • M8 branch planning: use dominance and continuation analysis to plan structured conditions and switches
  • M9 emission: emit the final nested wasmssa.block, wasmssa.loop, and wasmssa.if operations

The implementation uses an immutable CFGInfo snapshot and a separate NormalizedCFG. This makes graph transformations easier to reason about and avoids mutating MLIR blocks while analysis is still using them.

The first CFG snapshot records each terminator, successor slot, edge operand, predecessor, reachability state, and dense block id. Edges are identified by source block and successor slot because two edges between the same blocks can pass on different values. M5 creates shared synthetic exits for same return operations inside the block. The M6 step is used to find values defined in a loop and used outside it. M7 uses strongly connected components, reverse postorder, and dominance information to create one structured entry, latch, and exit interface for every cycle. M8 uses dominance and continuation planning to make conditional and switch arms single-exit. M9 finally combines the normalized edge operands to WasmSSA block, loop, and if results.

Once the above fixes were in place, testing on full compiler graphs found more bugs, which were then resolved. After the LLVM-only path was complete, the old CF-to-SCF and SCF-dependent Wasm route was removed.

By Week 11, the first complete path was working in Linux and Windows CI and the old SCF-dependent Wasm lowering was removed. Mach-O self-hosting was still too slow, which later prompted me to debug the bugs more carefully, across all layers, which made me find more bugs in the pipeline.

Bugs found while validating LLVM to WasmSSA

I spent the last 2-3 weeks on comparing C, LLVM, WasmSSA, AArch64, and Mach-O artifacts for all self-host source files as well as debugging the issues found. This found several bugs outside the main structurization algorithm as well:

  • mem2reg emitted arith.constant inside otherwise LLVM-only IR; it now emits llvm.mlir.constant
  • in the Mach-O backend, leaving an if at else or end updated the expected stack height but not the actual values stored on the stack
  • long decimal literals overflowed the accumulator in parser and produced wrong double constants
  • sizeof(char) used the i32 storage width instead of one byte
  • unsigned-char pointer and integer casts used sign extension instead of zero extension
  • llvm.zext i8 to i32 was incorrectly treated as a no-op in WasmSSA lowering
  • AArch64 did not handle llvm.intr.wasm.memory.size
  • AArch64, x64, and native LLVM IR paths did not handle llvm.intr.wasm.memory.grow
  • file-scope const globals were emitted as mutable globals
  • loop continue could bypass the normalized latch dispatch
  • unnamed block arguments were printed as unrelated %N values instead of %argN
  • an empty llvm.switch could omit the transfer to its default target
  • parsed linked wasmssa.* operations could remain unregistered and silently lose function bodies during the next lowering stage
  • the Wasm linker needed fixes for object layout, table slots, and __heap_base
  • local group storage needed dynamic growth for large modules

Tests are added for the above bugs identified in the scf-wasmssa PR. Once that PR work is complete, I shall split the fixes into smaller PRs for review. For Week 12, I generated and compared the LLVM, WasmSSA, and Mach-O output for each self-host source file instead of debugging only the final crash. This made it possible to find the stage where the first incorrect value, operation, or control-flow transfer appeared.

Compiler benchmark

PR #233 added a shared workload derived from LFortran’s parse3.cpp. It compares Clang, a small compiler containing only the required tinyC pipeline, and the full tinyC driver on the same generated C program.

The updated parse3.cpp-style benchmark results are:

Compiler Compile time (ms) Runtime (ms)
Clang 2487.613 0.798
Clang -O0 2510.281 0.965
Clang -O3 -march=native 4793.038 0.482
Small compiler (minimal compiler) 1268.100 1.115
Full compiler (normal tinyC compiler) 1170.895 0.975

The main comparison is the full compiler. This row uses tinyc_native_opt, which is the normal tinyC compiler built with Clang using -O3 -DNDEBUG -flto -march=native. Thus, the tinyC compiler itself is optimized with -O3 -DNDEBUG -flto -march=native, so the exact build enables LTO and disables debug assertions. It then compiles the generated parse3.cpp-style workload through the normal native MLIR and ELF path.

For this workload, the full compiler generated the executable faster than all three Clang configurations. Its generated program ran close to Clang -O0, while optimized Clang produced the fastest runtime.

Code contributions and review status

The main LFortran work is available in these branches:

  • mlir1 — first Final ASR to LLVM-dialect prototype
  • mlir2 — Initial ASR to high-level MLIR prototype
  • mlir4 — early ASR dialect syntax experiments
  • mlir6 — policy-driven ASR dialect and native storage work
  • mlir7 — cleaned version-0 ASR dialect prototype
  • mlir8-llvm-lowering — direct LLVM-dialect prototype with native and upstream implementations

The main standalone MLIR contributions are:

Contribution Result recorded during GSoC
PR #217: x86-64 ELF backend Created; 220/226 applicable tinyC tests passing
PR #227: external variadic lowering Merged
PR #228: typed operation/type API Submitted and revised after review
PR #229: parser and emitter fixes Submitted with the focused parser and emitter fixes
PR #232: LLVM-only backend boundary and LLVM CFG to WasmSSA WIP during the final weeks
PR #233: compiler benchmark Submitted

The report uses the contribution status recorded in the weekly posts. PRs which were still under review should be checked again before submitting the final public work-product link.

Work remaining

The main next steps are:

  1. finish Mach-O self-host execution and correctness validation
  2. split the LLVM-to-WasmSSA correctness fixes into smaller reviewable PRs
  3. finish review of the dialect-independent typed API and measure later changes with the same benchmark
  4. improve the performance of the native backend
  5. expand the LFortran LlvmEmitter to functions, conditionals, reals, strings, arrays, derived types, and runtime I/O
  6. decide where the ASR dialect should remain optional and where direct lowering is sufficient
  7. optimize the linked-Wasm lifting and backend stages after the full-workload correctness checks are stable

Final outcome

This internship did not produce a mature MLIR backend as an alternate for LFortran’s existing LLVM backend. It was an exploratory task to produce the groundwork needed to build one in a more maintainable way.

At the LFortran level, the work produced two branch-local prototypes: a version-0 ASR dialect experiment and a direct LLVM-dialect experiment with native and upstream implementations behind the same API. Neither prototype was integrated into LFortran. At the standalone MLIR level, the Mach-O backend is much more complete, the linked-Wasm self-host path works far enough for benchmarking, higher-level dialects have a common LLVM lowering boundary, and the first full LLVM CFG to structured WasmSSA path is implemented.

Most of the concrete implementation is in the standalone MLIR repository. The ELF and external-variadic changes, the typed-API and parser work, the benchmark, the LLVM-only backend boundary, and the direct WasmSSA structurizer together form the main technical outcome of the internship.

This work taught me a lot about compiler IR design, MLIR dialects, control-flow structurization, instruction selection, native object formats, self-hosting, and performance analysis. Progress was sometimes slower than expected because many failures appeared only at a later stage or only with the full compiler workload. The feedback from my mentors and the LFortran and certik/mlir communities was very helpful in understanding these problems and improving the designs.

I shall continue working on this project after GSoC, first focusing on correctness and review of the current lowering work, and then developing one of the LFortran prototypes toward integration and improving performance.

I spent about 216 hours on this project, which is more than the 175 hours required for the medium term project. I would like to thank my mentors, Ondřej Čertík, Pranav Goswami, Harshita Kalani & other contributors for helping me to work on this project. Their valuable feedback and suggestions were very helpful for me to work during the internship.

Weekly reports