Skip to main content

Retiring the C++ Backend and Teaching the Compiler to Monomorphise

· 8 min read
daily-progress: Part 3 of 11

Two things landed today that I’d been circling for a long time, and they’re related in a way that only became obvious once both were done.

The first is a deletion. The C++/MLIR codegen subtree is gone — 1568 files in one commit, plus another nine for the old C++ msgpack-reader generator. The second is a beginning: generics now have a real lowering path through the IR, instead of a checker that resolves them and a backend that has no idea what to do with them.

The connection is that I couldn’t have done the second thing while the first thing was still in the tree. You can’t teach two backends to monomorphise. You barely have the energy to teach one.

Deleting the backend I spent months on

For a while Hew compiled through MLIR. The frontend lowered to a custom dialect, MLIR lowered that toward LLVM, and a pile of C++ glued it together. It worked, and I learned an enormous amount writing it, and I do not miss it.

The Rust inkwell backend — hew-codegen-rs, which talks to LLVM directly — has been the real backend for a while now. The hew binary already depended only on it. The C++ tree was dead weight: it still built, still had its sanitizer CI lanes, still spent fifteen-to-twenty-five gigabytes of disk per checkout on cmake artefacts, and contributed nothing to a single compile a user would run.

So today it came out. The commit message is mostly an inventory of plumbing:

feat: retire C++/MLIR codegen subtree; codegen-rs is the sole backend

Removes the hew-codegen/ C++/MLIR subtree (1568 files) and the
hew-astgen C++-msgpack-reader generator (9 files).

(Don’t go looking for that commit verbatim — the release consolidation later squashed it into a larger one. The inventory stands.)

The build system lost its codegen, test-codegen, test-cpp, and test-wasm targets. make test now runs the Rust tests and nothing else. hew-cli/build.rs no longer shells out to cmake — the cfg(hew_embedded_codegen) gate is now permanently off, and the fail-closed stubs that were already in the tree surface the “codegen unavailable” path instead of pretending. The CI workflows shed their C++ build and sanitizer steps. I deleted an entire codegen-lint.yml. (Update, June 2026: the gate and the stubs are gone entirely now — the embedded-codegen path was deleted outright, not just switched off.)

There are loose ends I left on purpose. The architecture specs in docs/ still point at hew-codegen/src/mlir/*.cpp as if it exists — rewriting those is a separate documentation pass, not something I wanted tangled into a 1568-file deletion. (Update, June 2026: that pass happened; the MLIR diagrams that survive are explicitly labelled historical.) And the Alpine LLVM image still builds MLIR even though inkwell doesn’t need it, which is a few hundred megabytes of container nobody’s reaching for yet. (Deleting a thing is fast. Deleting every reference to the thing is the part that takes a week.)

The honest tradeoff: there’s a 42-fixture wire corpus that lived under hew-codegen/tests/, and it needs a Rust-native home before its round-trip test can run again. I marked that test #[ignore] rather than fake it green or delete the fixtures. It’s tracked, and it’s a real gap until I move it. (Update, June 2026: the gap dissolved instead of closing — the whole legacy wire path, fixtures and all, was deleted outright when inter-process messaging moved to a CBOR envelope. What’s left of the old helpers sits in std/encoding/wire, marked legacy.)

What I get back is a pipeline that’s Rust from the lexer to the LLVM module. One language to reason about, and one place a bug can live.

Teaching generics to lower

Hew has had generic functions in the type checker for a while. You could write fn announce<T: Named>(item: T) and the checker would accept it, resolve the bound, and tell you the program was well-typed. Then the backend would look at it and have nothing to emit, because there’s no such thing as a generic function in LLVM IR. LLVM has concrete functions over concrete types.

The standard answer is monomorphisation: for every distinct set of type arguments a generic is called with, you stamp out a separate concrete copy. max called at i64 and max called at string become two real functions with two real signatures. (The classic alternative is boxing everything behind a uniform representation; monomorphisation trades binary size for not paying that indirection at runtime.) The checker already knew the type arguments at every call site — it had to, to type-check the call. What was missing was carrying that knowledge down the IR ladder and acting on it.

Here’s the surface this work is aimed at. Two unrelated types satisfy one trait, and a generic function gets called at each of them:

trait Weight {
    fn weight(val: Self) -> i64;
}
 
type Crate {
    mass: i64;
}
 
type Bag {
    grams: i64;
}
 
impl Weight for Crate {
    fn weight(c: Crate) -> i64 {
        c.mass
    }
}
 
impl Weight for Bag {
    fn weight(b: Bag) -> i64 {
        b.grams
    }
}
 
fn heavier<T: Weight>(a: T, b: T) -> T {
    if a.weight() < b.weight() {
        b
    } else {
        a
    }
}
 
fn main() {
    let big_crate = heavier(Crate { mass: 10 }, Crate { mass: 40 });
    let big_bag = heavier(Bag { grams: 200 }, Bag { grams: 90 });
    println(big_crate.weight());
    println(big_bag.weight());
}

The two heavier(...) calls don’t dispatch to the same code. Each call site picks a distinct monomorphised function — heavier instantiated at Crate, and heavier instantiated at Bag — and the backend emits a separately-mangled LLVM function for each.

One gap, plainly: the day this landed, the a.weight() calls inside heavier — trait-method dispatch through a <T: Trait> bound — were not fully wired in the backend, so this exact program didn’t run end to end yet. The checker recorded the instantiations correctly, MIR substituted correctly, but at a polymorphic method-call site the checker wasn’t yet emitting a method-call rewrite, so MIR lowered it to a fail-closed diagnostic rather than guessing a target. That’s the correct behaviour for an incomplete layer — record what you know, refuse what you don’t, and don’t emit a call you can’t justify. I left a probe test in place that prints the gap today and flips to a hard assertion the moment the checker side lands the rewrite. (Update, June 2026: closed. This program compiles and prints 40 then 200 on the current candidate, and the probe asserts instead of waiting.)

How it threads through the IR

The work spans three layers, and I committed it as three layers because each one has a clean boundary.

The HIR layer collects the instantiations. The checker writes the concrete type arguments for each generic call into a side table; HIR reads that table and records every (fn, type_args) pair on the module. The deduplicating key is exactly that pair — call heavier at Crate a hundred times and you get one entry. Each entry carries a mangled symbol name.

The mangling sigil is $. My first instinct was __, and that’s wrong: the lexer admits __ inside ordinary identifiers, so a user could write a function whose name collides with a mangled one. $ is illegal in Hew source but perfectly legal in an LLVM symbol and in ELF, Mach-O, and wasm symbol tables. So it’s safe by construction — there’s no Hew program that can produce that name.

Recursion was the part I had to think about. A generic that calls itself —

fn id<T>(x: T) -> T {
    id(x)
}

— must not expand forever. The trick is that the checker emits an opaque type for an unbound type parameter T, not an inference variable, so the inner id(x) call records T symbolically rather than as a fresh concrete instantiation. One outer concrete entry, no infinite descent. There’s also a hard cap on the registry — 1024 instantiations — and exceeding it emits a diagnostic and stops, rather than running the machine out of memory. (Belt and suspenders. The opaque-T filter is the belt; I have been bitten by “this can’t possibly recurse” before.)

The MIR layer turns each collected (fn, type_args) pair into one MIR function with the substituted signature and body. Every place the MIR builder writes a type, it applies the substitution at write time — local allocations, statements, the decision trees that match lowering produces. At a concrete call site, CallDirect is rewritten to target the mangled symbol of the right monomorphisation instead of the generic original. An inner generic call inside a generic body resolves the same way, so outer<i64> calling id<i64> jumps straight to id-at-i64 with no runtime trampoline.

The codegen layer is the boring one, which is the point. By the time MIR hands over, every function is concrete — the backend emits a separately-mangled LLVM function per instantiation and never has to know generics existed.

dyn Trait and the fat pointer

Monomorphisation is the static half. The dynamic half — dyn Trait — landed the same day, all the way down the ladder.

trait Describe {
    fn describe(val: Self) -> string;
}
 
type Circle {
    radius: i64;
}
 
impl Describe for Circle {
    fn describe(c: Circle) -> string {
        "circle"
    }
}
 
fn announce(shape: dyn Describe) {
    println(shape.describe());
}

A dyn Describe value is a fat pointer — a data pointer and a vtable pointer side by side. The ABI for this got actual names today: HewTraitObject for the pair, HewVtable for the dispatch table. The IR grew matching nodes at every layer — a coercion node when a concrete value becomes a trait object, a dynamic-method-call node when you call through one — and each layer fails closed if the checker’s side table for the coercion is incomplete, rather than emitting a half-formed object.

Object safety is the gate. Not every trait can be a dyn — a trait with a method that returns Self by value, for instance, can’t, because the whole point of the trait object is that you’ve forgotten what Self is. So there’s an object-safety check that decides whether a trait may be named behind dyn at all, and it runs before any of the lowering does.

Dropping a trait object needed its own handling too. The concrete type is erased, so the destructor can’t be chosen statically — it lives in a fixed slot of the vtable, and scope exit emits a load-from-vtable-then-call, the same shape as closing any other resource at end of scope.

Associated types

The other piece traits needed was associated types — a type that belongs to the trait, named by the impl. The Index trait is the clean example, because obj[k] desugars straight to it:

type Grid {
    data: Vec<i32>;
    width: i32;
}
 
// `type Output` is an associated type. `Self.Output` is its
// projection inside the trait body; an impl pins it to a concrete type.
impl Index for Grid {
    type Output = i32;
    fn at(g: Grid, index: i32) -> i32 {
        g.data.get(index as i64)
    }
}
 
fn main() {
    let g = Grid { data: [10, 20, 30, 40], width: 2 };
    println(g[0]); // desugars to Index.at(g, 0)
    println(g[2]);
}

type Output is the associated type; Self::Output is the projection of it inside the trait body, and the impl pins it to i32. Indexing — g[0] — desugars to Index::at(g, 0), which means user types get [] by implementing one trait, exactly the way Iterator already worked. What landed today is the enforcement at the registration boundary: an impl’s binding for an associated type is checked against the bound the trait declared, defaults fill in a missing binding, and a trait that declares the same associated type twice is rejected. The bound was being parsed and then quietly discarded before; now it’s actually enforced.

Lowercasing the last two primitives

A small change rode along, and it’s the kind I keep putting off because it’s pure churn until it’s done. Hew’s primitives are lowercase — i64, f64, bool — except two had stayed PascalCase out of habit: String and Duration. They were the last two aliases that didn’t match the convention.

Syntax as of v0.5 — this snippet uses syntax from v0.5; some forms changed in later releases.
// Before
fn is_anagram(a: String, b: String) -> bool { /* ... */ }
 
// After
fn is_anagram(a: string, b: string) -> bool { /* ... */ }
v0.6+ — current idiomatic form.
// Before: String (capitalised) — now rejected; use lowercase string:
//   fn is_anagram(a: String, b: String) -> bool { /* ... */ }
 
// After
fn is_anagram(a: string, b: string) -> bool {
    a.len() == b.len()
}

That’s 418 files. The parser stops normalising string upward to String, the alias table drops the uppercase entries, and every type-position reference across the type checker, the IR, the analysis crates, the stdlib, the examples, and the tests moves to lowercase. The one thing the rename got wrong on its first pass was a playground example’s @name annotation — a human-readable display title that happens to contain the word, not a type keyword — which I had to restore by hand. (A find-and-replace doesn’t know the difference between a type and a label that reads like one. That’s what the second commit was for.)

So now a record reads the way I always wanted it to:

type Session {
    id: string;
    ttl: duration;
}

All lowercase, no exceptions, no PascalCase holdouts that make a reader wonder whether String is somehow special. It isn’t, and now the spelling says so.

The trait-method-through-a-bound gap is the next thing I’m walking toward, and the probe test is sitting there waiting to tell me when I’ve closed it. (Update, June 2026: closed, as noted above — the probe got its assertion.)

Hew Notes

Language development, engineering articles, releases and community projects. About once a month, with an extra issue for a significant release. Written in English.

Subscribe to Hew Notes