Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ LivePlot([iterable,] *panels, total=None, initial=0, unit="step", unit_scale=1,
| `progress`, `desc` | switch the tqdm bars off, or give the single-loop form's bar a description. |
| `record` | `True` keeps every rendered frame in `plot.frames`; a path such as `"run.gif"` also writes an animated GIF at `finish()`. |

## `liveplot.warm()`

Keep a render process ready so every later plot shows its first frame immediately instead of after ~0.6 s. Call once in a setup cell; optional.

## Logging

| call | meaning |
Expand Down
11 changes: 11 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ The training thread only appends numbers (about 40 µs per `log`). A separate re

Interrupting the cell is safe. Jupyter sends its interrupt to every process the kernel started; the render process ignores it, so you get a frozen plot with `plot.data` intact. A plot that is dropped without `finish()` shuts its process down when garbage collected, and the process exits by itself if the notebook kernel dies.

## Starting instantly

A plot's first frame waits for its render process to start, which is mostly a fresh Python importing matplotlib: about 0.6 s. Call `liveplot.warm()` once, in a setup cell, and a render process is kept ready in the background, so every plot from then on appears as soon as its first point is logged.

```python
import liveplot
liveplot.warm()
```

It is optional and off by default; the cost is one idle process that exits with the kernel.

## Options

| | |
Expand Down
4 changes: 4 additions & 0 deletions examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@

import numpy as np

import liveplot
from liveplot import LivePlot

MAIN = __name__ == "__main__"

if MAIN:
liveplot.warm() # optional: keeps a render process ready, so each plot below appears immediately


def slow(seconds=0.01):
"""The toy models below train in well under a second; this makes the plots watchable."""
Expand Down
4 changes: 2 additions & 2 deletions liveplot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Live training curves in notebooks: see `LivePlot`."""

from .liveplot import LivePlot
from .liveplot import LivePlot, warm

__all__ = ["LivePlot"]
__all__ = ["LivePlot", "warm"]
__version__ = "0.1.0"
101 changes: 74 additions & 27 deletions liveplot/liveplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,14 +346,30 @@ def render(self) -> bytes:
# --------------------------------------------------------------------------- the render process


def _render_worker(panels, inbox, outbox, refresh_seconds, layout, xlim, parent_pid):
def _render_worker(inbox, outbox, parent_pid):
"""
Loop: collect messages from `inbox`, redraw at most once per `refresh_seconds`
while there is new data, put PNG bytes on `outbox`. Messages: ("data", step,
metrics); ("layout", panels) to rebuild the figure; None to finish (draw one
last frame, put None, exit). Exits on its own if the parent process is gone.
First: import matplotlib and draw a throwaway frame (the slow part of starting up, ~0.6 s), then
wait for ("init", panels, refresh_seconds, layout, xlim). Doing it in that order is what lets
`warm()` start a process before any plot exists.
Then loop: collect messages from `inbox`, redraw at most once per `refresh_seconds` while there is
new data, put PNG bytes on `outbox`. Messages: ("data", step, metrics); ("layout", panels) to
rebuild the figure; None to finish (draw one last frame, put None, exit). Exits on its own if the
parent process is gone.
"""
signal.signal(signal.SIGINT, signal.SIG_IGN) # Jupyter interrupts the whole process group; not our business
_FigureRenderer([_normalise_panel("warm-up")], None, (1, None, None, (2, 2), 50, "step")).render()
while True:
if os.getppid() != parent_pid:
return
try:
msg = inbox.get(timeout=0.5)
except queue.Empty:
continue
if msg is None: # a spare that was never used, being shut down
return
if msg[0] == "init":
_, panels, refresh_seconds, layout, xlim = msg
break
renderer = _FigureRenderer(panels, xlim, layout)
dirty, last_draw, running = False, 0.0, True
while running:
Expand Down Expand Up @@ -395,6 +411,57 @@ def _render_worker(panels, inbox, outbox, refresh_seconds, layout, xlim, parent_
outbox.put(None)


def _spawn_renderer():
"""Start a render process; returns (process, inbox, outbox). It warms up, then waits for "init"."""
# "spawn", never "fork": the notebook process has usually initialised CUDA,
# and a forked child inherits a CUDA context it must not touch.
ctx = mp.get_context("spawn")
inbox, outbox = ctx.Queue(), ctx.Queue()
proc = ctx.Process(target=_render_worker, args=(inbox, outbox, os.getpid()), daemon=True)
# A spawned child re-runs the parent's __main__ *file* if there is one. In a
# notebook there isn't; in an interactive window / `python solutions.py`-style
# session `__main__.__file__` points at the whole notebook script, which the
# renderer has no use for (it would import torch and build environments just to
# draw a plot). Hide it for the duration of start() so the child stays light.
main = sys.modules.get("__main__")
hidden = {k: main.__dict__.pop(k) for k in ("__file__", "__cached__") if main is not None and k in main.__dict__}
try:
proc.start()
finally:
if main is not None:
main.__dict__.update(hidden)
return proc, inbox, outbox


_spare = None # a render process started by warm(), waiting to be adopted by the next LivePlot
_keep_warm = False # set by warm(): replace the spare each time a plot takes it


def warm():
"""
Keep a render process ready, so every `LivePlot` from now on shows its first frame almost
immediately instead of after the ~0.6 s it takes a fresh process to import matplotlib. Call it
once, in a setup cell. Each plot adopts the waiting process and a replacement starts in the
background. Optional: without it, each plot just takes that extra moment to appear. The spare is
an idle process that exits with the kernel.
"""
global _spare, _keep_warm
_keep_warm = True
if _spare is None or not _spare[0].is_alive():
_spare = _spawn_renderer()


def _take_renderer():
"""The warm spare if there is a live one (starting its replacement), else a freshly spawned process."""
global _spare
spare, _spare = _spare, None
if _keep_warm:
_spare = _spawn_renderer() # starting a process returns in milliseconds; it warms up in the background
if spare is not None and spare[0].is_alive():
return spare
return _spawn_renderer()


def _terminate(proc, inbox):
"""Finalizer: shut the render process down without blocking (used when a LivePlot is dropped)."""
if proc is not None and proc.is_alive():
Expand Down Expand Up @@ -751,28 +818,8 @@ def _panels_or_placeholder(self):
return self._specs or [_normalise_panel({"title": "waiting for data…", "metrics": ["_"]})]

def _start_process(self):
# "spawn", never "fork": the notebook process has usually initialised CUDA,
# and a forked child inherits a CUDA context it must not touch.
ctx = mp.get_context("spawn")
self._inbox, self._outbox = ctx.Queue(), ctx.Queue()
self._proc = ctx.Process(
target=_render_worker,
args=(self._panels_or_placeholder(), self._inbox, self._outbox, self.refresh_seconds, self._layout,
self.x_range, os.getpid()),
daemon=True,
)
# A spawned child re-runs the parent's __main__ *file* if there is one. In a
# notebook there isn't; in an interactive window / `python solutions.py`-style
# session `__main__.__file__` points at the whole notebook script, which the
# renderer has no use for (it would import torch and build environments just to
# draw a plot). Hide it for the duration of start() so the child stays light.
main = sys.modules.get("__main__")
hidden = {k: main.__dict__.pop(k) for k in ("__file__", "__cached__") if main is not None and k in main.__dict__}
try:
self._proc.start()
finally:
if main is not None:
main.__dict__.update(hidden)
self._proc, self._inbox, self._outbox = _take_renderer() # a warm() spare if there is one
self._inbox.put(("init", self._panels_or_placeholder(), self.refresh_seconds, self._layout, self.x_range))
weakref.finalize(self, _terminate, self._proc, self._inbox) # dropped without finish() -> no leak
# Frames are displayed by this thread as soon as the renderer produces them, so a loop that
# logs rarely (or is busy in a long step) still sees each frame when it is ready rather than
Expand Down
117 changes: 117 additions & 0 deletions tests/test_warm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import os
import subprocess
import sys
import time

import pytest

import liveplot
import liveplot.liveplot as L
from liveplot import LivePlot, warm


class _FakeHandle:
def __init__(self):
self.frames = []

def update(self, img):
self.frames.append((time.monotonic(), img.data))


@pytest.fixture(autouse=True)
def _reset_warm():
yield
if L._spare is not None and L._spare[0].is_alive():
L._spare[1].put(None)
L._spare[0].join(5)
L._spare, L._keep_warm = None, False


@pytest.fixture
def fake_notebook(monkeypatch):
h = _FakeHandle()
monkeypatch.setattr(LivePlot, "_make_display_handle", staticmethod(lambda: h))
return h


def _wait_warm(timeout=90):
"""A warm spare is ready once it has drawn its throwaway frame; we can't see that, so give it time."""
t0 = time.monotonic()
while time.monotonic() - t0 < timeout:
# the child blocks in inbox.get once warm; a crude but load-tolerant readiness signal is CPU going idle
with open(f"/proc/{L._spare[0].pid}/stat") as f:
fields = f.read().split(")")[-1].split()
state = fields[0]
if state == "S":
time.sleep(0.3)
with open(f"/proc/{L._spare[0].pid}/stat") as f:
if f.read().split(")")[-1].split()[0] == "S":
return
time.sleep(0.1)


def test_exported():
assert liveplot.warm is warm


def test_plot_adopts_the_warm_process(fake_notebook):
warm()
spare_pid = L._spare[0].pid
warm() # idempotent while a spare is waiting
assert L._spare[0].pid == spare_pid
p = LivePlot("loss", refresh_seconds=0.1, progress=False)
assert p._proc.pid == spare_pid, "the plot uses the spare"
assert L._spare is not None and L._spare[0].pid != spare_pid, "and a replacement spare is started"
p.log(0, loss=1.0)
p.finish()
assert fake_notebook.frames and not p._proc.is_alive()


@pytest.mark.skipif(not os.path.exists("/proc/self/stat"), reason="reads /proc")
def test_warm_first_frame_is_fast(fake_notebook):
warm()
_wait_warm()
t0 = time.monotonic()
p = LivePlot("loss", refresh_seconds=0.1, progress=False)
p.log(0, loss=1.0)
while not fake_notebook.frames and time.monotonic() - t0 < 30:
time.sleep(0.005)
first = fake_notebook.frames[0][0] - t0
p.finish()
assert first < 0.4, f"first frame took {first:.2f} s from a warm process"


def test_dead_spare_is_replaced(fake_notebook):
warm()
L._spare[0].terminate()
L._spare[0].join(5)
p = LivePlot("loss", refresh_seconds=0.1, progress=False) # must not try to use the dead one
p.log(0, loss=1.0)
p.finish()
assert fake_notebook.frames


def test_spare_exits_when_parent_dies():
code = f"""
import os, sys, time; sys.path.insert(0, {os.path.dirname(os.path.dirname(os.path.abspath(__file__)))!r})
import liveplot.liveplot as L
L.warm(); time.sleep(1.0)
print(L._spare[0].pid, flush=True)
os._exit(0)
"""
pid = int(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=60).stdout.strip())
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
return
time.sleep(0.2)
pytest.fail("unused spare outlived its parent")


def test_without_warm_no_spare_is_kept(fake_notebook):
p = LivePlot("loss", refresh_seconds=0.1, progress=False)
p.log(0, loss=1.0)
p.finish()
assert L._spare is None, "warm() was never called: plots don't leave processes behind"
Loading