- Status: Accepted (2026-04)
- Supersedes: none
- Relates to: ADR-0001 (MLIR as shared IR), ADR-0002 (autograd model)
- Authors: tesseract core team
M0 ships an eager tape plus a placeholder MLIR dialect (three ops, no lowering). M1's charter is to make the IR actually carry the training graph end-to-end and replace the eager tape as the source of truth. A naive interpretation is "delete the tape, emit MLIR everywhere, run mlir-opt". That has two serious problems:
- Build friction. The MLIR build takes 30–90 minutes per machine, grows
third_party/by ~3 GB, and introduces LLVM API pinning (currently 18.1.x). IfTESSERACT_ENABLE_MLIR=OFFis removed, every contributor pays this tax even for a one-line change to the CPU kernels. - API-level coupling. If the user-facing
ops::entry points composemlir::OpBuilderdirectly, every change to MLIR upstream becomes an API break on Tesseract's public surface. Upstream MLIR moves faster than a stable training framework can follow.
The same tension motivates PyTorch's FX / torch.compile architecture (Python
graph capture + optional Inductor backend), JAX's jaxpr + HLO split, and
TensorFlow 2's tf.function + MLIR path. Each of those has a neutral,
framework-native intermediate structure that is then optionally lowered
into the heavyweight compiler.
Adopt a two-stage graph IR:
-
Stage 1 — C++ graph (
tesseract::graph::Graph).- Defined in
include/tesseract/graph/and implemented insrc/graph/. Depends only ontesseract_coreandtesseract_ops. - Built whenever Tesseract is built (no CMake option toggles it off).
- Holds an SSA list of
graph::Opentries, each carrying(op_name, std::vector<Value>, std::vector<Value>, attr_map, std::vector<Tensor> saved_for_backward). Valuewraps a(shape, dtype, device)triple; no storage at record time. Materialized tensors are produced bygraph::run(g, inputs).- Serializable to and from a compact textual form so that graphs produced on a CPU-only developer machine can be replayed on an MLIR developer machine.
- Defined in
-
Stage 2 — MLIR module (
tesseractdialect).- Built only under
TESSERACT_ENABLE_MLIR=ON. - Produced by
graph::emit_mlir(Graph&), a straight one-to-one translation that never reshapes or reorders ops. - Consumed by the
tesseract → linalg → scf → arithpass pipeline and thetesseract-opttool for offline experimentation.
- Built only under
This split makes Stage 1 the source of truth and Stage 2 a view onto it. Users who just want graph-mode training get it without MLIR; users who want compiler-level optimizations flip the option on.
- Zero-dependency graph mode.
TESSERACT_ENABLE_MLIR=OFFcontinues to be the default; nobody is forced to build LLVM to use graph capture. - Testable in isolation. Each stage has its own test suite. Stage 1
uses Catch2 as today; Stage 2 uses
tesseract-opt+ FileCheck. - API stability decoupled from upstream MLIR.
tesseract::graph::*is the versioned surface; MLIR moves beneath it. - Reusable by M2 / M3. CUDA backend (M2) and LLM inference (M3) both
consume
tesseract::graph::Graphas their entry point. The MLIR path is one possible backend, not the only one. - Diffability. A textual Stage-1 dump is human-readable and survives MLIR version bumps unchanged.
- Two data structures to keep in sync. Every new op must be added to
both Stage 1 (
graph::OpKind) and Stage 2 (tesseract.<op>). Mitigated by a registry insrc/graph/OpTable.cppthat drives both declarations from a single list. - Emitter is an extra moving part. Bugs in
emit_mlirshow up only when MLIR is enabled. Mitigated by a parity test: every graph produced during ctest is, underENABLE_MLIR=ON, round-tripped throughtesseract-opt --verify-each. - Duplicated type system.
graph::Value(shape + dtype) overlaps withmlir::RankedTensorType. We keep them intentionally separate; Stage 1 must not import any MLIR headers.
- Define the canonical op list (name → arity → attrs) in one C++ table.
Both
graph::Opconstruction and TableGen*.tdfiles read from this table; a small CI check warns if the two drift. - Parity test: if
TESSERACT_ENABLE_MLIR=ON, everygraph::Graphconstructed in ctest also exercisesemit_mlir+verify().
- Stage-1 only (no MLIR). Rejected because it gives up the lowering / fusion / codegen machinery that makes MLIR worth depending on — the whole point of ADR-0001.
- Stage-2 only (MLIR everywhere). Rejected because of the build friction and API coupling described above.
- JAX-style tracing from Python. Rejected at M1 because Tesseract is still C++-first; Python bindings are an M4 task. We revisit this when pybind11 bindings land.
- PyTorch FX & TorchDynamo (2022–2024): https://pytorch.org/docs/stable/fx.html
- JAX
jaxpr: https://jax.readthedocs.io/en/latest/jaxpr.html - TensorFlow 2
tf.function+ MLIR: https://www.tensorflow.org/mlir docs/m1-plan.mdfor the concrete M1 track breakdown.