|
picceler
A compiled DSL for image processing, built on MLIR
|
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:
The module MLIRGen produces is what IRPassManager::run() receives — Phase 1 below is the first pass to touch it.
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:
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):
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:
Register the new pass in IRPassManager::addPasses() (src/pass_manager.cpp), inside whichever add*Passes() helper matches what it operates on: