Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Elixir and Livebook: concurrency, web, and AI

talks/2024/10-lambda-world.livemd

Elixir and Livebook: concurrency, web, and AI

Mix.install(
  [
    {:kino, "~> 0.11.0"},
    {:bandit, "~> 1.0"},
    {:req, "~> 0.4"},
    {:kino_vega_lite, "~> 0.1.11"},
    {:kino_bumblebee, "~> 0.4.0"},
    {:exla, ">= 0.0.0"},
    {:kino_db, "~> 0.2.3"},
    {:exqlite, "~> 0.11.0"}
  ],
  config: [nx: [default_backend: EXLA.Backend]]
)

Welcome

Elixir is a dynamic and functional programming language that runs on the Erlang VM:

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

Functional == reproducible

What makes notebooks hard to reproduce?

flowchart TD;
    root[Sources of irreproducibility];
    ooo[Out of order execution];
    gms[Global mutable state];
    root-->ooo;
    root-->gms;

For example, in Jupyter notebooks, the execution flow is:

flowchart LR;
  state((State));
  c1[Cell A];
  c2[Cell B];
  c3[Cell C];
  state--read #2 -->c1--write #2-->state;
  state--read #3 -->c2--write #3-->state;
  state--read #1 -->c3--write #1-->state;

Notebooks may linearize cells via static and dynamic analysis:

flowchart LR;
  state((State));
  c3[Cell C];
  c1[Cell A];
  c2[Cell B];

  state--read #1 -->c3--write #1-->state;
  state--read #2 -->c1;
  c2-- write #2 -->state;
  c1-->c2;

However, even if ordering is employed, they are still stateful. The following code, in most notebooks, will increment x:

x = 1
x = x + 1

Livebook execution model is fully sequential and there is no global mutable state. It looks like this:

flowchart TD;
  c1[Cell A];
  c2[Cell B];
  c3[Cell C];
  c1--Binding + Environment-->c2--Binding + Environment-->c3;

Now we can track inputs and outputs and, with a pinch of static analysis, we can cache evaluation results and notify when cells become stale.

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:

Maybe you want to see how Elixir can perform multiple tasks at once, scaling on both CPU and IO?

Kino.Process.render_seq_trace(fn ->
  ["/foo", "/bar", "/baz", "/bat"]
  |> Task.async_stream(
    fn _ -> Process.sleep(Enum.random(100..300)) end,
    max_concurrency: 4
  )
  |> Enum.to_list()
end)

Let’s take visualizations even further!

Plotting live data

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?

Demo time: Web + AI

Live programming: drag and drop

Try drag-and-dropping some files!

Live programming: debugging

Use |> dbg() after a pipeline for some awesome debugging.

Live programming: doctests

Doctests sit at the intersection of documentation and testing:

defmodule HelloWorld do
  @doc """

      iex> HelloWorld.my_addition(1, 2)
      3

      iex> HelloWorld.my_addition(1, 2)
      4

      iex> HelloWorld.my_addition(1, "2")
      3

  """
  def my_addition(a, b) do
    a + b
  end
end