Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
## 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·W` contracts
`[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; `broadcast` produced the `{:s, 32}` zeros that in turn made
`select` fall back.
- **integer literals** — `coerce_to/2` now converts rank-0 integer constants
and, via new `cast_s32_to_f32/f64` shaders, integer tensors of any shape. Nx
materialises literals as `{:s, 32}`: relu's `max(x, 0)`, a mean's divisor,
`select`'s zeros and pooling's `init_value` were 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) == 0`
turns an invisible performance cliff into a test failure. Off by default.
- **`test/nx_vulkan/grad_test.exs`** — gradient parity against
`BinaryBackend`. 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 Nx` contains zero gradient
examples, `deps/nx` ships no `test/`, and upstream's own
`Nx.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`](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). `0` restores 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`](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.Compiler` splits stages at `dot`
boundaries, so a graph that is almost all `dot`s 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/2` and `covariance/3` joined the doctest `@rounding`
bucket: 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_max` round-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.md` records what the audit established and how the
bug class stayed invisible; `PLAN_AFTER_BACKWARD_PASS.md` carries the
remaining work, each item with the measurement that motivates it.
- `test/nx_vulkan/node_test.exs` did check-then-act (`whereis` then `stop`) on a
globally named GenServer that every test `start_link`s from the test process,
so the node was already dying from its link when `on_exit` ran. 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.Defn` fusion compiler (`Nx.Vulkan.Compiler`).** An
`Nx.Defn.Compiler` that traces a `defn` to 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 at
`dot` / `conv` / `reduce` / `transpose` boundaries with `reshape` /
`squeeze` as zero-copy view boundaries and tuple/multi-output support.
Whole dense/CNN layers, classifier heads, softmax, layernorm, and
`x @ Wᵀ` fuse. Both f32 and f64. Cross-stage CSE exists but is
default-off (raced across the fleet, never wins; opt-in `NXV_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 `:nx` version constraint is now `{:nx, "~> 0.13"}`
(was `~> 0.10 or ~> 0.11 or ~> 0.12`). `VulkanoBackend` and 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 from `mix` and 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](https://github.com/vulkano-rs/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 vulkano
`Subbuffer<[u8]>` in a Rustler resource; the BEAM GC's drop
triggers vulkano's `vkDestroyBuffer + vkFreeMemory` chain.
- 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`
(routes `Nx.Block.LinAlg.SVD/QR/Cholesky/solve` through
`BinaryBackend`).
- 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's
`StandardDescriptorSetAllocator` creates a fresh
`DescriptorPool` per 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 matches `BinaryBackend`
byte-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 `BinaryBackend`
to 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 `BinaryBackend` to 2e-6 on synthetic
regression. SVD via host-fallback `block/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 + `glslangValidator` required:
- Linux: `apt install libvulkan-dev vulkan-tools glslang-tools`
- FreeBSD: `pkg install vulkan-loader vulkan-headers vulkan-tools glslang shaderc`
- 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.dot` falls back to host).
- Native linalg shaders (SVD, QR, Cholesky, solve) — Scholar
currently routes these through host.
- Custom `Nx.Defn` compiler — today we run through
`Nx.Defn.Evaluator` op-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 `VulkanoBackend` yet.
### Links
- Blog: [The Backend That Didn't Need to Know](http://www.dataalienist.com/blog-backend-didnt-need-to-know.html)
- Roadmap: [`docs/VULKANO_BACKEND_ROADMAP.md`](docs/VULKANO_BACKEND_ROADMAP.md)
- 10-minute intro: [`livebooks/intro_10min.livemd`](livebooks/intro_10min.livemd)
- Examples: [`examples/axon_training_loop.exs`](examples/axon_training_loop.exs),
[`examples/full_bench.exs`](examples/full_bench.exs)