September 2024 · 9 min read

The C99 Backend

Lateralus compiles to C99. Not LLVM IR, not assembly — plain C that you can read, debug with GDB, and compile with any C compiler on any platform.

Why C99?

LLVM is powerful but adds a 100+ MB dependency. C99 gives us portability for free — GCC, Clang, MSVC, TCC, even embedded compilers. The generated code is readable, which means users can inspect exactly what their pipelines compile to.

Name mangling

Lateralus names become C identifiers with a predictable scheme:

// Lateralus: std::collections::HashMap
// C99:      ltl_std_collections_HashMap

// Lateralus: fn process(data: Vec<Int>) -> Result<String, Error>
// C99:      ltl_Result_String_Error ltl_process(ltl_Vec_Int data)

Pipeline lowering

The pipeline operator is syntactic sugar that the compiler desugars before codegen:

// Lateralus
data |> filter(pred) |> map(transform) |> sum()

// Desugared
sum(map(filter(data, pred), transform))

// C99
ltl_Int ltl_tmp_1 = ltl_filter(data, pred);
ltl_Int ltl_tmp_2 = ltl_map(ltl_tmp_1, transform);
ltl_Int result = ltl_sum(ltl_tmp_2);

Memory management

Lateralus uses ownership semantics. The C99 backend inserts free() calls at scope exits and move semantics for transfers. Arena allocators are used for short-lived pipeline intermediates.

Struct layout

Lateralus structs map directly to C structs with the same field order. No vtables, no hidden pointers. A Lateralus Vec2 { x: Float, y: Float } is exactly 16 bytes in C, same as you'd write by hand.

Results

The C99 backend produces binaries within 5-10% of hand-written C performance. Compile times are fast because we generate simple, linear C without templates or metaprogramming.

What the generated C looks like

The C99 backend produces readable, debuggable C. Here's a real example:

// Lateralus source:
fn fibonacci(n: Int) -> Int {
    match n {
        0 => 0,
        1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

// Generated C99:
int64_t ltl_fibonacci(int64_t n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return ltl_fibonacci(n - 1) + ltl_fibonacci(n - 2);
}

Pattern matches compile to cascading if statements. Pipelines compile to nested function calls. Structs compile to C structs. The generated code is close enough to hand-written C that you can read it without knowing the compiler internals.

Pipeline compilation

Pipelines are the most interesting compilation target. The |> operator doesn't exist in C, so we transform it:

// Lateralus:
let result = data
    |> filter(|x| x > 0)
    |> map(|x| x * 2)
    |> sum()

// Generated C99:
ltl_Vec result_0 = ltl_filter(data, lambda_1);
ltl_Vec result_1 = ltl_map(result_0, lambda_2);
int64_t result = ltl_sum(result_1);

// With fusion optimization enabled:
int64_t result = 0;
for (size_t i = 0; i < data.len; i++) {
    if (data.ptr[i] > 0) {
        result += data.ptr[i] * 2;
    }
}

The fused version eliminates intermediate allocations entirely. The compiler recognizes filter |> map |> sum as a fusible chain and merges them into a single loop.

Memory management

Lateralus uses automatic reference counting (ARC) compiled to explicit retain/release calls in C:

// Lateralus: no memory annotations needed
fn process(data: Vec<String>) -> Vec<String> {
    data |> filter(|s| s.len() > 10)
}

// Generated C99: explicit reference counting
ltl_Vec ltl_process(ltl_Vec data) {
    ltl_retain(data);  // +1: function owns a reference
    ltl_Vec result = ltl_filter(data, lambda_1);
    ltl_release(data); // -1: function done with input
    return result;      // Caller owns the result
}

The compiler performs escape analysis to eliminate redundant retain/release pairs. In practice, 60-70% of reference counting operations are optimized away at compile time.

Interop with C libraries

Because we compile to C, calling existing C libraries is trivial:

// Declare a C function
extern fn sqlite3_open(filename: *CChar, db: **Sqlite3) -> Int

// Use it naturally in Lateralus
let db = sqlite3_open("mydb.sqlite")
let rows = db
    |> query("SELECT * FROM users WHERE active = 1")
    |> map(|row| User {
        name: row.get_string("name"),
        email: row.get_string("email"),
    })

No bindings generator. No FFI boilerplate. The extern declaration tells the compiler the function exists in a linked C library. The linker resolves it.

Why not LLVM?

LLVM would produce faster binaries. So why C99?

We may add an optional LLVM backend in the future for performance-critical code. But C99 remains the default because the development experience is better.

Lateralus is built by bad-antics. Follow development on GitHub or try the playground.