Yash Nagda

Blog post

Week 8: GSoC Project Progress

Hours this week: 18

Total hours: 138

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

  • Continue to work upon the optimizations to improve the performance of the mlir backend.
  • Push and merge all the open PRs for the certik/mlir and certik/corec repositories.

This week, I continued these tasks with a focus on the design of the lowering pipeline. The common idea behind all two tasks is to keep each layer responsible for one job. Platform behavior should stay in the platform layer, dialect conversion should stay in the shared LLVM lowering, and target backends should only handle the final LLVM-dialect operations needed by that target.

Based on the feedback, following tasks are assigned to me here in this issue #226.

  • Lower everything to LLVM, do not handle other dialects in the backends
  • Get the runtime performance to be comparable to Clang’s default (Debug) mode (currently 3x slower)
  • Speedup the speed of compilation to be as fast as possible (ideally 10x faster than Clang).
  • Adding more C and LLVM support all the way through (on all platform binaries)

So, I continued my work based on these issues in priority.

Task 1: Finishing the external variadic-function design

The first task was to finish the changes needed for removing the old fchmod() workaround from corec:

PR #227: Handle variable number of arguments in dialect lowering

The main issue was the handling of external variadic functions. A call such as open(path, flags, mode) is variadic, but its body belongs to the platform library and is not present in the current compilation unit. Earlier, tinyC used the same buffer-based representation for both internal and external variadic functions. This works for functions compiled together with the program, but it does not match the platform ABI expected by an external function such as open.

The new design separates the two cases:

  • for a variadic function defined inside the current compilation unit, the existing buffer-based lowering is kept
  • for an external variadic function, the arguments are passed directly to llvm.call

This avoids encoding the arguments into a temporary buffer and then decoding them again before making an external call.It keeps the platform call compatible with the native ABI without changing the behavior of internal variadic functions. Once this PR is reviewed, the related corec cleanup can be merged without keeping the earlier fchmod() workaround.

Task 2: Lower everything to LLVM before the backend

The second task is based on the first point in issue #226: lower everything to the LLVM dialect and do not handle higher-level dialects separately in every backend.

The ongoing work is available in PR #3 on the llvm-scf-lowering-1 branch.

Earlier, some scf and arith operations could reach a target backend directly. For example, the AArch64 backend had its own logic for operations such as scf.while and scf.index_switch. This made the backend larger and mixed two different jobs: converting MLIR dialects and generating target code. If the same operation reached x86, AArch64, and WasmSSA, each backend could need a separate implementation of the same lowering rule.

Before, the pipeline mixed dialect conversion with target codegen:

flowchart TB
  A["scf / arith / LLVM"] --> B["target backend"]
  B --> C["target code"]

The new design introduces one clear boundary:

flowchart TB
  A["scf / arith"] --> B["shared LLVM lowering"]
  B --> C["LLVM dialect"]
  C --> D["target backend"]
  D --> E["target code"]

The shared MLIR_LowerToLLVMDialect step now lowers structured control flow and arithmetic operations before target code generation. For AArch64, the backend-side scf handling is removed, while LLVM control-flow operations such as llvm.br and llvm.cond_br are handled by the backend. This keeps the target backend focused on translating LLVM dialect operations only into AArch64 instructions.

This design also makes the behavior more consistent across targets. A fix in the common lowering can help every backend instead of being repeated in x86, AArch64, and WasmSSA. I had few doubts for the WASMSSA backend, now that they are resolved, I am working on the same implementations for the same backend as well.

Task 3: Enum-based LLVM operation handling

The third task continues the string-to-enum refactor mentioned in the Week 7 blog. The work is available on the llvm-opt-1 branch. Earlier, several lowering passes and backends repeatedly read operation names such as "llvm.load" or "llvm.store" and compared them as strings. These comparisons happened while walking the IR, so the same textual name could be checked many times. String-based dispatch is also easy to extend incorrectly because a spelling mistake still compiles.

The main design rule is to keep operation-name strings at the boundary. The central enum is MLIR_OpType, declared in mlir_api.h, with values such as OP_TYPE_LLVM_LOAD, OP_TYPE_LLVM_STORE, and OP_TYPE_LLVM_CALL. For native MLIR text, op_string_to_type() in mlir_parser.c maps the name to this enum while parsing, and mlir_api_impl.c stores it as op.op_type. For upstream MLIR operations, mlir_api_impl_upstream.cpp maps and caches the upstream operation name. Both implementations expose the result through MLIR_GetOpType().

flowchart TB
  A["mlir_api.h declares MLIR_OpType and OP_TYPE_LLVM values"] --> B["mlir_parser.c, mlir_api_impl_upstream.cpp, or mlir_lower_to_llvm.c assigns the enum at the boundary"]
  B --> C["mlir_api_impl.c stores op.op_type in native IR"]
  C --> D["MLIR_GetOpType() returns typed operation identity"]
  D --> E["optimization passes and x86-64 backend switch on the enum"]
  E --> F["mlir_op_names.c maps back to text only when constructing or printing MLIR"]

The diagram shows the complete flow. mlir_lower_to_llvm.c uses enum values when it creates new LLVM operations. The optimization passes use MLIR_GetOpType() and switch on the returned enum instead of matching names such as "llvm.load". The x86-64 backend uses the same typed identity while selecting machine-code lowering. In the reverse direction, op_type_to_string() in mlir_op_names.c provides the canonical text only when an operation needs to be constructed or printed. Symbol names, diagnostics, and actual string data remain strings because they are data, not operation identity.

Enum comparisons are cheaper than repeatedly matching strings, but the larger benefit is a cleaner and safer pipeline. Adding a new LLVM operation now requires adding its typed identity at the parser or API boundary, after which the compiler can point to the enum cases that still need support. The branch work is complete enough to finalize the design, but it depends on the current lowering PRs. Once those PRs are merged, we can open the enum branch PR for reviews and testing.

Outcome

By the end of this week:

  • the external variadic-function fix is almost complete.
  • the AArch64 backend is being converted to an LLVM-only input boundary
  • shared scf and arith lowering is being kept outside the target backends
  • the string-to-enum design now covers lowering, selected optimization passes, and the x86 backend

The next steps are to complete the same lowering cleanup for WasmSSA, merge the current PRs in order, and then finalize and benchmark the enum-based LLVM refactor.

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.
  • Push and merge all the open PRs for the certik/mlir.

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.