This document explains how the compiler gets a .pic source file all the way down to LLVM IR:
how the initial picceler-dialect MLIR is built, what passes run on it, in what order, and why
that order matters.
Audience: contributors working on the compiler's front end or lowering passes. The pass
sections assume familiarity with MLIR concepts (dialects, dialect conversion, RewritePatterns,
ConversionTarget).
For what the ops/types themselves mean, see docs/dialect-reference.md —
this document is about pipeline structure, not op-level semantics.
Before any pass gets involved, three stages turn source text into the picceler-dialect MLIR
module the pass manager will run on:
- Lexer (
src/lexer.cpp) — turns the raw source text into a flat stream ofTokens (identifiers, keywords, numbers, strings, symbols), skipping whitespace and#comments as it scans.Lexer::getTokens()drives this until it produces anEOF_TOKEN. - Parser (
src/parser.cpp) — a recursive-descent parser that consumes the token stream and builds an AST (node types ininclude/ast.h/src/ast.cpp:ModuleNode,FunctionNode,AssignmentNode,IfNode,ForNode,CallNode,BinaryOpNode, ...). Expression parsing follows the precedence chain documented inLANGUAGE.md(parseRelational→parseAdditive→parseMultiplicative→parsePrimary). Once parsing succeeds,ModuleNode::normalizeTopLevelStatements()applies the implicit-main-wrapping rule (also documented inLANGUAGE.md). - MLIRGen (
src/mlir_gen.cpp) — walks the normalized AST and emits the initialpicceler-dialect MLIR: onefunc.funcperFunctionNode, and apicceler.*op for every builtin call (dispatched through_functionTable, the same table that backs the builtin lists inLANGUAGE.md).
The module MLIRGen produces is what IRPassManager::run() receives — Phase 1 below is the first
pass to touch it.
.pic source
│
▼ Lexer (src/lexer.cpp)
tokens
│
▼ Parser + AST normalization (src/parser.cpp, src/ast.cpp)
AST
│
▼ MLIRGen (src/mlir_gen.cpp)
picceler dialect MLIR
│
▼
┌───────────────────────────┐
│ 1. High-Level Optimization │ addHighLevelOptimizationPasses()
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ 2. Runtime Lowering │ addRuntimeLoweringPasses()
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ 3. Affine Lowering │ addAffineLoweringPasses()
└───────────────────────────┘
│
▼
┌───────────────────────────┐
│ 4. Backend Lowering │ addBackendLoweringPasses()
└───────────────────────────┘
│
▼
LLVM dialect → LLVM IR → object code
The four phases are registered in IRPassManager::addPasses() (src/pass_manager.cpp) and always
run in this order.
| # | Pass | Summary |
|---|---|---|
| 1 | mlir::createCanonicalizerPass() |
Cleans up the IR fresh out of mlir_gen.cpp before the pattern-matching pass below runs on it. |
| 1.5 | PiccelerAddProfilingPass (src/picceler_add_profiling_pass.cpp) — only when --profile is passed |
Wraps every picceler-dialect op with piccelerTraceBegin/piccelerTraceEnd runtime calls, so the compiled binary emits a Perfetto-viewable trace at exit. See docs/profiling.md. |
| 2 | PiccelerFiltersToConvPass (src/picceler_filters_to_conv_pass.cpp) |
Rewrites sharpen / box_blur / gaussian_blur / edge_detect / emboss into a canonical picceler.convolution + kernel pair. Collapses five op-specific lowerings into one, so every later pass only has to know how to lower convolution. |
PiccelerAddProfilingPass must run in this exact spot: after the canonicalizer (so dead/folded ops
never show up mislabeled in the trace) and before PiccelerFiltersToConvPass (so a gaussian_blur
in the trace reads "gaussian_blur", not the "convolution" it gets rewritten into one pass later).
After this phase, the only "filter" op left in the IR is picceler.convolution.
sharpen/box_blur/gaussian_blur take two different paths through PiccelerFiltersToConvPass
depending on whether their strength/radius argument is a compile-time constant:
- Constant (the common case: a literal, or something the canonicalizer folded to one) — kernel
weights are computed host-side (
calculateSharpenKernel,calculateBoxBlurKernel,calculateGaussianKernel) into apicceler.kernel.const+picceler.kernel<RxC>, exactly as before.PiccelerKernelToMemrefPass(Phase 3) later materializes that into a fixed-sizememref.alloca. - Runtime (e.g. the argument traces back to a function parameter) — there is no compile-time
value to size a
picceler.kernel<RxC>with, sobuildSharpenKernelDynamic/buildBoxBlurKernelDynamic/buildGaussianKernelDynamicbuild the same arithmetic directly as arith/memref/affine (and, for gaussian'sexpterm,math) ops, writing straight into a memref that's fed topicceler.convolutionas-is.sharpen's kernel is always 3x3 regardless of strength, so it only needs a small fixed-sizememref.alloca;box_blur/gaussian_blur's kernel dimensions (2*radius+1) are themselves runtime values, so those get a dynamically-shapedmemref<?x?xf64>frommemref.allocinstead (see the "Note" at the end of Phase 4 for whyallocand notallocahere). Either way,picceler.convolution's kernel operand was already typedPicceler_AnyKernelType = AnyTypeOf<[Picceler_KernelType, AnyMemRef]>(tablegen/ops.td), so no dialect/op changes were needed to accept it —getKernelNeighborhoodSize(src/ops/convolution.cpp) just needed to read a dynamic memref's shape withmemref.diminstead of assuming compile-time-known dimensions, the same thingdilate/erode's owngetNeighborhoodSizealready did for their radius (see Phase 3 below).
This two-path design — rather than always building the kernel dynamically, even for a constant
radius — was a deliberate choice: it keeps the constant case's IR exactly as compact and
canonicalizer/test-friendly as before (a single dense-attribute kernel, still foldable by
IdentityConvolutionPattern), and only pays for runtime kernel-fill loops when the input genuinely
requires them.
| # | Pass | Summary |
|---|---|---|
| 1 | PiccelerOpsToFuncCallsPass (src/picceler_ops_to_func_calls_pass.cpp) |
Converts the I/O-facing ops (load_image, save_image, show_image, read_number, read_string, print) into calls into the C runtime, and simultaneously starts converting picceler.image into memref<?x?x4xi8> via a TypeConverter, propagating the new type through function signatures, calls, and returns. Runs before affine lowering because Phase 3's patterns pattern-match on memref-typed operands, not the original picceler.image type. |
One nuance worth knowing: print's format string must resolve to a compile-time
picceler.string.const — the pass splits it on {} at pass-run time and emits one runtime call
per literal chunk / substituted argument, so a print whose format string is itself a runtime
value will fail this pass.
| # | Pass | Summary |
|---|---|---|
| 1 | PiccelerKernelToMemrefPass (src/picceler_kernel_to_memref_pass.cpp) |
Materializes picceler.kernel.const's dense-attribute payload into a memref.alloca buffer via memref.store. Must run before PiccelerToAffinePass, since the convolution lowering pattern expects to memref.load kernel weights from a buffer, not read a constant attribute directly. |
| 2 | PiccelerToAffinePass (src/picceler_to_affine_pass.cpp) |
The core compute-generation pass. Everything before it is type/op bookkeeping; everything after it is generic dialect-to-LLVM lowering. |
PiccelerToAffinePass rewrites each remaining compute op into affine.parallel loops over image
rows/columns operating directly on memref pixel buffers, using a separate conversion pattern per
op shape (grouped by the same TableGen interfaces used in docs/dialect-reference.md):
ElementWiseUnaryOpToAffine— ops withElementWiseUnaryOpInterface:brightness,invert.RotateToAffine—rotate, its own pattern (dimensions may swap for 90°/270° rotations, which doesn't fit the generic elementwise/neighborhood shapes). A compile-time-constant angle is validated and normalized host-side, same as always; a runtime angle gets the same validation (multiple of 90) and normalization (((a % 360) + 360) % 360) rebuilt out ofarith.remsi/arith.cmpiinstead, with an invalid angle triggering afunc.call @abortguarded byscf.if(the same "runtime guard, not a compile error" shapeElementWiseBinaryOpToAffine's dimension check below already uses) rather than failing to compile.NeighbourhoodOpsToAffine— ops withNeighbourhoodOpInterface:convolution(howsharpen/box_blur/gaussian_blur/edge_detect/embossreach this pass, per Phase 1),dilate,erode. Emits anscf.ifper sampled neighbor to guard out-of-bounds reads at the image border. Each op'sgetNeighborhoodSize()returns the taps-per-row/column as anaffine.parallelbound —dilate/erodecompute it from their radius operand (2*radius+1) regardless of whether that's a constant or runtime value;convolutionreads it from its kernel operand's shape, either the compile-timepicceler.kernel<RxC>/memref<RxCxf64>case or, for a genuinely dynamically-shapedmemref<?x?xf64>kernel (Phase 1's runtimebox_blur/gaussian_blurpath), withmemref.diminstead.ElementWiseBinaryOpToAffine— ops withElementWiseBinaryOpInterface:diff,blend. Emits anscf.ifthat checks the two input images have matching dimensions.CropToAffine—crop, its own pattern (copies a sub-rectangle rather than transforming every pixel of the input).
By the time this pass finishes, no Picceler compute op should remain — only affine, arith,
memref, and the occasional scf.if guard.
This phase has no more Picceler-specific compute lowering to do — it's entirely about reaching the LLVM dialect:
| # | Pass | Summary |
|---|---|---|
| 1 | PiccelerToLLVMIRPass (src/picceler_to_llvm_ir_pass.cpp) |
The last Picceler-specific pass: converts picceler.string.const into llvm.global + llvm.addressof + llvm.gep, and lowers func ops to LLVM. Marks the whole Picceler dialect and the func dialect illegal — the checkpoint that verifies Phases 1–3 left nothing behind. |
| 2 | createReconcileUnrealizedCastsPass (1st) |
Folds away UnrealizedConversionCastOp bridges left by the type-converter passes above (e.g. the memref-descriptor cast built by buildImageMemref in Phase 2), before the structural lowerings below have to reason about them. |
| 3 | createCanonicalizerPass |
General cleanup ahead of the structural lowerings. |
| 4 | createLowerAffinePass |
Lowers affine.for / affine.parallel / affine.apply to scf + arith — there's no direct affine→LLVM path in upstream MLIR. |
| 5 | createFinalizeMemRefToLLVMConversionPass |
Converts memref descriptors/ops into raw LLVM struct manipulation and llvm.gep/load/store. |
| 6 | createSCFToControlFlowPass |
Lowers the scf.if from Phase 3's bounds/dimension-mismatch/angle-validity guards, plus whatever step 4 introduced, into unstructured cf branches. Must come after step 4, which is what produces most of the scf ops. |
| 7 | createArithToLLVMConversionPass, createConvertMathToLLVMPass, createConvertControlFlowToLLVMPass, createConvertFuncToLLVMPass |
Lower arith, math, cf, and any remaining func ops to LLVM. func ops are normally already gone by step 1, so the func-to-LLVM pass here is a no-op safety net. The math-to-LLVM pass is what makes math.exp (gaussian_blur's runtime kernel path, Phase 1) and sqrt()/pow() translatable at all — without it, any math op that survives constant folding fails at LLVM IR translation. |
| 8 | createReconcileUnrealizedCastsPass (2nd) |
Final cleanup — steps 4–7 each introduce their own casts converting operand/result types piecemeal. After this the IR should be fully LLVM dialect, ready for translation to LLVM IR. |
Note: most memref buffers in this pipeline are memref.alloca (kernel buffers, Phase 3/Phase 1)
rather than memref.alloc, and image buffers are built directly around a pointer the C runtime
already owns (Phase 2's buildImageMemref) — so there's no buffer-deallocation pass anywhere in
this pipeline. The two exceptions are also the two places a buffer's size can't be bounded at
compile time: RotateToAffine/NeighbourhoodOpsToAffine/ElementWiseBinaryOpToAffine/
ElementWiseUnaryOpToAffine/CropToAffine's output image buffers (Phase 3), and
buildBoxBlurKernelDynamic/buildGaussianKernelDynamic's runtime-sized kernel buffer for a
non-constant box_blur/gaussian_blur radius (Phase 1) — both intentionally use memref.alloc
instead, to avoid an unbounded stack allocation. These still never get an explicit memref.dealloc;
the leak is a known, pre-existing limitation of this pipeline, not something introduced here.
Once the module reaches the LLVM dialect, mlir::translateModuleToLLVMIR hands off to a plain
llvm::Module and MLIR is out of the picture. Compiler::emitObjectFile (src/compiler.cpp) then
runs LLVM's own middle-end optimization pipeline via llvm::PassBuilder — buildO0DefaultPipeline
at -O0, buildPerModuleDefaultPipeline at -O1/-O2/-O3 — before handing the module to
TargetMachine::addPassesToEmitFile for instruction selection and object emission. --opt-level
(0-3, or the clang-style -O0/-O1/-O2/-O3 aliases) controls both which pipeline runs and the
codegen optimization level passed to createTargetMachine; it defaults to 2. --native
switches the target CPU/features from the "generic" baseline to the host's
(llvm::sys::getHostCPUName/getHostCPUFeatures), which is required for the vectorizer to actually
emit packed instructions in some cases (e.g. the NaN-propagating arith.maximumf/minimumf used by
every op's clamp lowers to a packed compare-and-blend sequence only where AVX is available — the
generic baseline can't express it and falls back to scalar code even when the loop vectorizer
otherwise judged the loop profitable to vectorize). --native binaries are not portable to other
CPUs and will SIGILL if run on a machine lacking the features they were built for.
IRPassManager's constructor (src/pass_manager.cpp) wires up three pieces of built-in tooling
before any pass runs:
- Full-IR dump to
dump.mlir— writes the module IR todump.mlir(relative to the working directory the compiler was invoked from) after every pass, unbuffered, so a crash mid-compile still leaves a usable partial dump. - Per-pass IR-printing file tree — additionally writes one file per pass invocation into
.pass_manager_output/(MLIR's default tree directory for this API), handy when you want to jump straight to one specific pass's output instead of scrolling throughdump.mlir. PassLoggerinstrumentation (include/pass_manager.h) — logsStarted pass: <name>/Finished pass: <name>atdebuglevel andFailed pass: <name>aterrorlevel via spdlog.
Register the new pass in IRPassManager::addPasses() (src/pass_manager.cpp), inside whichever
add*Passes() helper matches what it operates on:
- Rewrites one high-level Picceler op into another →
addHighLevelOptimizationPasses()(seePiccelerFiltersToConvPassfor the pattern to follow, including how it branches between a host-computed constant kernel and one built from runtime arith/memref/affine ops depending on whether its input is a compile-time constant). - Replaces a Picceler op with a runtime call, or extends the
picceler.image→memreftype conversion →addRuntimeLoweringPasses(). - Generates the actual pixel-processing loops for a compute op →
addAffineLoweringPasses(), afterPiccelerKernelToMemrefPassif the new op consumes kernel data, and pick which of the four*ToAffinepattern shapes (unary / neighborhood / binary / bespoke likecrop/rotate) the new op fits. - Anything below the
affine/memref/scflevel, or standard MLIR-to-LLVM conversions →addBackendLoweringPasses(), keepingPiccelerToLLVMIRPassfirst since it's what enforces that no Picceler orfuncops remain.
docs/dialect-reference.md— op/type-level detail for every dialect that shows up during this pipeline.