Powered by AppSignal & Oban Pro

Livebook + Numerical Elixir

talks/2026/03-numerical-elixir.livemd

Livebook + Numerical Elixir

Mix.install(
  [
    {:kino, "~> 0.11"},
    {:bandit, "~> 1.0"},
    {:req, "~> 0.4"},
    {:kino_vega_lite, "~> 0.1"},
    {:kino_bumblebee, "~> 0.4"},
    {:exla, ">= 0.0.0"},
    {:kino_db, "~> 0.2"},
    {:exqlite, "~> 0.11"}
  ],
  config: [nx: [default_backend: EXLA.Backend]],
  system_env: [
    CC: "/opt/homebrew/opt/llvm@16/bin/clang",
    CXX: "/opt/homebrew/opt/llvm@16/bin/clang++"
  ]
)

Why Livebook and Numerical Elixir?

  • Focus on product and developer tooling
  • Technical-depth across language design, web apps, and machine learning
  • Leading both in-house and open source teams

Intro to Elixir and Livebook

Elixir is a dynamic, concurrent and distributed programming language that runs on the Erlang VM:

list = ["hello", 123, :banana]
Enum.fetch!(list, 0)

Concurrency

In Elixir, all code runs inside lightweight threads called processes. We can literally create millions of them:

for _ <- 1..1_000_000 do
  spawn(fn -> :ok end)
end

Process communicate by sending messages between them:

parent = self()

child =
  spawn(fn ->
    receive do
      :ping -> send(parent, :pong)
    end
  end)

send(child, :ping)

receive do
  :pong -> :it_worked!
end

And Livebook can helps us see how processes communicate between them!

Bridging the gap: smart cells

The Erlang VM provides a great set of tools for observability. Let’s gather information about all processes:

processes =
  for pid <- Process.list() do
    info = Process.info(pid, [:reductions, :memory, :status])

    %{
      pid: inspect(pid),
      reductions: info[:reductions],
      memory: info[:memory],
      status: info[:status]
    }
  end

But how to plot it?

Numerical Elixir

Challenge: using Elixir for deep learning sounds counter intuitive.

BUT!

@jit
def predict(params, batch):
    x = batch @ params["w1"] + params["b1"]    # dot(w1) |> add(b1)
    x = jax.nn.sigmoid(x)                      # logistic()
    x = x @ params["w2"] + params["b2"]        # dot(w2) |> add(b2)
    x = jax.nn.softmax(x, axis=1)              # softmax()
    return x

With tensor compilers, Python is simply a tracer/tape recorder. However:

  • Can’t trace conditionals
  • Can’t trace side-effects (x[y] = z)

JAX official guide says:

> JAX is intended to be used with a functional style of programming

> Unlike NumPy arrays, JAX arrays are always immutable

The Elixir version:

 defn predict(params, batch) do
    x = Nx.dot(batch, params.w1) + params.b1
    x = Nx.logistic(x)
    x = Nx.dot(x, params.w2) + params.b2
    Nx.softmax(x)
  end

Demo time

Smart cells for machine learning!

A (almost) complete ecosystem for machine learning

  • Nx => Numpy + JAX + Tensor Serving
    • Runs on OpenXLA (Google), PyTorch (Meta), MLX (Apple)
  • Axon => PyTorch Ignite
  • Bumblebee => Hugging Face Transformers
  • Scholar => scikit-learn + cuML
  • Livebook => Jupyter notebooks
  • ??? => Pandas + Arrow

Results

  • Nx runs in production in many Elixir companies, albeit small models
  • Livebook has taken over multiple domains: teaching, embedding, and machine learning
  • Polars was the wrong foundation for dataframes, pivoting to DuckDB

My responsibilities

  • As a developer
    • Overall Nx design
    • Implemented autograd
    • Implemented concurrent and distributed serving
  • As a product lead
    • Shaped the overall vision for Livebook as a developer tool
    • Built the team (3-7 devs) who worked on Livebook (in-house) and Livebook Teams (product)
  • As a technical leader
    • Coordinated open-source work across multiple projects
    • Presented at technical and academic conferences

In-depth demo

defmodule WebAI do
  # Our abstraction for web apps
  use Plug.Builder

  plug :fetch_query_params
  plug :render

  def render(conn, _opts) do
    # Load a predefined model from hugging face
    {:ok, model_info} =
      Bumblebee.load_model({:hf, "finiteautomata/bertweet-base-emotion-analysis"})

    {:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "vinai/bertweet-base"})

    serving =
      Bumblebee.Text.text_classification(model_info, tokenizer,
        compile: [batch_size: 1, sequence_length: 100],
        defn_options: [compiler: EXLA]
      )

    # Run the model
    output = Nx.Serving.run(serving, conn.params["text"])

    # Format and send the response
    response =
      for %{label: label, score: score} <- output.predictions do
        "* #{label} (#{trunc(score * 100)}%)\n"
      end

    Plug.Conn.send_resp(conn, 200, response)
  end
end

# Run the web server
Kino.start_child!({Bandit, plug: WebAI, port: 6789})
resp = Req.get!("http://localhost:6789", params: [text: "oh wow, i didn't know that"])
IO.puts(resp.body)