erlCuda

GPU compute for the BEAM. erlCuda lets Erlang code launch CUDA kernels written in Rust, using Rustler NIFs as the bridge and the Rust-CUDA toolchain to compile Rust to PTX.

Status: early alpha. Four kernels run end-to-end (Erlang -> Rustler NIF -> dedicated GPU worker thread -> real CUDA kernel -> async result): vector_add, reduce, dot_product, and matmul. This is backed by explicit multi-GPU device selection, opportunistic per-device job batching, and a reproducible pure-Rust-vs-full-stack latency benchmark (see Benchmarks below) — the initial roadmap items are all addressed, though several are explicitly noted below as best-effort or untested beyond the maintainer's own machine rather than fully hardened.

Why

NVIDIA's Rust support for CUDA (via the Rust-CUDA project: rustc_codegen_nvvm

The interesting engineering problem isn't the kernel code itself, it's making GPU execution behave inside the BEAM's cooperative scheduling model.

Architecture

+-------------------------------------------------------------+
| Erlang app |
| |
| erlcuda:launch(Kernel, Args) -> {ok, JobId} |
| receive {erlcuda, JobId, {ok, Result}} -> ... end |
+-------------------------------|------------------------------+
| NIF call (non-blocking)
v
+-------------------------------------------------------------+
| erlcuda_nif (Rust, Rustler) |
| |
| - validates args, encodes job |
| - pushes job onto a channel to the GPU worker |
| - returns {:ok, job_id} to BEAM immediately |
+-------------------------------|------------------------------+
| mpsc channel
v
+-------------------------------------------------------------+
| GPU worker (dedicated OS thread) |
| |
| - owns the CUDA context for its device (one thread per device) |
| - loads compiled kernel modules (PTX) |
| - launches kernel, (optionally streams / async copies) |
| - on completion: OwnedEnv::send_and_clear(pid, {:erlcuda, |
| job_id, result}) |
+-------------------------------|------------------------------+
| cust host API
v
+-------------------------------------------------------------+
| GPU kernels (Rust, no_std) |
| |
| compiled with rustc_codegen_nvvm to nvptx64-nvidia-cuda PTX, |
| loaded and launched by the host-side worker above |
+---------------------------------------------------------------+

Why a dedicated GPU worker thread, not a NIF-per-call

A CUDA context is bound to the OS thread that created it. The BEAM runs NIFs across a pool of scheduler threads (plus dirty schedulers), so naively calling CUDA driver APIs directly from a regular NIF means the context migrates between threads across calls, which the CUDA driver does not support cleanly.

erlCuda instead runs a single long-lived OS thread per GPU that owns the CUDA context for its whole lifetime. NIFs never touch the GPU directly; they only enqueue work on a channel and return immediately. This also means:

Dirty schedulers are deliberately avoided as the primary mechanism: they help with blocking calls, but a kernel launch is not actually CPU-bound blocking work, and pinning the CUDA context's owning thread ourselves gives full control over queueing, batching, and lifetime.

Components

erlCuda/
├── src/
│ ├── erlcuda.erl # public API (launch/2,3, launch_sync/2,3)
│ ├── erlcuda_nif.erl # NIF loader/stubs
│ └── erlcuda.app.src # OTP application resource file
├── native/
│ └── erlcuda_nif/ # Rust crate, Rustler NIF + GPU worker thread
│ ├── src/
│ │ ├── lib.rs # NIF entry points (launch_vector_add, launch_reduce,
│ │ │ # launch_dot_product, launch_matmul)
│ │ ├── worker.rs # GPU worker thread, channel, async result delivery
│ │ ├── backend.rs # Backend trait, CpuBackend, CudaBackend (owns
│ │ │ # Context/Module/Stream, launches the kernel)
│ │ └── job.rs # Job struct crossing the channel
│ └── Cargo.toml
├── kernels/ # Rust GPU kernels compiled to PTX
│ ├── src/
│ │ └── lib.rs # #[no_std] kernel functions
│ └── Cargo.toml # built with rustc_codegen_nvvm (Rust-CUDA)
├── test/
│ └── erlcuda_tests.erl # EUnit test suite
├── bench/
│ └── erlcuda_bench.erl # full-stack latency benchmark (see Benchmarks below)
├── rebar.config
└── LICENSE

Requirements

Building rustc_codegen_nvvm (one-time, per machine)

native/erlcuda_nif/build.rs compiles kernels/ to PTX via cuda_builder, which needs the rustc_codegen_nvvm backend available as a dynamic library on PATH. This repo deliberately has no root Cargo workspace (native/erlcuda_nif/ must stay on stable, kernels/ on nightly), which rules out cuda_builder's own "build it for me" paths — so until this is scripted, it has to be built once by hand from the pinned Rust-CUDA checkout:

# Locate Cargo's own checkout of the Rust-CUDA repo, fetched automatically
# as a git dependency of this crate, at commit
# 6a836d9236fc38e0fa7a71f7bdeda7a8f82bc8d5 — normally under
# ~/.cargo/git/checkouts/rust-cuda-*/6a836d9*/ (run `cargo build` once first
# if it doesn't exist yet, to make Cargo fetch it):
cd <path-to-that-checkout>
cargo build -p rustc_codegen_nvvm --release

Once built inside that same checkout (not an independently git cloned copy — the auto-discovery below only looks in Cargo's own git-checkout cache, and only accepts a checkout of this exact pinned commit), native/erlcuda_nif/build.rs automatically finds it and extends its own PATH for the build — you don't need to manually export PATH in every new shell session. The one-time cargo build -p rustc_codegen_nvvm --release step above is still required if you've never built it on this machine before.

Without that one-time build, cargo build in native/erlcuda_nif/ fails to locate the codegen backend.

Usage

-module(example).
-export([run/0]).
run() ->
{ok, JobId} = erlcuda:launch(vector_add, [A, B]),
receive
{erlcuda, JobId, {ok, Result}} -> Result;
{erlcuda, JobId, {error, Reason}} -> erlang:error({gpu_kernel_failed, Reason})
end.

erlcuda:launch_sync/3 wraps the receive above with a timeout for the common case; see src/erlcuda.erl.

Pass {device, N} as an option to target a specific GPU (defaults to {device, 0}): erlcuda:launch(vector_add, [A, B], [{device, 1}]).

Three more kernels are available besides vector_add, using the same launch/launch_sync API:

erlcuda:launch_sync(reduce, [A]),
erlcuda:launch_sync(dot_product, [A, B]),
erlcuda:launch_sync(matmul, [AFlat, BFlat, M, N, K])

reduce sums a single vector down to one value; dot_product takes two equal-length vectors and returns their dot product as a single value; matmul multiplies an M x K matrix by a K x N matrix (each passed flattened, row-major) and returns the flattened M x N result.

Note on batching: the per-device worker's opportunistic batching (see Roadmap below) only coalesces a batch into a single kernel launch when every job in it is vector_add targeting the same device. If a drained batch contains any reduce, dot_product, or matmul job — or a mix of kernel types — the worker falls back to running each job in that batch one at a time, in order, still returning the correct per-job result for each. This was already true architecturally before reduce/dot_product/matmul existed; it's called out here now that there's more than one non-vector_add kernel to wonder about.

Benchmarks

Two small programs measure per-job vector_add latency (1000-element f32 vectors, one job in flight at a time) at opposite ends of the stack:

At 1000 elements the actual GPU compute time is negligible either way, so the gap between the two (if any) should mostly reflect NIF/channel/BEAM-message overhead rather than kernel execution time.

Reproduce with (requires a git clone — bench/ is dev tooling and isn't shipped in the hex.pm package):

cd native/erlcuda_nif && cargo run --release --features bench --bin bench_pure_cuda
erl -pa _build/default/lib/erlcuda/ebin -eval "erlcuda_bench:run(), init:stop()." -noshell # from the repo root, after `rebar3 compile`

A methodology caveat found while measuring this

Both programs are correct on their own, but running them as two separate, freshly-launched OS processes back-to-back is not a clean way to compare steady-state latency: an earlier review pass reported that whichever program happened to run second in a pair tended to read noticeably higher, regardless of which program it was — attributed to CUDA context creation/teardown handoff contention between the outgoing and incoming process, not to either program's own behavior.

Six alternating-order trials taken in one session on this machine did not reproduce that flip. In every trial, erlcuda_mean_us came out higher than pure_cuda_mean_us, whether erlCuda ran first or second:

trial 1st run 1st value (us) 2nd run 2nd value (us)
1 pure_cuda 328.7 erlcuda 369.2
2 erlcuda 383.6 pure_cuda 337.8
3 pure_cuda 334.4 erlcuda 371.3
4 erlcuda 372.3 pure_cuda 337.7
5 pure_cuda 336.6 erlcuda 373.3
6 erlcuda 376.4 pure_cuda 347.9

pure_cuda_mean_us ranged 328.7-347.9 (median ~337.2us); erlcuda_mean_us ranged 369.2-383.6 (median ~372.8us), with no overlap between the two ranges across those 6 runs — at the time, that looked like a real, order-independent full-stack overhead of roughly 35-40us (~10-11%), distinct from the position-dependent noise described above.

A second independent session on the same machine and the same checkout contradicted this in direction. Re-running both programs 8 more times (4 pure_cuda-first, 4 erlcuda-first) gave:

pure_cuda: 451.3, 431.6, 438.6, 445.1, 458.3, 446.4, 449.6, 443.0 (range 431.6-458.3)
erlcuda: 440.9, 424.5, 421.4, 419.3, 429.6, 427.6, 423.4, 429.6 (range 419.3-440.9)

This time pure_cuda read consistently higher than erlcuda by roughly 15-20us, the opposite of the first session's finding — and both sessions' absolute magnitudes shifted too (420-460us here vs. 330-380us before), most likely from ambient GPU/driver/thermal state at the time of each session.

Taken together, the honest conclusion is that this benchmark cannot reliably determine even the direction of erlCuda's overhead at this vector size, let alone its magnitude. Session 1's two ranges didn't overlap at all; session 2's overlapped somewhat (431.6-440.9us shared by both), but its means still moved in the opposite direction from session 1's. Either way, the two sessions disagree with each other about which side was faster. Whatever the true NIF/channel overhead is at 1000 elements, it is evidently small enough to be dominated by session-to-session noise (GPU clock/power state, driver scheduling, background load) when measured this way — one-shot process launches with 100 in-process repetitions each. A methodology that could actually resolve this would need to interleave both measurements within a single long-lived session (or otherwise control for cross-session GPU state), which is out of scope for the simple manual comparison built here.

This is a single-machine sample, not a tracked benchmark and not a general performance claim, and — as demonstrated above — not even a stable one across sessions on the same machine. There's no CI job pinning these numbers, no fixed hardware/driver baseline, and no statistical confidence interval behind them. Run the commands yourself; do not treat either session's numbers, or this project's, as a reliable verdict on which side is faster.

Roadmap

Credits

This project is a thin bridge between two existing pieces of work; the hard technical parts belong to them:

License

Apache License 2.0. See LICENSE.