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.

Elixir defn code compiled through EXLA to a native backend, orchestrated by normal Elixir processes
Fig. 1 — The orchestration stays in Elixir processes; the numeric kernel is JIT-compiled to native code.
# 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 with Explorer.DataFrame.filter/2 style verbs.
  • Bumblebee — load pretrained Hugging Face models (speech, vision, text) in three lines and serve them behind Nx.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

  1. Run the softmax defn in a script with EXLA; compare the result with a hand-rolled version using Enum.
  2. Load one Bumblebee model and serve two concurrent requests through Nx.Serving.
  3. Explain to a colleague why serving (not training) is where Elixir fits.

Next: Lab Examples