Forging deep learning in Zig

Fucina

Eager tensors, named axes, lightweight autograd.

A CPU-first tensor/autograd runtime and LLM inference engine — one program, in one language. No graph compiler, no lazy evaluation: what you write is what runs, in the order you wrote it. And eager doesn't mean slow — on measured CPU shapes it matches or beats llama.cpp.

eager · what you write is what runs CPU-first · the GPU is just matmul comptime-typed axes no GC · no Python · no C++
the first program — build · contract · differentiate
The mental model

Five ideas carry the whole library.

Eager & explicit
Every op validates, allocates, and calls a kernel immediately. No graph to build first, no fusion pass.
One tensor
The same facade carries no-grad inference and gradient-tracked training. Identical call sites.
Axes by name
Names and rank are part of the Zig type, checked at compile time. Sizes stay runtime.
Explicit ownership
Value handles over reference-counted buffers. Owned results, deterministic cleanup.
Sealed dtypes
f32 differentiates; f16/bf16 run forward math; quantized blocks serve as matmul RHS. The type system enforces it.
Named axes

Axes have names, not numbers.

Operations name the axes they act on, and broadcasting aligns axes by name, never by position. The tag algebra is comptime-only data — it compiles down to stride math with zero runtime tagging cost, and a misuse is a compile error, not a silent transpose.

x.dot(&ctx, &w, .in) — contract the axis called .in. No axis bookkeeping, no guessing which dimension is which.

The idea pays off hardest in einsum: operands already carry named axes, so there is no "gid,jd→gij" mini-language to parse at runtime — the output tag tuple is the whole equation, and a tag that exists in no operand is a compile error.

axis names live in the type — resolved at comptime
einsum with no string to parse — the out tags are the equation
deinit is a recycler — the same address comes back
ctx.replace — advance a carried tensor in one move
The data model

Memory that recycles itself.

A tensor is a value handle over a reference-counted buffer. deinit isn't a naive free — it's the recycling driver: the refcount hits zero, the buffer returns to the pool, and the next same-size op gets the same address back, keeping the hot working set warm in cache.

Views are refcounted aliases — a zero-copy narrow outlives its parent safely. No arena to reset, no garbage collector to chase. An idiomatic defer x.deinit() gives an O(1) working set within a forward pass; training holds exactly what backward needs, because operand views bump refcounts. An arena allocator was evaluated and rejected — the adjudication is on record in docs/MEMORY-MODEL.md.

Automatic differentiation

The gradient graph is the tensors.

Nothing records a tape and nothing builds a graph object: each result simply carries a pointer to the operation that produced it, so the dependency graph already exists in the values themselves. backward() discovers its topology by walking those pointers. A variable carries that state; a constant doesn't — and the call sites are identical, so training and inference take the same kernel path and produce identical values.

Write the forward once. Open an exec scope and defer-deinit inference code trains unchanged: every intermediate is adopted by the scope, deinit on it becomes a safe no-op, the whole graph lives until backward() — then one close releases the step.

Backward is concurrent with no persistent engine and no trace: each node carries an atomic dependency counter and fires on a bounded worker pool when it drains. The loop closes on CPU — LoRA fine-tune a quantized GGUF, merge, re-quantize, and the exported GGUF loads and answers in llama.cpp. Optimizers (SGD / AdamW / Muon / APOLLO) are golden-parity-tested against their references.

a variable records; a constant doesn't — same call sites
open a scope — the same forward trains as-is
one eager op · no graph, no capture, no replay
y = x.dot(&ctx, &w, .in)
├─ gate: big enough? work · shape · residency
│ no CPU kernels NEON·sdot / AVX2·VNNI
└─ yes submit now Metal / CUDA, at once
sync on first host read · queue ordered
declined CPU · the GPU is never needed
Metal (macOS) and CUDA (Linux) plug in at the op seam and accelerate the matmul family: f32/f16 GEMM, quantized prefill linears, MoE expert FFN — CUDA adds fused prefill attention and an opt-in decode GEMV. No device type in the API, no graph retained; CUDA needs no SDK at build time (dlopen'd cuBLAS + vendored PTX).
The design point

CPU-first. The GPU is just matmul.

Mainstream GPU stacks run on whole-graph machinery: fusion passes, static memory plans, captured launch sequences. Fucina builds no such graph — so there is nothing to hand a GPU, and that is the choice: on CPU, per-op dispatch costs next to nothing and kernels specialize at compile time — NEON/dotprod on Apple Silicon, AVX2/AVX-VNNI on x86 — as if -march=native were always on.

Measured, not asserted: paired same-machine runs, same GGUF, same threads, CPU-only both sides. On an M1 Max Fucina beats llama.cpp in 221 of 236 sweep cells and ties 13 — losses recorded as plainly as wins in docs/BENCHMARK.md, dated snapshot.

CPU-first is the destination, not a stage on the way to somewhere else. The known price: without a graph, a GPU helps only at the op seam — Fucina will never chase TensorRT-class GPU throughput, and says so.

Proof of work

Forged on real models.

The runtime grows through what it runs, and every family is validated against its reference implementation first: token-ID-exact tokenizers, logit-parity oracles vs llama.cpp, byte-exact quantization encoders, byte-identical GGUF re-emit. Parity first, speed second — in that order.

MoE bigger than RAM is first-class: --moe-stream pages routed experts from disk, bit-identical to the resident path — the 142 GB Qwen3-235B and the 164.6 GB DeepSeek V4 Flash decode on a 64 GB machine.

On top: lossless draft-model-free speculative decoding (up to 2.3×), batch-N multi-stream decode (3.2× aggregate at 8 streams), JSON-schema / grammar-constrained output, and an OpenAI-compatible server.

quick start — a GGUF in, a conversation out
Qwen3 0.6B→235B Qwen3.5 DeepSeek V2/V3 DeepSeek V4 Flash GLM-4.5 Gemma 4 DiffusionGemma Inkling nanochat Parakeet ASR OmniVoice TTS LocateAnything facedetect Neural Amp Modeler
fucina.es · OpenAI-ES, kept vanilla
eps_n ~ N(0, I) n = 1 … population
R_n = reward(theta + sigma * eps_n)
C_n = (R_n mean(R)) / (std(R) + 1e-8) z-score
theta += (alpha / population) * Σ C_n * eps_n
One scalar reward per member, so ES composes with anything scoreable from a forward pass — and every parameter is fair game: f32 / f16 / bf16 tensors, whole registries, even packed ternary genomes. No GradState needed.
Neuroevolution

Training without gradients.

fucina.es trains with forward passes only — a faithful reimplementation of ES-at-scale, kept deliberately vanilla. Perturb the parameters, score each member with one reward, and step toward the members that did better.

Because the signal is a scalar, it reaches places a gradient can't: non-differentiable rewards, and quantized ternary weights that have no gradient at all. Noise is never stored — every perturbation regenerates from (seed, iteration, member), O(1) memory beyond the parameters. zig build es-spirals trains from scratch with no backward anywhere; zig build es-finetune fine-tunes a real quantized GGUF this way, LoRA-only or full-parameter.