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.
Profiles
Section titled “Profiles”| 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.
Backends
Section titled “Backends”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/, …).
WebAssembly
Section titled “WebAssembly”--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.
Why release goes through C
Section titled “Why release goes through C”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).
The generated C
Section titled “The generated C”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:
brine build --emit-c Wrote target/dev/hello.c Fresh hello v0.1.0 [dev] in 0.02s Output target/dev/hello.exeIt 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.
Caching
Section titled “Caching”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-hashfile next to the executable records what it was linked from, and a matching build printsFresh.
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.
Compile speed
Section titled “Compile speed”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:
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.92sbrine 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.
Performance
Section titled “Performance”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
mallocandfreefor 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 --reportlists 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, thencounts[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 mades += tgrow 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.
Reproducing the numbers
Section titled “Reproducing the numbers”From a clone of the Brine repository, with Brine and a C compiler on your PATH:
git clone https://github.com/brinelang/brine && cd brinebrine run bench/compare.brbench/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.45xspectral 0.271s 0.274s 1.01x 0.548s 2.02xfannkuch 1.686s 1.537s 0.91x 2.038s 1.21xmandelbrot 0.567s 0.597s 1.05x 0.821s 1.45xsieve 0.253s 0.351s 1.39x 0.508s 2.01xbinarytrees 1.337s 0.747s 0.56x 1.183s 0.88xwordfreq 0.023s 0.090s 3.90x 0.117s 5.04xmatmul 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/.