Backend Lowering Architecture
Status: Normative Last Updated: 2026-01-19
Informative References
Commentary: For accessible explanation of the two-layer model and why certain constructs require backend-specific dialects, see Why F# Is A Natural Fit for MLIR on the SpeakEZ blog.
For .NET Developers: Guidance on transitioning from CLR concepts to native compilation is available in the rationale documentation.
1. Overview
This chapter specifies how Clef lowers high-level constructs to backend-specific representations using MLIR’s multi-dialect architecture.
2. The Two-Layer Model
Clef uses a two-layer intermediate representation:
F# Source → CCS (Clef Compiler Service) → PSG → Alex → MLIR (mixed dialects) → Backend → Native Binary
↑
Portable + Backend-Specific2.1 Portable Dialects
Operations that are semantically backend-independent use MLIR’s portable dialects:
| Dialect | Purpose | Operations |
|---|---|---|
func | Function structure | func.func, func.call, func.return |
cf | Control flow | cf.br, cf.cond_br, cf.switch |
scf | Structured control | scf.while, scf.for, scf.if |
arith | Arithmetic | arith.addi, arith.constant, arith.cmpi |
These dialects can lower to multiple backends: LLVM, SPIR-V, WebAssembly, custom hardware.
2.2 Backend-Specific Dialects
Operations that commit to a specific representation require backend-specific dialects. For LLVM targets:
| Category | Operations | Reason |
|---|---|---|
| Function pointers | llvm.mlir.addressof, indirect llvm.call | Pointer representation varies by backend |
| Struct manipulation | llvm.insertvalue, llvm.extractvalue, llvm.getelementptr | No portable heterogeneous struct type |
| Memory operations | llvm.load, llvm.store, llvm.alloca | ABI-dependent alignment |
3. Dialect Selection Rules
3.1 Use Portable Dialects When
- The operation has no representation dependency on the target
- The function is called directly by name (not through a pointer)
- Control flow is structural (branches, loops, conditions)
- Operations are arithmetic (add, compare, etc.)
3.2 Use Backend-Specific Dialects When
- Taking a function’s address for storage or indirect call
- Manipulating heterogeneous structs (records, closures, unions)
- Operations have ABI implications (memory layout, alignment)
- Platform-specific intrinsics (syscalls, atomics)
4. Flat Closure Pattern and Backend Dialects
Clef implements closures, lazy values, and sequences using flat closures that store function pointers in structs. This pattern requires backend-specific code.
4.1 Why Backend-Specific
The flat closure pattern requires:
- Taking a function’s address: backend-specific operation
- Storing address in struct: backend-specific struct manipulation
- Calling through stored pointer: backend-specific indirect call
There is no portable MLIR representation for “pointer to function”:
| Backend | Function Pointer Mechanism |
|---|---|
| LLVM | !llvm.ptr + llvm.mlir.addressof |
| SPIR-V | Function tables, OpFunctionPointer |
| WebAssembly | Function indices, call_indirect |
4.2 Correct Dialect Usage
// Thunk function - LLVM dialect (address will be taken)
llvm.func private @lazy_thunk(%struct_ptr: !llvm.ptr) -> i64 {
// Struct access - LLVM dialect
%cap_ptr = llvm.getelementptr %struct_ptr[0, 3] : !llvm.ptr -> !llvm.ptr
%cap = llvm.load %cap_ptr : !llvm.ptr -> i64
// Arithmetic - portable dialect (valid in llvm.func body)
%result = arith.addi %cap, %cap : i64
llvm.return %result : i64
}
// Entry point - func dialect (called by name)
func.func @main() -> i32 {
// ...
func.return %ret : i32
}4.3 Dialect Mixing Rules
MLIR allows mixing portable and backend-specific operations within function bodies:
func.callinsidellvm.func: Valid. Anllvm.funccan call afunc.funcusingfunc.call.llvm.calltarget restriction:llvm.callcan only call functions defined asllvm.func.- Portable ops in any function:
arith.*,cf.*,scf.*operations work in bothfunc.funcandllvm.funcbodies.
5. Entry Point Example
In freestanding mode, _start is an llvm.func (its address may be taken by the linker), but it calls main which is a func.func:
llvm.func @_start() -> i32 {
// Read argc/argv via inline asm (LLVM-specific)
%argc = llvm.inline_asm "mov (%rsp), $0", "=r" : () -> i64
%argv = llvm.inline_asm "lea 8(%rsp), $0", "=r" : () -> !llvm.ptr
// Call main - uses func.call since main is func.func
%result = func.call @main(%argc, %argv) : (i64, !llvm.ptr) -> i64
// Exit syscall (LLVM-specific)
llvm.inline_asm has_side_effects "syscall", "..." %result : ...
llvm.unreachable
}6. Platform Configuration
The project file specifies the target platform:
[compilation]
target = "x86_64-unknown-linux-gnu"
[platform]
word_size = 64
endianness = "little"This configuration flows through:
Fidelity.Platformselects the appropriatePlatformDescriptor- CCS uses platform info for type layouts and intrinsic typing
- Alex selects appropriate backend dialect usage
- Backend receives correctly-lowered IR
7. Normative Requirements
- Portable Operations SHALL use portable dialects: Control flow, arithmetic, and directly-called functions use
func,cf,scf,arithdialects - Address-taken functions SHALL use backend dialects: Functions whose address is taken use the backend’s function definition (e.g.,
llvm.func) - Struct operations SHALL use backend dialects: Record, union, and closure struct manipulation uses backend-specific operations
llvm.calltarget restriction:llvm.callSHALL only call functions defined asllvm.func; to call afunc.funcfromllvm.func, usefunc.call- Platform configuration flow:
fidprojplatform settings SHALL inform all lowering decisions
See Also
- Program Semantic Graph - PSG structure consumed by Alex
- Type Representation Architecture - NativeType and TypeConRef
- Closure Representation - Flat closure memory layout
- Lazy Representation - Lazy as extended closure
- Platform Bindings - Platform descriptor and syscalls
- Native Type Mappings - Type-to-layout mapping