Skip to content

Tools

Builds, backends and performance

Brine has three build profiles: dev (the default, built for a fast edit-run loop), release (--release, optimized through a C compiler) and debug (--debug, for a debugger). On x86-64 Windows and Linux and on AArch64 Linux, dev and debug builds go straight to machine code, so the whole 34,000-line compiler builds itself in under a second; release builds go through gcc or clang and run at about the speed of hand-written C. Every profile keeps every runtime check the prover couldn’t remove: there is no unchecked mode.

Profile Command Code generator on x86-64 Windows/Linux, AArch64 Linux Elsewhere Output
dev brine build, brine run native backend C at -O2, split into units compiled in parallel target/dev/
release --release C at -O3, one unit C at -O3, one unit target/release/
debug --debug, brine debug native backend with DWARF, no optimizations C at -O0 -g with #line target/debug/

What stays the same in every profile:

  • The checks. Overflow, bounds, division by zero, narrowing conversions and contracts behave identically. A program that panics in a release build panics in a dev build at the same place with the same message. See Runtime checks.
  • The results. Integer and float semantics are Brine’s, not the C compiler’s: no fused multiply-adds, the same rounding, the same reduction order. A release build prints the same output as a dev build.
  • The runtime. The runtime (brine_rt.c) is always C, compiled once with the profile’s flags and cached.

C builds use -std=c11 -fno-strict-aliasing -w plus the profile’s flags. brine test --release runs the tests with the release profile.

All backends read the same Brine IR, which sits after the type checker and the prover. The IR keeps only the checks the prover couldn’t remove, and records what Brine knows that C doesn’t (no aliasing between values, race-free par).

Backend Source Writes Used for
x86-64 compiler/x64.br, obj.br COFF objects (Windows), ELF objects (Linux), with unwind tables and DWARF dev and debug builds on x86-64 Windows and Linux
AArch64 compiler/a64.br, obj.br ELF objects with .eh_frame and DWARF dev and debug builds on AArch64 Linux
C compiler/ir_c.br C11, one statement per IR instruction every --release build; every build on macOS and other platforms; every WebAssembly build; the bootstrap seed
PTX compiler/ptx.br NVIDIA GPU kernels par constructs that can run on a GPU, on Windows and Linux
Metal compiler/msl.br Metal Shading Language the same, on Apple GPUs (macOS)

The native backends write the object file themselves; the system C compiler is still used to link it with the runtime. The GPU backends run next to whichever CPU backend is used: the program carries both versions of each eligible construct, and the runtime picks one per launch. See GPUs.

Platform dev release debug
Windows x86-64 native (COFF) C native + DWARF
Linux x86-64 native (ELF) C native + DWARF
Linux ARM64 native (ELF) C native + DWARF
macOS Apple silicon C C C with #line
macOS Intel C C C with #line

macOS builds go through C until Brine writes Mach-O objects (on the roadmap). BRINE_BACKEND=c or =native overrides the choice; such builds go to their own folders (target/dev-c/, …).

--target wasm and --target web compile the generated C with wasi-sdk instead of the system C compiler, for every profile: dev at -O2 in parallel units, release at -O3 in one unit, debug at -O0 -g. They go to their own folders (target/dev-wasm/, target/release-web/, …). The checks and the results are the same as natively; par constructs run on one thread. See WebAssembly.

gcc and clang at -O2/-O3 already produce code close to the best hand-written C gets, with decades of optimization passes behind them. The native backends are built for compile speed: they turn the IR into machine code in one quick pass, with locals and floats kept in registers but without the loop optimizations, inlining across functions and vectorization a C compiler does. So Brine uses its own backends where you build often (dev, debug) and the C compiler where you want the fastest program (release). Optimization passes on the IR are on the roadmap.

A native backend’s advantages are elsewhere: builds take a fraction of a second, they will eventually need no C toolchain, and they can do what C can’t express well (exact overflow handling, guaranteed tail calls, debug info for Brine names and types).

brine build --emit-c writes the program as a single C file next to the executable (target/dev/hello.c), as the C backend generates it, even when the executable was built by a native backend:

Terminal window
brine build --emit-c
Wrote target/dev/hello.c
Fresh hello v0.1.0 [dev] in 0.02s
Output target/dev/hello.exe

It includes brine_rt.h, so compiling it by hand needs the runtime from the Brine source tree (runtime/brine_rt.h, runtime/brine_rt.c). It’s for reading and for curiosity: don’t edit it and build from it, since the next build regenerates it.

Builds do as little work as they can:

  • The runtime is compiled once per runtime version, C compiler and flags, into rt-<hash>/ in the cache. The first build of a new project takes about two seconds for this; later builds skip it.
  • Objects are named by a hash of what they’re made from. A C dev build splits the program into one translation unit per core (up to 16), assigning each function to a unit by a hash of its name, with each unit declaring only what it uses. The units compile in parallel, and an edit recompiles only the units it touches. Release builds stay a single unit, so the C compiler sees the whole program.
  • The link is skipped when nothing changed: the .brine-hash file next to the executable records what it was linked from, and a matching build prints Fresh.

The cache is target/cache/ in a project and <temp>/brine-cache/ for single files. See Projects and brine.toml.

The compiler uses par itself: imported modules are parsed a wave of imports at a time, in parallel, and the prover checks every function on its own thread. Results are combined in a fixed order, so diagnostics are the same whatever the thread count.

The compiler is about 34,000 lines of Brine. Building it with itself on a Ryzen 9 9950X3D (Windows 11, gcc 16.2), with the runtime already cached:

Build Time
dev, native x86-64 0.92 s
dev, nothing changed (Fresh) 0.66 s
dev through C (BRINE_BACKEND=c, 16 units in parallel) 2.65 s
release through C (-O3, one unit) 27.4 s

BRINE_TIMES=1 shows where the time goes in the native build:

Terminal window
BRINE_TIMES=1 brine build compiler/main.br -o brine2
parse 0.263s
check 0.122s
uniform 0.013s
verify 0.157s
lower 0.071s
x86-64 0.162s
link 0.089s
Built main [dev] in 0.92s

brine check stops after verify, which is why it is the fastest way to find errors. A small program checks in a few milliseconds and builds in about 0.1 s, almost all of it linking.

The bench/ folder in the Brine repository has eight single-threaded programs, each written in Brine and in C with the same algorithm. Times are relative to the C version compiled with gcc -O2; lower is better, below 1.00x is faster than C.

Program What it exercises Brine release Brine dev (native)
nbody float math on an array of structs (C: 0.717 s) 1.01x 3.42x
spectral float math, small function calls, array reads 1.02x 2.03x
fannkuch small int arrays, swaps and index arithmetic 0.91x 1.22x
mandelbrot tight float loops over a 4000×4000 grid 1.00x 1.30x
sieve a 100M-element bool array, strided writes 1.15x 1.44x
binarytrees allocating and freeing many small recursive values 0.55x 0.89x
wordfreq building strings, counting them in a {str: int} map 3.91x 5.06x
matmul 800×800 float matrix multiply in flat arrays 1.16x 6.28x

Measured on a Ryzen 9 9950X3D, Windows 11, gcc 16.2 (MinGW), Brine 0.4.0, best of three runs. Expect a few percent of noise between runs, more for the shortest programs.

What the numbers say:

  • Release builds run at C’s speed on numeric code (nbody, spectral, mandelbrot), within a few percent, with every check that remains still running.
  • Brine wins where its memory model helps. binarytrees runs in about half of C’s time: small blocks come from Brine’s own allocator, and a value with only one reference is freed without an atomic instruction, while the C version calls malloc and free for every node.
  • Checks that remain cost something. In sieve, the prover can’t show that is_prime[j] is in bounds in the inner loop, so the bounds check runs on every write of the inner loop (brine check --report lists it). matmul and nbody have no checks left at all.
  • Strings and maps are the slow spot. wordfreq takes almost 4x C’s time. The C version writes each word into a reused char[16] buffer and looks it up in a bare open-addressing table; the brine version builds a new reference-counted string for every word (w += letters[...] makes a one-character string per step), then looks the word up twice (w in counts, then counts[w] += 1) in a map that also keeps insertion order. The C run is also only 23 ms, so fixed startup costs show more. Faster strings and maps are ongoing work; 0.4.0 already made s += t grow strings in place.
  • Dev builds trade run time for build time. The native backend doesn’t vectorize or optimize loops, which matters most for matmul (6.28x) and nbody (3.42x). For anything you time, use --release.

These programs are single-threaded on purpose, to compare code generation. With par, Brine programs also use every core and, when it’s faster, the GPU: see GPUs for those numbers.

From a clone of the Brine repository, with Brine and a C compiler on your PATH:

Terminal window
git clone https://github.com/brinelang/brine && cd brine
brine run bench/compare.br

bench/compare.br builds each program three ways (C with $CC -O2, falling back to the first of cc, gcc and clang; Brine --release; Brine dev), checks that all three print the same output, and prints the best of three runs:

program C -O2 release dev (native)
nbody 0.708s 0.710s 1.00x 2.441s 3.45x
spectral 0.271s 0.274s 1.01x 0.548s 2.02x
fannkuch 1.686s 1.537s 0.91x 2.038s 1.21x
mandelbrot 0.567s 0.597s 1.05x 0.821s 1.45x
sieve 0.253s 0.351s 1.39x 0.508s 2.01x
binarytrees 1.337s 0.747s 0.56x 1.183s 0.88x
wordfreq 0.023s 0.090s 3.90x 0.117s 5.04x
matmul 0.062s 0.068s 1.10x 0.405s 6.53x

(That is a second run on the same machine: the short programs, sieve and matmul especially, vary the most.) It tests the brine on your PATH; to test another build, pass its path first (it must contain a / or end in .exe): brine run bench/compare.br -- ./brine2. Program names after that run only those programs: brine run bench/compare.br -- nbody sieve. The executables go to target/bench/.