Elixir: Numerical & Embedded Elixir
BEAM languages were never fast at raw numeric loops — so the ecosystem did not try to make Elixir
fast. It made Elixir the orchestrator: numerical kernels run in native code behind
ordinary functions. The same philosophy puts Elixir inside microcontrollers with Nerves.
Nx: Tensors and defn
Nx brings NumPy-style tensors to Elixir. Write numerical code inside defn
— a purely numerical function — and the same code can be JIT-compiled to CPU, GPU, or TPU through
the EXLA backend.
# In a script: Mix.install([{:nx, "~> 0.9"}, {:exla, "~> 0.10"}])
defmodule ML do
import Nx.Defn
# defn restricts code to tensor operations — that is what lets the
# compiler lower it to native/GPU code.
defn softmax(t) do
Nx.exp(t) / Nx.sum(Nx.exp(t))
end
end
t = Nx.tensor([1.0, 2.0, 3.0])
ML.softmax(t)
#=> #Nx.Tensor<f32[3] [0.0900, 0.2447, 0.6652]>
Explorer and Bumblebee
Explorer— data frames (the pandas equivalent) built on Rust's Polars; chains withExplorer.DataFrame.filter/2style verbs.Bumblebee— load pretrained Hugging Face models (speech, vision, text) in three lines and serve them behindNx.Serving— a supervised, batched, multi-node inference service.Membrane— multimedia pipeline framework: composable elements for RTP, WebRTC, HLS; the same supervision model applied to media streams.
# Serving a pretrained model — the orchestration is plain OTP.
{:ok, model_info} = Bumblebee.load_model({:hf, " bert-base-uncased"})
serving = Bumblebee.Text.text_embedding(model_info, compile: [batch_size: 8])
Nx.Serving.run(serving, "Elixir makes ML serving boring") #=> tensor
Nerves: Embedded Elixir
Nerves compiles an OTP application into firmware for Raspberry Pi-class devices:
your supervision tree boots on the device, wired to hardware through the Circuits
libraries. AtomVM shrinks the same idea to microcontrollers. The result: a sensor
reading loop is a supervised process with a timer — the exact patterns from phase 4.
# A supervised hardware loop — no init scripts, no watchdog hacks:
defmodule Sensor do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts[:pin], name: __MODULE__)
@impl true
def init(pin) do
# Re-deliver :read to ourselves every second, forever.
Process.send_after(self(), :read, 1_000)
{:ok, pin}
end
@impl true
def handle_info(:read, pin) do
# Circuits.GPIO — hardware I/O as ordinary functions.
value = Circuits.GPIO.read(pin)
# Standard telemetry event (see the Performance lesson).
:telemetry.execute([:sensor, :reading], %{value: value})
Process.send_after(self(), :read, 1_000)
{:noreply, pin}
end
end
When Elixir Is the Wrong Host
- Model training — the Python ecosystem (PyTorch, JAX) is orders of magnitude deeper; train there, serve with Bumblebee.
- Dense linear algebra on CPU without a backend — EXLA helps, but a Rust NIF may beat it.
- Hard real-time (microsecond determinism) — no garbage-collected runtime belongs there; BEAM soft-real-time (bounded, predictable latency) is a different, valuable property.
The sweet spot: serving and orchestration — where many concurrent requests meet heavy kernels, and fault tolerance matters more than single-core speed.
Practice
- Run the softmax
defnin a script with EXLA; compare the result with a hand-rolled version usingEnum. - Load one Bumblebee model and serve two concurrent requests through
Nx.Serving. - Explain to a colleague why serving (not training) is where Elixir fits.
Next: Lab Examples