C++20 compiler

Noria

C++20 LLVM No GC Native binaries

A statically typed language and C++20 compiler that owns the path from source to a native binary: lexing, parsing, module resolution, type checking, reachable generic monomorphization, LLVM IR generation, optional optimization, object emission, and host linking on macOS and Linux.

The language

Noria is a small, statically typed language and compiler. Noria has a stdlib containing dynamic arrays, linked lists, BSTs, open-addressed hash maps/sets, and heaps.

  • Scalars: i32, bool, f64, void
  • Aggregates: [T], str, struct
  • ADTs: Sequence, Dictionary, Set with compile-time impl tags
  • Modules: selective import std::…::{…} · module-private fields
  • Ownership: clone / borrow / move / drop, no garbage collector
stdlib Sequence · reduce a buffer
import std::sequence::{Sequence, sequence_len, sequence_push};

helper sum(values: Sequence<i32>) {
  let i: i32;
  let total: i32;
  while i < sequence_len(values) {
    total = total + values[i];
    i = i + 1;
  }
  return total;
}

fn main() {
  let values: Sequence<i32>;
  sequence_push(values, 10);
  sequence_push(values, 32);
  return sum(values);
}

From source to a native binary

The compiler follows a facade pattern with checkpoints used for testing: StopAfter::Tokens, Ast, Typed, and Ir. C++ tests drive the same pipeline without writing files.

compileSource · src/Compiler.cpp
Lexer normalize identifiers · preserve file/line/column StopAfter::Tokens
Parser owned AST · canonical Type values StopAfter::Ast
Module resolver selective std:: merge · SymbolOrigins · privacy
ADT defaulting omitted tags become arr / hashmap
Type checker inference · constraints · places vs rvalues
Monomorphizer reachable worklist · deterministic mangling StopAfter::Typed

frontier loop: type-check only newly emitted specializations, then enqueue what they reach

LLVM IR layouts · ownership · runtime traps StopAfter::Ir
opt / llc optional -O1/-O2/-O3 · object emission
host clang native link · macOS and Linux, x86-64 and ARM64 executable

Ownership, no garbage collector

Strings, arrays, structs with heap allocated fields, and the stdlib containers are managed. Each one is freed exactly once. There is no garbage collector.

clone · borrow · move · drop
let b = a;                 // deep clone; a and b free independently
sequence_push(s, x);       // borrow; mutation is visible to the caller
return owned;              // move the local to the caller
return param;              // clone so the caller still has a valid value
holder.nested = [];        // free the old value, then store
let numbers: Sequence<i32>; // empty default is still owned
print("xy" + "z");         // concat allocates; the parts are freed
s[i]                       // string element: clone
a[i]                       // array element: borrow
Locals freed when the block ends or you reassign
Fields and indexes the struct or array still owns what is stored there
Defaults and concatenation you own what you just created, including empty containers
Temporaries print, len, and indexing free the temporary after use

Generated code tracks whether each value is owned. Arguments are borrowed, so a callee can mutate a container without taking it. Returning an owned local moves it; returning a parameter clones. Overwriting a field or index frees what was there first. Each scope has a flag containsPtr that is used to free resources on scope exit.

codegen · drop on scope exit

scopes_ stack

function · demo() containsPtr: false
  • k · i32
block · { … } containsPtr: true
  • buf · [i32] · owned

block exit free owned locals, then pop the block

while body · { … } containsPtr: false
  • i · i32

block exit early return, no drop IR emitted

return free remaining owned locals, inner → outer

emitted IR · pseudocode

; block exit, only if scope.containsPtr

owned ← load bool from %buf.owned
if not owned: goto next_local

ptr ← load ptr from %buf.slot
call @free(ptr)                   ; [i32] heap buffer

; same owned-bit gate for other managed locals:
call @__noria.rt.drop_str(str_ptr)
call @sequence_drop$s.i32(handle) ; one monomorphized specialization

next_local:

; scalar-only scope (containsPtr = false)
; → no instructions inserted

Memory model & structs

Structs are stack-allocated value aggregates, with members accessed through compile-time known offsets.

Array data lives on the heap; the variable itself is a stack pointer to that buffer. Empty arrays and array concatenation are owned.

Function arguments borrow that pointer, so kth_largest(nums, k) sees the same buffer as the caller. let copy = nums is different: that's a deep clone, and the two arrays drop independently.

memory · kth_largest(nums, k)

stack frame

heap

k i32 2
i i32 4
nums [i32] ptr
malloc · nums buffer len: i64 = 6 @ offset 0 [ 3, 2, 1, 5, 6, 4 ] borrowed at the call; caller still owns it
heap Sequence<i32,arr> ptr
Sequence<i32,arr> backing store

stack grows ↑ · scalars live in-frame · pointers alias heap

Passing nums borrows the pointer into the callee frame, both frames alias the same heap buffer. Assignment clones. Only scalars live entirely on the stack.

Compiler performance

Phase timings over 19,600 in-process compilations (196 inputs × 100 rounds, through LLVM IR) surfaced a hot path in monormphization and resolving stdlib imports. Through implementing frontier-only specialization and caching of frequently used AST components benchmark latency was reduced from 27.69s → 7.05s

19,600 compilations
27.69→7.05s aggregate through IR
74.5% lower latency
3.93× throughput
27.69s baseline, before reusable AST-component caching
20.15s parsed-module + stdlib-specialization LFU cache · −27.2%
7.05s selective admission + frontier-only specialization · −74.5%
Monomorphization2.29s · 32.5%
Import resolution1.88s · 26.7%
LLVM IR generation1.73s · 24.5%
Lexing + parsing0.66s · 9.4%
Type checking0.49s · 6.9%

Bounded LFU

Two LFU caches: 64 parsed stdlib modules, 256 specializations. Frequency buckets with deterministic oldest-in-min-bucket eviction.

Selective admission

Function specializations below a 1 KiB AST weight and structs below eight fields are cheaper to rebuild than keep.

Generics & monomorphization

Generic functions and structs infer type arguments at call sites, check implementation-tag constraints, then monomorphize only the reachable specializations before LLVM codegen. There is no type erasure and no vtable: each distinct T / I pair becomes a concrete body with a deterministic mangled name such as id$s.i32.

monomorphize · src/monomorphize/Monomorphize.cpp
1 Unify infer T, I · check constraints
2 Sort mangled name · location
3 Dedup clone templates once
4 Frontier check new bodies only
5 Enqueue reachable callees
6 Rewrite strip templates · LLVM

Abstract Data Types vs Data Structures

Many programming languages give users access to a wide variety of data structures, without having to define their interfaces. This can leave users confused about which data structures to use for which applications. For instance, in C++, vector, deque, and list are all sequences, but nothing about the name or high level code indicates that they are different from set.

Noria names the abstract data type first, then picks the structure as a compile-time tag, the user chooses the interface before the implementation. Leave the tag off and you still get a real default: Sequence<T> is arr, Dictionary and Set are hashmap. Changing a tag changes representation and complexity, not the API. Monomorphization erases the tag: there is no vtable and no runtime representation branch.

stdlib · ADT + impl tag

Sequence<T, I>

push · pop · get · set · insert · remove · len

impl arr dynamic array · get O(1)
impl list doubly linked · get O(n)

Dictionary<K, V, I>

insert · get · contains · remove · len

impl hashmap open addressing · O(1) avg
impl bst ordered tree · O(h)

Set<T, I>

insert · contains · remove · len

impl hashmap hash table set · O(1) avg
impl bst BST set · O(h)
Same ADT, different structure
let seq: Sequence<i32>;                 // empty, impl arr
let map: Dictionary<i32, i32>;           // empty, impl hashmap
let tree: Dictionary<i32, i32, bst> = dictionary_new(0, 0);

Those representations are not compiler magic. Array growth, circular-sentinel lists, hashmap probing, BST links, and heapify live in stdlib/ as Noria. Heap algorithms are written against the Sequence interface, so a list-backed heap is still correct, and visibly slower, rather than special-cased.

stdlib/sequence.noria · one API, two bodies
fn sequence_new<T, I>(sample: T) -> Sequence<T, I> impl arr {
  let cap: i32 = 4;
  let header: __rt_ptr = alloc_bytes(16);
  let data: __rt_ptr = alloc_bytes(cap * size_of());
  let init0: i32 = store_i32_at(header, 0, 0);
  let init1: i32 = store_i32_at(header, 1, cap);
  let init2: i32 = store_ptr_at(header, 1, data);
  return Sequence<T, I> { handle: header };
}

fn sequence_new<T, I>(sample: T) -> Sequence<T, I> impl list {
  let sentinel: __rt_ptr = alloc_bytes(20);
  let self0: i32 = store_ptr_at(sentinel, 0, sentinel);
  let self1: i32 = store_ptr_at(sentinel, 1, sentinel);
  return Sequence<T, I> { handle: sentinel };
}

Case folding on names

Identifiers and keywords are folded to lowercase in the lexer so that an inconsequential capitalization difference is not a compile error. Count and count are the same binding; sequence_len and Sequence_Len are the same function. String literal contents keep their original casing, case still matters for data, not for names.

The decision is about what a name means. Accidental PascalCase after a camelCase declaration, or mixing a type-style capital with a later lowercase use, should not be a distinct symbol.

Same binding, different capitalization
helper mean(values: Sequence<f64>) -> f64 {
  let Count: i32 = sequence_len(values);
  if count == 0 {
    return 0.0;
  }
  let i: i32;
  let Total: f64;
  while i < count {
    total = total + values[i];
    i = i + 1;
  }
  return total / (count as f64);
}

The lexer emits lowercase lexemes; type checking and codegen never see the original casing. Tests cover mixed-case source against the normalized token stream.

Headers that describe the function

fn, util, helper, and recfn are the same declaration as far as the compiler is concerned, they lex as one keyword and produce one AST node. The spellings exist so a header can state how the function is meant to be used without forcing a reader through the body.

Best practice in Noria source: fn for ordinary entry points and public operations; util for small, reusable operations (square, min, a cast wrapper); helper for logic that exists to serve one caller (merge, a partition step); recfn for functions whose interesting structure is recursion. The toolchain does not check that a recfn actually recurses, the annotation is a reading convention, not a second type system.

helper vs recfn · intent in the header
helper merge(values: Sequence<i32>, lo: i32, mid: i32, hi: i32) -> void {
  let i: i32 = lo;
  let j: i32 = mid;
  let k: i32 = 0;
  let tmp: Sequence<i32> = sequence_new(0);
  while i < mid && j < hi {
    if values[i] <= values[j] {
      sequence_push(tmp, values[i]);
      i = i + 1;
    } else {
      sequence_push(tmp, values[j]);
      j = j + 1;
    }
    k = k + 1;
  }
  return;
}

recfn sort_range(values: Sequence<i32>, lo: i32, hi: i32) -> void {
  if hi - lo <= 1 {
    return;
  }
  let mid: i32 = lo + (hi - lo) / 2;
  sort_range(values, lo, mid);
  sort_range(values, mid, hi);
  merge(values, lo, mid, hi);
  return;
}

Return types when they help the reader

A trailing -> Type is optional so headers are not forced to repeat what the body already determines. Best practice is the opposite of “always omit”: keep an explicit annotation on functions that other code calls often, so a reader can take the result type from the header. Omit it on internal helpers whose only job is to return whatever the body computes.

When omitted, the typechecker unifies every return into one result type, including across mutually recursive functions, generics, structs, and arrays. Bare return; infers void.

examples/basic/inferred_return_types.noria · exit 8
fn identity<T>(value: T) {
  return value;
}

fn even(value: i32) {
  if value == 0 { return true; }
  return odd(value - 1);
}

fn odd(value: i32) {
  if value == 0 { return false; }
  return even(value - 1);
}

fn main() {
  if even(6) {
    return identity(4) + 4;
  }
  return 0;
}

Variable declarations

Variable declarations accept name-first or type-first forms so you can write the order you are thinking in. value: i32 or i32: value are equivalent. The let keyword can be omitted from some declarations for shorthand. A bare identifier (no let keyword) on the left of = is always assignment. Default initialization is real initialization, omitting the initializer does not give a garbage value.

Name-first, type-first, defaults, assignment
let x: i32 = 42;
x: i32 = 1;
i32: y = 2;
let n: i32;                 // 0
let text: str;              // ""
let values: Sequence<i32>;  // empty, impl arr
x = x + y;

End-to-end examples

Passing programs under examples/basic/ exercise imports, stdlib ADTs, generics, and codegen, not just parser smoke tests. Several are adapted from common interview problems to stress the full pipeline.

examples/basic/leetcode_kth_largest.noria
import std::heap::{heappop, heappush};
import std::sequence::{Sequence, sequence_len, sequence_new};

fn kth_largest(nums: [i32], k: i32) -> i32 {
  let heap: Sequence<i32, arr> = sequence_new(0);
  let i: i32 = 0;
  while i < len(nums) {
    if sequence_len(heap) < k {
      heappush(heap, nums[i]);
    } else {
      let smallest: i32 = heappop(heap);
      if nums[i] > smallest {
        heappush(heap, nums[i]);
      } else {
        heappush(heap, smallest);
      }
    }
    i = i + 1;
  }
  return heappop(heap);
}

Tests & tooling

The language corpus is a contract, including the programs that must fail: 277 accepted programs and 170 rejected ones, 148 semantic, 22 lexer/parser, 447 language programs in total.

447 corpus programs
277 accepted
170 rejected
16 CTest checks
Native behavior linked exit status and stdout on selected programs
Emitted IR bounds checks, drops, mangled specializations, dedup
Diagnostics located semantic and syntax failures
Runtime traps status 70 plus diagnostic text
Optimizer regressions ownership and container programs re-run at -O2
Ownership leak corpus across container tags; Linux leak checks on generated programs
Container models 300 deterministic ops vs Python oracles
Install layout PATH invocation and stdlib discovery after cmake --install
NORIA_REQUIRE_LLVM_TOOLS=1 ctest --test-dir build

GitHub Actions builds on macOS and Ubuntu. Both jobs run the suite, sanitizers, and leak checks. Linux also leak-checks generated programs while macOS sanitizers catch use-after-free.

Parser and type checker errors include source line and column information:

./build/noria examples/invalid/unknown_variable.noria -o build/out.ll
noria: error: 2:10: typecheck: unknown local variable 'missing'

Why I built this

A compiler is where software and hardware meet. Building Noria meant tracing that path myself and not treating any stage as a black box.

  • End-to-end ownership. I learn best when I can follow a whole system from input to output. Noria sharpened that habit: every bug belongs to a stage, and every stage has tests.
  • Hard problems, measured fixes. Generic monomorphization needed a fixed-point specialization pass; ownership needed clone/borrow/move/drop in codegen without a GC; compiler latency needed profiling 19,600 runs before the LFU cache was worth adding, all of that taught me to validate assumptions with data, not vibes.
  • Better instincts in production work. Debugging across layers: is this a type error, a codegen bug, or a toolchain issue? These skills transfer directly into shipping production code at scale.