0.3.0 (2026-08-08)
The backward-pass release. 0.2.0 shipped conv, the fusion compiler and native f32 — but a CNN training step still ran mostly on the CPU, and nothing in the suite could see it.
Being precise about what that means, because "broken" would overstate it:
0.2.0 always computed correct results. The host fallback is the
Nx.BinaryBackend reference, bit for bit, and forward/inference genuinely ran
on the GPU. What was wrong is that it was an inference backend published as a
training backend. A LeNet training step took 20.9 s; it takes 84 ms here.
Anyone following the README's "autograd for free" headline got ~250× less than
advertised, and no assertion on values could have revealed it — which is why
this release ships the tooling that can (Nx.Vulkan.Fallback) alongside the
fix.
This release also stops paying one command-buffer submit and one fence wait per op, worth a further 1.45–1.71× on a training step across the fleet.
If you are on 0.2.0 and training on the GPU, upgrade.
Fixed — the backward pass
A host fallback returns a bit-identical result, because the fallback is the
Nx.BinaryBackend reference every test compares against. So no assertion on
values can detect that an op silently left the GPU, and one had: conv's entire
backward pass ran in pure Elixir. Nx.Defn.Grad (hidden, so not linked) emits
ops nobody writes by
hand, and every GPU fast path had been gated on the shapes a forward pass
produces.
Eight ops moved back on-device. Seven were narrow gates refusing work the
existing shaders could already do; only reverse and broadcast needed new
kernels.
- conv — accepts non-identity input/kernel/output permutations (the gradient swaps the first two axes) by rotating into the native layout on-device, and coerces a mismatched operand dtype instead of refusing it.
- dot — accepts any rank-2 contraction orientation.
y = x·Wcontracts[1]/[0]and always hit the shader; its gradients arrive as[1]/[1]and[0]/[0], so every dense layer was paying two host matmuls per step. - reduce — accepts a kept axis in the middle (
sum(axes: [0,2,3]), the conv bias gradient) by rotating the kept axes to the front. - max/min pooling, both directions. Forward is one thread per output;
backward is one thread per input, which is what avoids float atomics and
is why it requires non-overlapping windows. Ties go to the last maximum
in row-major order, verified against
BinaryBackend—>instead of>=is correct on random data and wrong on every relu-zero tie. - reverse and broadcast — new index-remap shaders (rank ≤ 4). Both had
no shader at all;
broadcastproduced the{:s, 32}zeros that in turn madeselectfall back. - integer literals —
coerce_to/2now converts rank-0 integer constants and, via newcast_s32_to_f32/f64shaders, integer tensors of any shape. Nx materialises literals as{:s, 32}: relu'smax(x, 0), a mean's divisor,select's zeros and pooling'sinit_valuewere each dragging a whole tensor to the CPU behind a four-byte constant.
A CNN training step now performs exactly one host fallback: pow in f64,
which GLSL.std.450 does not provide and which should stay a fallback rather
than silently boundary-cast through f32.
Added — verification that can see this class of bug
Nx.Vulkan.Fallback— counts host fallbacks per process, attributing each to the callback at compile time.assert Fallback.count_total(fun) == 0turns an invisible performance cliff into a test failure. Off by default.test/nx_vulkan/grad_test.exs— gradient parity againstBinaryBackend. The suite previously had no gradient tests at all, which is how the conv regression survived; "autograd for free" was the headline claim of the README and nothing exercised it.docs/BACKEND_VERIFICATION_GAP.md— what the Nx ecosystem does and does not verify about a third-party backend.doctest Nxcontains zero gradient examples,deps/nxships notest/, and upstream's ownNx.Helpers.check_grads!is behind the packaging wall.
Performance
One value_and_grad step, batch 32, vs Nx.BinaryBackend, losses
bit-identical:
| model | RTX 3060 Ti | GT 650M (2012) | GT 750M (2013) |
|---|---|---|---|
| conv→conv→dense | 31.0 ms (436×) | 35.1 ms (477×) | 25.4 ms (440×) |
| LeNet-style | 84.1 ms (363×) | 77.6 ms (434×) | 64.3 ms (334×) |
The LeNet step was 20.9 s before this work. Absolute GPU times cluster in 25–85 ms across cards spanning 2012–2021 — at this size the work is dispatch-bound, not compute-bound.
Changed — one submit per batch of dispatches, not one per op
Acting on "dispatch-bound" above. Every op used to build its own command
buffer, submit it, and block on queue.wait_idle(). Dispatches are now
recorded into a pending queue and submitted as one command buffer with one
fence wait, flushed automatically at every host boundary (buf_download,
buf_upload_into, concat_buffers, fft) and at a size cap. Correctness
never depends on flushing by hand.
This is safe rather than a synchronisation minefield for two reasons:
vulkano's AutoCommandBufferBuilder tracks resource usage while recording and
inserts the pipeline barriers between commands in build(), so a
read-after-write between two batched dispatches is synchronised; and the only
way a value reaches the host is a download, which flushes first.
One value_and_grad step of an MNIST MLP at batch 32, submit-per-dispatch vs
batched, losses bit-identical:
| host | GPU | before | after | |
|---|---|---|---|---|
| super-io | RTX 3060 Ti (Ampere) | 16.446 ms | 9.627 ms | 1.71× |
| mac.247 | GT 650M (Kepler, 2012) | 14.583 ms | 8.829 ms | 1.65× |
| 248 | GT 750M (Kepler, 2013) | 13.301 ms | 9.147 ms | 1.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. No hardware crossover, unlike register blocking (Ampere-only) and
the many-slot fused reduce (Kepler-only), so this needs no
Nx.Vulkan.Device.class/0 gate and is on by default.
bench_results/BATCHED_DISPATCH.md.
Added — dispatch batching controls
Nx.Vulkan.NativeV.flush/0— submits any recorded-but-unsubmitted dispatches and waits. Never required for correctness; it matters for measurement, since timing a loop with no readback now times the recording rather than the work.NXV_BATCH_MAX— dispatches to record before forcing a submit (default 64).0restores submit-per-dispatch, and is the A/B control for any measurement of this change as well as the escape hatch if a driver dislikes long command buffers. The cap measured flat from 32 to 256 on all three fleet hosts, so 64 is a safe default rather than a tuned constant.
Measured — where the remaining gap is, and where it is not
Raced against EXLA (CUDA) on the Axon MNIST model, one training step, losses
bit-identical to BinaryBackend
(bench_results/MNIST_EXLA_RACE.md):
- The fusion compiler regresses on a matmul-dominated graph — 0.76× on
the dense MLP, 0.98× on the CNN.
Nx.Vulkan.Compilersplits stages atdotboundaries, so a graph that is almost alldots has nothing for tracing and boundary buffers to amortise against. Reading the eager-vs-fused rows and concluding "we need more fusion" would build the wrong thing; that is what motivated batching instead. - Correction to an earlier claim. A previous version of that file said EXLA
"failed to compile" conv. That was wrong, and wrong in the direction that
flattered this project — it was written from one failing run without
isolating the cause. EXLA compiles and trains convolutional models normally.
A 17-variant matrix narrows the real failure to two stacked convs + stride
2 +
channels: :first, in the gradient only; any single relaxation compiles, and Axon's default layout (:last) avoids it entirely.
Notes
standard_deviation/2andcovariance/3joined the doctest@roundingbucket: both used to host-fall-back and match exactly, and now run natively 1 ULP away. Excepting a function drops all of its doctests (863 → 851), so the bucket is worth watching rather than growing silently.- Nx.BinaryBackend's
window_scatter_maxround-trips f64 through f32. For f64 pooling gradients this backend is now more accurate than the reference it is tested against, so the pooling test asserts values are exact elements of the source rather than agreement with the host. docs/BACKWARD_PASS_AUDIT.mdrecords what the audit established and how the bug class stayed invisible;PLAN_AFTER_BACKWARD_PASS.mdcarries the remaining work, each item with the measurement that motivates it.test/nx_vulkan/node_test.exsdid check-then-act (whereisthenstop) on a globally named GenServer that every teststart_links from the test process, so the node was already dying from its link whenon_exitran. A latent flake, unrelated to any GPU work; now tolerates the pid dying in that window.
0.2.0 (2026-08-02)
The fusion compiler release. First release since 0.1.0.
Added
Nx.Defnfusion compiler (Nx.Vulkan.Compiler). AnNx.Defn.Compilerthat traces adefnto a stage schedule running on-device with GPU-resident intermediates and no interpreter fallback: elementwise fusion (one shader, one dispatch), parallel fused reductions (workgroup-per-slot tree reduce), and a multi-stage split atdot/conv/reduce/transposeboundaries withreshape/squeezeas zero-copy view boundaries and tuple/multi-output support. Whole dense/CNN layers, classifier heads, softmax, layernorm, andx @ Wᵀfuse. Both f32 and f64. Cross-stage CSE exists but is default-off (raced across the fleet, never wins; opt-inNXV_CSE=1). Use:Nx.Defn.jit(&fun/2, compiler: Nx.Vulkan.Compiler).- Native f32 compute. The hot ops (elementwise, matmul, conv, reduce, transpose) now dtype-dispatch native f32 shaders alongside f64, instead of casting f32 to f64. f64 remains the default accumulator policy (safe); f32 wins on bandwidth-bound ops and is available where a workload opts into it. This supersedes the "f64-only compute" note below for those ops.
- conv (im2col + GEMM) and transpose as native GPU ops, in f32 and f64.
Changed
- f64-only compute (later superseded — see "Native f32 compute"
under Added above; native f32 shaders were re-added for the hot ops).
Native shaders — elementwise binary/unary,
reductions, rank-2 matmul (
matmul_f64.spv), and the leapfrog chain synth — now run in f64; f32 inputs are accepted and cast to f64. This supersedes 0.1.0's "elementwise f32 + f64 / matmul f32-only" coverage: matmul is now native f64 (was host-fallback for f64), and the eXMC precision contract is:f64(EMLX, the f32-only backend, was dropped). Consumer GPUs are slower at f64, but correctness took priority over the f32 speed path. - Support Nx 0.13. The
:nxversion constraint is now{:nx, "~> 0.13"}(was~> 0.10 or ~> 0.11 or ~> 0.12).VulkanoBackendand the f64 chain-shader synthesis path run unchanged against nx 0.13's backend API. Required for consumers on nx 0.13 — e.g. eXMC's vulkan milestone, which references this repo frommixand could not resolve against the prior sub-0.13 constraint.
0.1.0 (2026-05-20)
First Hex release.
Added — Nx.Vulkan.VulkanoBackend (pure-Rust path)
A new Nx.Backend implementation built on the vulkano
Rust wrapper around Vulkan compute. Sibling to the existing
Nx.Vulkan.Backend (C++ spirit-backed); they share the SPV
catalog under priv/shaders/ and the chain-shader synthesis
pipeline.
Why a second backend. A use-after-free in the C++ FFI layer
crashed the live trader three minutes after every restart —
Nx.Vulkan.Native.byte_size raising :badarg on a stale
VkBuf* pointer that had outlived its referent. Vulkano's
Arc<Buffer> ownership makes that bug class structurally
impossible: a Subbuffer<u8> cannot outlive its parent at the
Rust type level.
What it ships.
- Buffer lifecycle:
buf_upload,buf_alloc,buf_download,buf_byte_size,buf_upload_into. Each wraps a vulkanoSubbuffer<[u8]>in a Rustler resource; the BEAM GC's drop triggers vulkano'svkDestroyBuffer + vkFreeMemorychain. - Compute ops (24 native through specialised SPVs):
- Elementwise binary (f32 + f64): add, subtract, multiply, divide, pow, max, min.
- Elementwise unary (f32 + f64): exp, log, sqrt, abs, negate, sigmoid, tanh, floor, ceil, sign.
- Reductions (f32 + f64): sum, reduce_max, reduce_min; all-axes, leading-axis, trailing-axis.
- Shape / movement: reshape (zero-copy), squeeze (zero-copy), 2D transpose.
- Matmul: rank-2 × rank-2, f32 only.
- Host-fallback callbacks (correctness first; perf-native
shaders pending): slice, as_type, comparison ops (equal,
not_equal, less, less_equal, greater, greater_equal), select,
all, any, dot (non-standard axis configs),
block/4(routesNx.Block.LinAlg.SVD/QR/Cholesky/solvethroughBinaryBackend). - Pipeline cache keyed by
(spv_path, op_code). First call builds the layout + pipeline; subsequent calls reuse them. Required for long-running workloads (without it, vulkano'sStandardDescriptorSetAllocatorcreates a freshDescriptorPoolper unique layout identity, eventually exhausting driver limits on FreeBSD).
Validated workloads.
- Axon training step: Dense → sigmoid → Dense + MSE +
Nx.Defn.value_and_grad. Forward loss matchesBinaryBackendbyte-identical; gradient sum agrees to 1e-8. 100-step SGD trajectory matches at every step within 2e-6; final loss agrees to 4e-7 with both backends converging by 350×. - eXMC regime model log-posterior: 8 free RVs, softmax-mixture
custom likelihood over 200 observations. Matches
BinaryBackendto 1e-7 at f64 precision. Roughly 2× faster than the C++ path on the bench target (GT 650M, FreeBSD 15.0). - Scholar linear regression (normal equation + SVD):
coefficients match
BinaryBackendto 2e-6 on synthetic regression. SVD via host-fallbackblock/4.
Autograd. No backward callbacks were written. Nx.Defn.grad
is a graph transformation that expresses backward ops in terms
of forward ops — forward op coverage is therefore gradient
coverage when running through Nx.Defn.Evaluator. Validated
end-to-end via the Axon training step.
Added — Mission II chain-shader synthesis
Exmc.NUTS.CustomSynth-style runtime synthesis of multi-RV
HMC/NUTS chain shaders. Take a multi-RV IR with a Custom
likelihood, trace via Nx.Defn, emit GLSL, compile to SPIR-V,
content-address cache, dispatch. Validated on the regime model
(8 RVs + 200-obs softmax-mixture) on GT 650M at 60 ms per K=32
leapfrog dispatch — 8.3× under the 500 ms/sample budget.
Existing — Nx.Vulkan.Backend (C++ spirit path)
The legacy backend stays in this release. It runs the chain-shader
synthesis pipeline and the Mission II dispatch. The stale-handle
bug class that motivated the migration is still present; the
recommended path forward is VulkanoBackend for general Nx
work plus the spirit-backed chain dispatch (or vulkano's
chain-shader dispatch via Nx.Vulkan.NativeV.leapfrog_chain_synth)
for HMC.
Build notes
- Rust 1.85 pinned via
rust-toolchain.toml. See the comment in that file for the upstream rustler reason. - Vulkan SDK +
glslangValidatorrequired:- Linux:
apt install libvulkan-dev vulkan-tools glslang-tools - FreeBSD:
pkg install vulkan-loader vulkan-headers vulkan-tools glslang shaderc
- Linux:
- vulkano 0.34 builds in ~30s on Linux, ~3:18 on FreeBSD 15.0.
What's missing (the honest queue)
- Persistent buffer pool (per-call allocation works but costs a millisecond per dispatch).
- f64 matmul shader (regime model's
Nx.dotfalls back to host). - Native linalg shaders (SVD, QR, Cholesky, solve) — Scholar currently routes these through host.
- Custom
Nx.Defncompiler — today we run throughNx.Defn.Evaluatorop-by-op; whole-graph optimisation is EXLA-style work. - Convolutions, FFTs, sort, scatter — the long tail of Nx ops.
- R4 live-trader cutover — the production trader has not been
switched to
VulkanoBackendyet.
Links
- Blog: The Backend That Didn't Need to Know
- Roadmap:
docs/VULKANO_BACKEND_ROADMAP.md - 10-minute intro:
livebooks/intro_10min.livemd - Examples:
examples/axon_training_loop.exs,examples/full_bench.exs