Architecture¶
flowchart TB
subgraph train ["Training process"]
direction LR
add("run.add_scalar(dict)") -- append --> dq[("deque")]
dq --> writer("<b>Writer thread</b><br/><small>encodes frames, updates the summary,<br/>heartbeat every 10 s</small>")
end
subgraph folder ["Run folder"]
direction TB
runjson("<b>run.json</b><br/><small>rewritten atomically</small>")
events("<b>events.r0.s0.log</b><br/><small>N S S T S H S … E</small>")
media("<b>media.r0.pack</b><br/><small>images, video, figures</small>")
parquet("<b>data.parquet</b><br/><small>written at finish</small>")
files("<b>files/</b><br/><small>annotations.json</small>")
runjson ~~~ parquet
events ~~~ files
end
subgraph viewer ["Viewer process"]
direction LR
catalog("<b>Catalog</b><br/><small>SQLite cache in ~/.cache,<br/>rebuilt freely</small>")
reader("<b>RunReader</b><br/><small>NumPy, incremental, Parquet</small>")
server("<b>Starlette</b><br/><small>JSON · Arrow · SSE<br/>static frontend</small>")
catalog --> server
reader --> server
end
spa("<b>Svelte 5 SPA</b><br/><small>uPlot · Arrow JS · state in URL</small>")
train -- "append frames, rewrite run.json" --> folder
folder <-. "stat and read runs, write annotations" .-> viewer
viewer <-- HTTP --> spa
classDef code fill:#0072b21f,stroke:#0072b2,stroke-width:1.5px
classDef disk fill:#e69f0024,stroke:#e69f00,stroke-width:1.5px
classDef ui fill:#009e7324,stroke:#009e73,stroke-width:1.5px
classDef gone fill:transparent,stroke:#999999,stroke-width:1px,stroke-dasharray:4 3
class add,writer,catalog,reader,server code
class dq,runjson,events,media,parquet,files disk
class spa ui
style train fill:transparent,stroke:#8c8c8c,stroke-width:1px,stroke-dasharray:5 4
style folder fill:transparent,stroke:#8c8c8c,stroke-width:1px,stroke-dasharray:5 4
style viewer fill:transparent,stroke:#8c8c8c,stroke-width:1px,stroke-dasharray:5 4
Logging side (run.py, _writer.py, _format.py, _env.py, _media.py, artifacts.py)¶
Standard library only: a training script that logs never imports NumPy, PyArrow or the server.
add_scalar()resolves the step, then appends(kind, step, time, dict(data))to acollections.deque. That is the whole cost on the training thread: no locks, no conversion, no I/O.- A daemon writer thread wakes every second (or on
flush()), drains the deque, flattens nested dicts, converts values to float (this is where a tensor's.item()runs), assigns scalar ids, updates the running summary (last/min/max/count/non-finite) and writes oneSframe holding four columns for the whole batch, preceded by any newNframes. Columns are encoded witharray.array, byte-swapped on big-endian machines. - Every 10 s the thread writes a heartbeat frame and rewrites
run.json(summary + heartbeat) atomically. finish()(also viaatexit, the exception hook and context managers) drains, writes an end frame, fsyncs and marks the status. Compaction runs afterwards if PyArrow is importable.
Reading (reader.py, compact.py, series.py, alerts.py)¶
LogFilekeeps a byte offset per log file and decodes only the bytes appended since the last call. Frames are walked withstruct; scalar payloads become NumPy views vianp.frombuffer. Per-scalar arrays come from one stable argsort on scalar ids (a radix sort, since ids fit in 8 or 16 bits).RunReadermerges sessions (later session wins) and prefersdata.parquetwhen its recorded log sizes match the current ones.compact.pywrites zoom levels (8× downsampling per level, mean plus min/max envelope) with one row group per (scalar, level).series.pyholds the NumPy math: equal-count bucketing, x-axis transforms (step, wall time, relative, active time), and group aggregation (interpolate each run onto a shared grid of about one or two points per pixel, then mean/std/min/max/median per grid point, ignoring NaN).alerts.pyruns the health checks on at most 2,000 buckets per scalar: first non-finite value, spikes against a trailing median and MAD, and sustained drops below a fraction of the median.compact_runstores the result inrun.json; the server computes it live for running runs and caches it by log size.
Viewer server (catalog.py, query.py, collection.py, server/)¶
Catalogwalks the folder for*/runs/*/run.json(string-basedos.scandir, no pathlib in the hot loop), statsrun.jsonand the run folder (annotations are replaced atomically, which bumps the folder's mtime), and re-reads only what changed. Records are cached in SQLite on local disk so a restart does not re-read thousands ofrun.jsonfiles.collection.pyreads and writes<project>/collections/*.json; the catalog attaches each run's collections to its record on every list, with the membership cached on the files' mtimes.query.pyis a small recursive-descent parser for the query language; filtering and ordering run in Python over catalog records.server/data.pykeeps an LRU ofRunReaders, picks a Parquet zoom level from the requested point count and visible range, and returns chart data as an Arrow IPC stream (series, x, y, lo, hicolumns plus series metadata in the schema).timings.pyturns a run's timer scalars into the Timing tab: per timer the calls, mean, p95, total, share and a per-step series (mean with min–max across ranks), nesting inferred from which spans fall inside which, the time ranks waited for each other, findings, and the spans of one step for the timeline.server/data.pycaches the summary per run until its logs grow (/api/runs/{key}/timing?rank=and/timing/spans?step=).server/app.pyhas the routes, gzip, a localhost-only host check, and one background task that polls for changes every 1.5 s and fans them out to all open browser tabs over Server-Sent Events (runswhen metadata changed,datawhen a live run's logs grew).
Frontend (frontend/)¶
Svelte 5 with runes and strict TypeScript, built by Vite into
src/nuboard/static/ so the wheel ships it.
lib/router.svelte.ts: all view state in the URL hash.lib/state.svelte.ts: info, run list, collections and selection.lib/sweep.ts: axes, scales and parameter importance for Sweeps.lib/api.ts: fetch helpers; Arrow decoding withapache-arrow.components/LineChart.svelte: uPlot with joined x arrays, bands, smoothing, robust y range, NaN markers, drag-to-zoom and cursor sync.pages/: Runs, Charts, Media, Sweeps, Timeline, Artifacts. The new pages draw plain SVG; no chart library beyond uPlot is needed. Styling uses Tailwind v4 with the design tokens of the Studio mockup (Okabe–Ito based, light and dark).
Performance¶
Measured with benchmarks/bench.py in the (slow, shared) container this
version was built in; a laptop is typically 2–3× faster.
| Target | Measured |
|---|---|
add_scalar() of a 50-scalar dict well under 50 µs |
~2.5 µs |
| 1M scalar points: under 30 MB live | 28 MB of event log |
| 1M points: under 0.1 s to read | 0.12–0.15 s (decode 0.04 s, per-scalar split the rest) |
| one scalar from Parquet under 10 ms | ~7 ms warm, ~60 ms for the first read after opening |
| run list of 5,000 runs under 200 ms | ~65–100 ms per refresh; ~0.35 s to open the cached catalog at server start |
The background writer handles roughly 600–700k points per second in this container, far above typical logging rates; if a loop logs faster than that for a sustained period, the queue grows in memory.
Deliberate choices¶
- No DuckDB, no database server. NumPy and PyArrow do the math; SQLite is only a local cache.
- No locks on the write path. One writer per (rank, session) file; readers tolerate torn frames.
- No SQLite on network filesystems. The catalog lives in the user's cache folder.
- Name in one place.
pyproject.toml, thesrc/nuboard/folder andfrontend/src/lib/brand.ts; everything else derives fromnuboard._meta.NAME. See Development.