Nx.Vulkan

A GPU tensor backend for Nx that runs on anything with a Vulkan driver — including FreeBSD, where CUDA and Metal don't exist.

✓ Linux + NVIDIA RTX 3060 Ti (proprietary driver)
✓ FreeBSD + NVIDIA GT 750M (NVIDIA legacy driver)
✓ FreeBSD + NVIDIA GT 650M (NVIDIA legacy driver)

Why this exists →WHY.md — the f64 conviction, autograd-for-free, reach over peak FLOPS, and one-GPU-to-a-fleet.

Goals

What works today

Roadmap and future work: ROADMAP.md.

The Nx.Defn fusion compiler (thrust 3)

EXLA's structural edge over an eager backend is whole-graph compilation: it fuses a chain of ops into one kernel instead of dispatching each separately. Nx.Vulkan.Compiler is an Nx.Defn.Compiler that does the same for the cases it supports — the closest this project comes to closing that gap.

Nx.Defn.jit(&my_fun/2, compiler: Nx.Vulkan.Compiler).(a, b)

It traces a defn to an expression DAG and compiles it to a stage schedule that runs on-device with GPU-resident intermediates and no fallback to the interpreter:

Whole layers fuse end-to-end: relu(x @ W + b), relu(conv(x, k) + b), a CNN classifier head (conv → flatten → dense), softmax and layernorm reduction patterns, and transposed-weight layers (x @ Wᵀ). Anything unsupported falls back to Nx.Defn.Evaluator, so results are always correct — worst case is "no fusion, same as eager."

Standing

The fusion compiler is the goal this effort set out to reach: a credible #2 compute backend for elixir-nx, with EXLA's whole-graph compilation now present in the one place a Vulkan backend can offer it — on any GPU with a driver, CUDA or not.

Building on a compute kernel of your own? See the vulkan-nx-compute skill for the shader → NIF → Nx playbook and the hard-won parity/dispatch gotchas.

Position vs EXLA and EMLX

EXLAEMLXNx.Vulkan.VulkanoBackend
Backing APIGoogle XLAApple MLX (Metal)Khronos Vulkan via vulkano (Rust)
MaturityYears; productionReleased 2024Released 2026
Linux + NVIDIA CUDA✓ canonical✓ via Vulkan
macOS + Apple Silicon✓ canonical✓ via MoltenVK
FreeBSD + NVIDIA✓ only path
Windows / WSL2partial via TF✓ (Vulkan ships on Windows)
Op coveragefull Nx surface (~200)full Nx surfacenative core (elementwise, matmul, conv, reduce, pooling, layout ops), rest via host fallback
Nx.Defn fusion compiler✓ XLA whole-graph✓ MLX✓ multi-stage split (elementwise/reduce/dot/conv/transpose, f32+f64)
Nx.Defn.grad (autograd)fullfull✓ free (graph transformation)
fp64 computefullnone (Metal limit)✓ native f32 and f64 (binary/unary/reduce/matmul/conv/transpose)

The autograd insight

Nx.Defn.grad is a graph transformation that runs at compile time on the Nx.Defn.Expr AST. For every forward op in the graph, it inserts the corresponding backward op expressed in terms of more forward ops. The backend never sees a "backward op" — it just keeps executing forward primitives. Forward op coverage IS gradient coverage when running through Nx.Defn.Evaluator.

That means VulkanoBackend supports gradients for any function expressible in its native ops + host-fallback long tail. No backward callbacks were written. Validated by running a complete Axon training step (Dense → sigmoid → Dense → MSE → Nx.Defn.value_and_grad) on Nx.Vulkan.VulkanoBackend, with gradient sum agreeing to 1e-8 against the BinaryBackend reference.

Benchmarks

Batched dispatch (August 2026)

One value_and_grad step of an MNIST MLP at batch 32, submit-per-dispatch (NXV_BATCH_MAX=0) vs batched. Same graph, same commit, arms differing only in the environment variable:

hostGPUbeforeafter
super-ioRTX 3060 Ti (Ampere, 2021)16.4 ms9.6 ms1.71×
mac-247GT 650M (Kepler, 2012)14.6 ms8.8 ms1.65×
mac-248GT 750M (Kepler, 2013)13.3 ms9.1 ms1.45×

The loss is identical in every arm on every host — two architectures, two operating systems. Batching changes when work is submitted, never what is computed. Reproduce with examples/mnist_mlp_step_bench.exs; method and the full cap sweep in bench_results/BATCHED_DISPATCH.md.

The three tables below predate batched dispatch and are therefore pessimistic by roughly the factors above. They have not been re-run because their harnesses were scratch projects rather than committed examples — so they are left at their measured values rather than adjusted by arithmetic.

CNN training (August 2026, pre-batching)

One value_and_grad step, batch 32, versus Nx.BinaryBackend. Losses are bit-identical to the host on every row.

modelsuper-io (RTX 3060 Ti)mac-247 (GT 650M, 2012)mac-248 (GT 750M, 2013)
conv→conv→dense, strided31.0 ms (436×)35.1 ms (477×)25.4 ms (440×)
LeNet-style, max-pooled84.1 ms (363×)77.6 ms (434×)64.3 ms (334×)
inference, batch 25617.5 ms (1107×)71.1 ms (274×)31.8 ms (407×)

The LeNet step was 20.9 s before the backward pass stopped falling back to the host — the same measurement, same box. Read the absolute times rather than the multipliers: a speedup here mostly measures how slow pure-Elixir BinaryBackend is, which varies by host CPU. The absolute GPU times cluster in 25–85 ms across three cards spanning 2012–2021, because at this model size the work is dispatch-bound rather than compute-bound — which is why a 2012 laptop GPU is competitive with a 2021 desktop one.

vs EXLA (August 2026, pre-batching)

The Axon MNIST guide model, one training step at batch 32 on the RTX 3060 Ti — a dense-only MLP, which is the shape most favourable to EXLA and least favourable here:

backendmsvs BinaryBackend
Vulkan, eager14.1485×
Vulkan, Nx.Vulkan.Compiler18.5370×
EXLA (CUDA)0.7159581×

EXLA is ~20× ahead, and fusion does not close it — it costs 24% (0.76× fused vs eager). On a graph that is almost all dot, there is no elementwise work for fusion to amortise against, so the deficit is dispatch overhead and GEMM quality rather than missing whole-graph compilation.

That diagnosis is what batched submission acts on, and it is why this race is labelled pre-batching: the eager row above has since improved ~1.7× on this box. The race has not been re-run — it needs a working EXLA, which this repo deliberately does not depend on — so no combined figure is claimed here. The remaining lever the measurement points at is GEMM quality.

On a 2×strided-conv CNN the gap is similar — 41.3 ms eager vs 1.45 ms, with fusion neutral at 0.98× — so EXLA leads on both graph shapes tested. The qualitative difference is availability: EXLA cannot be installed on the two FreeBSD Keplers at all, where Vulkan runs the same CNN in 64–78 ms. Full numbers, an XLA gradient-compile failure narrowed to one specific conv configuration, and what it took to get EXLA running: bench_results/MNIST_EXLA_RACE.md.

f32 vs f64 per op

sh scripts/race.sh — f32 speedup over f64, same shapes, all on-GPU:

opsuper-iomac-247mac-248
matmul 512³0.61×0.47×0.45×
conv 16→32ch1.7×1.21×3.48×
elementwise add 1M2.38×4.3×7.01×
sum 1M1.97×1.9×1.9×

f32 matmul is slower than f64, and that is the accumulator policy working as designed: f32 matmul defaults to matmul_f32_f64acc.spv, paying an f32→f64 conversion on top of the same f64 MAC rate. f32 wins where it is meant to — bandwidth-bound elementwise and reductions. Switch with Nx.Vulkan.VulkanoBackend.put_f32_matmul_accumulator(:f32) if you want speed over the f64-accumulated reference.

Square matmul (May 2026)

Milliseconds per dispatch, median of 50–200 iterations:

sizebin (super-io)bin (mac-247)vulkano (super-io)vulkano (mac-247)
16×162.762.511.181.06
64×64130.76158.457.077.92
256×25620,09713,891149.19136.10
1024×1024n/a (hours)n/a (hours)2,3232,843

Full bench: examples/full_bench.exs.

Quickstart

As a backend in your project

# mix.exs
def deps do
[
{:nx, "~> 0.13"},
{:nx_vulkan, "~> 0.3"}
]
end

Or track main directly:

{:nx_vulkan, git: "https://github.com/borodark/nx_vulkan"}

On 0.2.0 and training on the GPU? Upgrade. 0.2.0 computed correct results, but its backward pass ran largely on the host — a LeNet step took 20.9 s there and 84 ms here. See the CHANGELOG.

# Build a tensor, transfer to GPU, do work
x_bin = Nx.tensor([1.0, 2.0, 3.0, 4.0], type: :f32)
x_vk = Nx.backend_transfer(x_bin, Nx.Vulkan.VulkanoBackend)
y_vk = Nx.sigmoid(x_vk)
y_bin = Nx.backend_transfer(y_vk, Nx.BinaryBackend)
IO.inspect(Nx.to_list(y_bin))
# [0.7310585975646973, 0.8807970881462097, 0.9525741338729858, 0.9820137619972229]

Try the Axon training example

git clone https://github.com/borodark/nx_vulkan
cd nx_vulkan
mix deps.get && mix compile
elixir examples/axon_training_loop.exs

Runs a 100-step Dense(4→32, tanh)→Dense(1) regression with manual SGD. Compares loss trajectories on BinaryBackend vs VulkanoBackend. PASS verdict on both Linux + FreeBSD.

Try the full bench

mix run examples/full_bench.exs

Per-op + end-to-end + robustness across every backend Nx can find. Auto-detects EXLA availability. Runs in ~10 minutes on RTX 3060 Ti, ~15 on GT 650M.

Building

Prerequisites

Build

mix deps.get
mix compile

Vulkano compiles in ~30s on Linux, ~3:18 on FreeBSD 15.0 (mostly dependency compilation). The spirit/C++ path compiles in parallel.

Rust toolchain pin

rust-toolchain.toml pins rustc to 1.85. The reason is in the file's comment; bump when upstream rustler emits a corrected rustler-sys signature.

Blog series

Sibling: zed

zed is the declarative ZFS + Elixir deploy tool that orchestrates BEAM nodes. nx_vulkan is consumed inside deployed BEAM nodes — not as a zed dependency. See specs/nx-vulkan-execution.md in the zed repo for the integration story.

License

Apache 2.0. Same as Spirit and Nx.