GPU feasibility
An empirical answer to a question docs/rewrite-plan.md defers to this file:
should Twill get a GPU backend, and if so when. Everything below was measured
on one machine on 2026-08-07. Nothing here is estimated.
The short version: a GPU backend is technically reachable, the honest speedup is about 9x and not the 30x the rewrite plan guesses at, it only applies to matmul above roughly 128x128, it is slower than the current CPU code at the sizes Twill actually benchmarks, and it costs the no-dependencies property that the project is built on. Recommendation is do not do this yet.
The machine
ASUS ROG Zephyrus G16. Intel Core Ultra 9 285H, 16 cores, 16 threads. NVIDIA GeForce RTX 5070 Laptop GPU (Blackwell, 36 CUs, 8GB) plus an Intel Arc 140T integrated GPU (128 CUs, shared memory). Windows 11.
This is a hybrid laptop, not a workstation. That matters for the conclusion: any CUDA-only story silently excludes one of the two GPUs in this very machine, and excludes every contributor without an NVIDIA card.
What was measured
1. Driver and CUDA support
nvidia-smi on the host:
NVIDIA-SMI 610.88 KMD Version: 610.88 CUDA UMD Version: 13.3
0 NVIDIA GeForce RTX 5070 ... WDDM | 00000000:01:00.0 Off | N/A
57C P8 3W / 65W | 114MiB / 8151MiB | 0% Default
A current driver supporting CUDA 13.3. No local CUDA toolkit is installed
(nvcc is absent), so compiling CUDA means a container.
2. Docker GPU passthrough
Docker 29.6.2, with nvidia present in the runtime list. The literal test:
$ MSYS_NO_PATHCONV=1 docker run --rm --gpus all \
nvidia/cuda:12.6.0-base-ubuntu22.04 nvidia-smi
NVIDIA-SMI 610.57.01 KMD Version: 610.88 CUDA UMD Version: 13.3
0 NVIDIA GeForce RTX 5070 ... On | 00000000:01:00.0 Off | N/A
58C P8 3W / 65W | 114MiB / 8151MiB | 0% Default
Passthrough works. The card is visible inside a container with no extra setup.
It is worth recording what happened next, because it is part of the cost. Pulling
nvidia/cuda:12.8.1-devel-ubuntu22.04, which is roughly 5GB, wedged the Docker
Linux engine. The API began returning 500 Internal Server Error on every route
including _ping, and docker calls then hung indefinitely. Restarting Docker
Desktop and terminating the docker-desktop WSL distro did not bring the engine
back within the session. The end-to-end CUDA kernel compile was therefore not
completed here. On a 16GB machine the CUDA devel image is not a casual
dependency, and a build pipeline that needs it is not a casual pipeline.
That gap is covered, and more usefully, by the next test, which runs a real f64 kernel on the same card without any of that machinery.
3. The vendor-neutral options
Both GPUs register ICDs on this host, read out of the driver registry:
NVIDIA GeForce RTX 5070 Laptop GPU
OpenCL: ...\nvami.inf_amd64_...\nvopencl64.dll
Vulkan: ...\nvami.inf_amd64_...\nv-vk64.json
Intel(R) Arc(TM) 140T GPU (8GB)
OpenCL: ...\iigd_dch.inf_amd64_...\igdrcl64.dll
Vulkan: ...\iigd_dch.inf_amd64_...\igvk64.json
OpenCL.dll and vulkan-1.dll are both present in System32. Neither needs an
SDK to use: the ICD loader is part of the driver install.
OpenCL was tested properly. A host program that resolves the loader with
LoadLibrary/GetProcAddress, declares the dozen API entry points it needs, and
carries no headers and no SDK, compiles with the mingw gcc already on this box
and runs against both GPUs:
OpenCL platforms: 2
[NVIDIA CUDA]
device: NVIDIA GeForce RTX 5070 Laptop GPU
OpenCL 3.0 CUDA | CUs=36 | 1545MHz | mem=8.5GB
cl_khr_fp64 extension: YES DOUBLE_FP_CONFIG=0x3f
f64 vecadd n=1048576: host-verified max abs err=0 c[0]=1.0000 c[n-1]=786432.2500
fma32: 0.13 ms 18009.9 GFLOP/s
fma64: 6.90 ms 341.7 GFLOP/s
>> f32:f64 ratio = 52.7 : 1
[Intel(R) OpenCL Graphics]
device: Intel(R) Arc(TM) 140T GPU (8GB)
OpenCL 3.0 NEO | CUs=128 | 2350MHz | mem=8.4GB
cl_khr_fp64 extension: YES DOUBLE_FP_CONFIG=0x3f
f64 vecadd n=1048576: host-verified max abs err=0 c[0]=1.0000 c[n-1]=786432.2500
fma32: 2.60 ms 3223.4 GFLOP/s
fma64: 72.44 ms 115.8 GFLOP/s
>> f32:f64 ratio = 27.8 : 1
So a double-precision kernel does run, on both vendors, and its result is
bit-exact against the host (max abs err = 0 over a million elements).
Scoring the four candidates on this machine:
- OpenCL. Viable, and the only one demonstrated end to end here. Works on both GPUs, no SDK, no admin, no container. Its weakness is that it is a legacy-ish API that vendors maintain rather than invest in.
- CUDA. Passthrough proven, but NVIDIA-only, needs a container to compile, and the container broke the engine on this machine. It would leave the Arc GPU unused.
- Vulkan compute. Plausible: both vendors ship ICDs. Not tested, because
compiling GLSL to SPIR-V needs
glslc, which is absent. More portable than CUDA and considerably more work than OpenCL. - WebGPU/wgpu. Not testable here. There is no local Rust toolchain, and wgpu would mean either cgo against a native library or shelling out to a separate binary. Also, WebGPU has no f64 type at all, which by itself rules it out for Twill.
4. The f64 throughput reality
The claim to check was that consumer NVIDIA cards run double precision at about 1/64 of single. Measured on this card: 1/52.7. So the claim is close to right, slightly pessimistic, and the correction does not change anything.
The RTX 5070 does 18.0 TFLOP/s in f32 and 342 GFLOP/s in f64. Twill's tensors
are []float64 throughout, so Twill gets the 342, which is 5% of the number
the card is sold on. The Arc iGPU is less lopsided in ratio (27.8:1) but slower
in absolute terms (116 GFLOP/s f64), so it is worse either way.
5. GPU against Twill's actual CPU code
internal/tensor.mm is a parallel triple loop over []float64, split across
cores by runChunks. Measured, both in-repo and as a standalone copy of mm
at sizes the repo does not yet benchmark:
BenchmarkMatMul64-16 74233 ns/op
BenchmarkMatMul256-16 1925365 ns/op
standalone mm(), GOMAXPROCS=16:
N time f64 GFLOP/s
64 74.2 us 7.1
128 289.1 us 14.5
256 1762.8 us 19.0
512 9939.5 us 27.0
1024 85878.0 us 25.0
Against the same matmul in OpenCL f64 on the RTX 5070, timed two ways: kernel only, which is what a device-resident tensor would cost, and round trip, which is what a naive per-op offload would cost:
N kernel-only round-trip f64 GFLOP/s (rt)
64 10.0 us 92.8 us 5.7
128 17.6 us 187.5 us 22.4
256 100.6 us 438.9 us 76.5
512 707.3 us 1841.1 us 145.8
1024 5666.6 us 9378.0 us 229.0
Putting those side by side is the whole argument:
| N | CPU | GPU round trip | speedup | GPU kernel only | speedup |
|---|---|---|---|---|---|
| 64 | 74.2 us | 92.8 us | 0.8x | 10.0 us | 7.4x |
| 128 | 289 us | 187 us | 1.5x | 17.6 us | 16.4x |
| 256 | 1763 us | 439 us | 4.0x | 101 us | 17.5x |
| 512 | 9940 us | 1841 us | 5.4x | 707 us | 14.1x |
| 1024 | 85878 us | 9378 us | 9.2x | 5667 us | 15.2x |
Three things follow.
First, at 64x64, the size BenchmarkMatMul64 uses, offloading is a loss.
The GPU takes 92.8 us to do what the CPU does in 74.2 us. Transfer and launch
overhead is roughly 80 us per op and it does not shrink.
Second, the ceiling is about 9x with transfers and about 15x if tensors live on
the device. docs/rewrite-plan.md weighs "a 2.6x on CPU and a 30x on GPU". The
30x is not available here, in f64, on this card. The real comparison is 2.6x
against 15x, and the 15x only for large matmul.
Third, the Intel iGPU never beats the CPU except at N=1024, where it manages 65.5 GFLOP/s round trip against the CPU's 25. The vendor-neutral story is real for correctness and useless for speed on the integrated part.
What integrating this into Twill would cost
The backward pass
Tensor is Data []float64 plus Grad []float64, and every backward closure
reaches straight into those slices. MatMul's is representative:
return track2(res, a, b, func() {
g := res.Grad
if a.RequiresGrad {
bt := transpose2d(b.Data, k, n)
dA := mm(g, m, n, bt, k)
ga := a.ensureGrad()
for i := range dA { ga[i] += dA[i] }
}
...
})
A device-resident tensor means Data and Grad are handles, not slices, and
every one of those closures has to be rewritten. That is the entire operator
surface: ops.go (1234 lines), einsum.go, conv.go, scan.go, gather.go,
and jet.go, which does forward mode and would need each op's JVP on the device
too. There are 152 direct .Data uses in non-test code, 37 of them outside
internal/tensor, in internal/value, internal/interp and cmd/twill. So
the boundary is not contained inside the tensor package today, and containing it
is a prerequisite, not a detail.
Halfway is worse than either end. If only matmul is offloaded, every op around it pays the 80 us round trip, and the table above says that loses below N=128. A GPU backend that pays for itself has to keep tensors resident across a whole training step, which means the autodiff graph itself becomes device-aware.
There is a second, quieter cost. parallel.go is explicit that results are
"bit-identical to a serial run" and "the same on any number of cores", and
parallelSum uses fixed-size blocks specifically so the answer does not depend
on the worker count. GPU reductions do not give that for free. docs/finance.md
sells determinism and reproducibility as a thing Twill wins on "regardless".
A GPU backend puts that in tension with itself, and for finance that is not a
small print item.
The zero-dependency policy
go.mod has no requires. The release workflow builds with CGO_ENABLED=0 across
a GOOS/GOARCH matrix. Design principle 4 is "No dependencies", and the README
leads with a binary that has none.
Every GPU option breaks that in practice. CUDA needs cgo or a driver-API
binding. OpenCL, even in the header-free form used above, still needs a runtime
dlopen of a vendor library and a way to call into it, which under
CGO_ENABLED=0 means either purego-style assembly thunks or a third-party
package. Calling it "no dependency" because the loader ships with the driver
would be a word game. The dependency is the driver, and it is not in the binary,
and it is not portable across the release matrix.
The build
This is the hard blocker and it is worth stating plainly. The current pipeline
cross-compiles to every target from one runner with cgo off. A CUDA build cannot
be produced that way at all. Even the OpenCL path cannot: CGO_ENABLED=0 means
no C calls, and turning cgo on means a per-target C toolchain, which means the
matrix stops being a matrix. Adding a GPU backend is not a new file in
internal/tensor. It is a different release model, and a second binary flavour
to test and support.
Recommendation
Do not do this yet. Not never, but not now, and not before the things ahead of it.
The evidence for that, in order of weight:
- The workload is not there. Twill's benchmarks are 64x64 matmul, 128x128
broadcast add, 256x32 softmax.
minParallelis 8192, so most ops do not even go multi-core today. At those sizes the GPU is measurably slower. Buying a GPU backend now means buying a 0.8x. - f64 throws away 95% of the card. 342 GFLOP/s out of 18 TFLOP/s. The biggest single GPU win available to Twill is not a GPU backend at all, it is f32 support, which is worth up to 52.7x on the same silicon and costs a type parameter rather than a rewrite. That question should be settled first, because a device-resident f64 tensor engine is a lot of work aimed at the slow path of the hardware.
- The ceiling is 15x, not 30x.
docs/rewrite-plan.mdshould be corrected. 15x is still more than the Rust rewrite's 2.6x to 3.2x, so this does not settle the roadmap ranking on its own, but it should be ranked on the real number and only for large dense matmul. - It contradicts stated positions.
docs/finance.mdalready lists "competing with CUDA/PyTorch on large dense deep learning under a pure-Go, no native deps constraint" as a non-goal. Nothing measured here argues that non-goal was wrong. It argues it was right.
What is worth doing instead, cheaply:
- Add matmul benchmarks at N=256, 512, 1024. They are the sizes where any of this becomes a question, and right now there is no CPU baseline at 512 or 1024 in the repo.
- Treat f32 as the open performance question rather than GPU.
- If someone does want to prototype, use OpenCL, not CUDA. It ran on both GPUs here with no SDK, no container, and no admin, and it keeps the Arc part in play. Keep it out of tree and behind a build tag so the default binary and the release matrix stay exactly as they are.
Revisit when there is a real Twill program whose matmuls are 256x256 or larger and whose profile is matmul-bound. Until such a program exists, this is optimising a workload nobody has run.
Reproducing
The probes are standalone C and Go, not part of the build. The OpenCL ones need
only gcc and a GPU driver:
gcc -O2 clprobe.c -o clprobe.exe && ./clprobe.exe # devices, fp64, f32:f64 ratio
gcc -O2 clrt.c -o clrt.exe && ./clrt.exe # matmul kernel vs round trip
The CPU column is a verbatim copy of internal/tensor.mm and runChunks run
under go run, so it is the same code path the interpreter uses.