From da3c7e88236314d75e31688da6b3c8c6a61f2d88 Mon Sep 17 00:00:00 2001 From: David Quarel Date: Thu, 17 Sep 2026 14:44:08 +0000 Subject: [PATCH] liveplot.warm(): keep a render process ready so plots appear immediately A plot's first frame waited ~0.75 s for its render process, almost all of it a fresh Python importing matplotlib. The render worker now does that warm-up first and then waits for its configuration, so a process can be started before any plot exists. warm() starts one; each LivePlot adopts the waiting process and a replacement starts in the background. Opt-in: without warm() nothing changes and no process is left behind. Measured in a real kernel, three plots in a row with a 2 s gap between cells: first frame 0.72-0.78 s cold, 0.21-0.24 s with warm(). The demo's setup cell calls it; the guide and API pages document it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01G9zs684RFxHvW6cNWqTPSF --- docs/api.md | 4 ++ docs/guide.md | 11 ++++ examples/demo.py | 4 ++ liveplot/__init__.py | 4 +- liveplot/liveplot.py | 101 +++++++++++++++++++++++++++---------- tests/test_warm.py | 117 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 29 deletions(-) create mode 100644 tests/test_warm.py diff --git a/docs/api.md b/docs/api.md index 69a281d..12f9e0c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | diff --git a/docs/guide.md b/docs/guide.md index b7b70e1..85faac8 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 | | | diff --git a/examples/demo.py b/examples/demo.py index 0b4f9e1..7231016 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -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.""" diff --git a/liveplot/__init__.py b/liveplot/__init__.py index 257fe41..3f1b835 100644 --- a/liveplot/__init__.py +++ b/liveplot/__init__.py @@ -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" diff --git a/liveplot/liveplot.py b/liveplot/liveplot.py index f64fdd9..70023d1 100644 --- a/liveplot/liveplot.py +++ b/liveplot/liveplot.py @@ -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: @@ -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(): @@ -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 diff --git a/tests/test_warm.py b/tests/test_warm.py new file mode 100644 index 0000000..3542b37 --- /dev/null +++ b/tests/test_warm.py @@ -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"