A plot grid's first render blocks the dashboard's shared Tornado IOLoop, and the block grows faster than the number of cells in the grid. At 24 cells -- a legal grid, the size widget allows 2-6 rows by 2-6 columns -- one session switching to the grid tab makes every other session on that server unresponsive for 11 seconds.
This is the residual of #1185 after PR 1197 fixed the colormap cold start that dominated it. It was previously assumed to be a first-paint latency for the rendering user only; measuring from a second, idle session shows it is server-side and shared.
Measurements
Fake backend, dummy fixture, Detectors grid expanded to N 1x1 cells, idle 24-core machine. Two browser sessions: one renders the grid, the other sits idle on the Workflows tab and probes. The idle session's fetch latency is a server-side measurement -- its own main thread is idle, which its setInterval lateness (<= 8 ms throughout) confirms.
| cells |
models in doc |
server block (seen by the idle session) |
client block (renderer's own main thread) |
| 3 |
267 |
0.7 s |
1.9 s |
| 6 |
489 |
0.9 s |
4.3 s |
| 12 |
937 |
2.8 s |
13.9 s |
| 24 |
~1900 |
11.1 s |
17.6 s |
The client column is the browser-side cost of instantiating the model tree. It is far larger, but it is a per-user cost and belongs to #1055, whose "switch into plot tab" target reports 1.7-1.9 s of blocked main thread with 4 plots -- consistent with the 3-cell row here. This issue is about the server column.
Server-side accounting matches the observed block. At 12 cells:
[passprof] tab_switch 444ms (wall) models 84->325
[passprof] pass full=True handlers=4 models=325 inner=1632.9/1508.0ms freeze_exit=59.1/55.9ms hold_exit=615.7/537.2ms (wall/cpu)
444 ms + 2.31 s = 2.75 s against 2.78 s measured from the idle session. inner is entirely PlotGridTabs._poll_for_plot_updates; hold_exit is event dispatch and serialization; freeze_exit is bokeh's model-graph recompute.
Steady state scales too. One session on a 12-cell grid costs ~200 ms of handler plus ~40-60 ms of freeze-exit per 1 Hz data tick -- about 25% of a core, per session, per grid. The unconditional 5 s full pass costs 40-68 ms of freeze-exit at 937 models with inner=0.2 ms, i.e. bokeh recomputing the model graph for a pass that changed nothing.
What a cell is made of
74 models per cell, from diffing the document's settled model census at 3 cells against 6 cells (222 models for 3 extra cells). Grouped by what owns them:
| group |
models/cell |
what |
| bokeh plot internals |
~37 |
figure, two axes (each with ticker, formatter, label policy, scale, range, grid), title, glyph renderers, ColumnDataSource + view + selection objects, colorbar + mapper, legend, box-select annotation |
| bokeh toolbar |
~18 |
pan, wheel zoom, box zoom, reset, save, hover, a CustomAction, the ToolProxy objects GridPlot's toolbar merging adds on top, and a Tooltip/TooltipIcon per tool |
| our Panel chrome |
~19 |
6 HTML panes, 3 SVGIcon, ~2 Button, ~6 Row/Column, Spacer, Dropdown -- the cell titlebar |
Half the models in a cell are not the plot. A quarter is a full interactive toolbar on a tile that can be a couple of hundred pixels wide; a quarter is the titlebar we build ourselves. Both are app-side choices.
One concrete piece of waste inside the plot group: every GlyphRenderer instantiates four glyph objects, the real one plus copies a live monitoring view never displays.
[census] glyph_variants {'glyph': 'Image', 'selection_glyph': 'Image',
'nonselection_glyph': 'Image', 'muted_glyph': 'Image',
'hover_glyph': 'NoneType'}
That is 16 of the ~110 plot-internal models per 3 cells. It is a bokeh default, so the question is whether holoviews can be told not to populate them.
Caveats: the synthetic grid cycles the fixture's three cell templates (two single-layer image cells, one two-layer lines cell), so 74 is an average over a slightly heavier mix than a pure image grid; and because the census covers the whole document, models are grouped by type rather than attributed to an individual widget. ToolProxy at ~3/cell means the GridPlot toolbar merging adds models rather than replacing the per-figure tools -- worth confirming before treating merging as a saving.
Open question
What makes it superlinear. Per-cell work inside the pass looks roughly linear from 3 to 12 cells (tab_switch 130/245/490 ms; inner 377/703/1633 ms), yet the measured block goes 0.9 -> 2.8 -> 11.1 s from 6 to 24. Either something in the pass is O(cells^2) only at larger sizes, or the block at 24 cells spans several passes. Instrumenting a 24-cell run is the first step -- it was not captured here because the tour driver's fixed wait window is too short at that size.
Directions
- Chunk cell materialization across ticks so no single pass holds the loop for seconds. Does not reduce total work, but bounds the block, which is the point.
- Skip the freeze-exit recompute on full passes where no handler mutated anything. Small in absolute terms (<1% duty), but it is pure waste and grows with document size.
- Reduce models per cell (74 at present, see the breakdown above): drop or share the per-figure toolbar, slim the titlebar, suppress unused glyph variants. Pays off on both the server and, much more, the client.
Reproducing
Harness and usage notes: https://gist.github.com/SimonHeybrock/e99259966da78a49a8225cd0a470ff9d
export PLAYWRIGHT_CHROMIUM_EXECUTABLE=/usr/bin/chromium # devcontainer only
python grid_stall.py --mode observer --cells 24 # server vs client split
python grid_stall.py --mode tour --cells 12 # per-step + server instrumentation
ESSLD_CENSUS=1 python grid_stall.py --mode tour --cells 6 # model census; diff two cell counts
Run from the repo root on an otherwise idle machine: under CPU oversubscription the loop's scheduling delay dominates and swamps the effect.
Two measurement traps, both hit during this investigation:
- A
fetch probe in the rendering page cannot separate a blocked server loop from a blocked browser main thread -- both delay the round trip. Probe from a second, idle session.
- Tornado's
request_time under-reports a blocked loop: a loop that cannot read the socket has not started the clock. It showed 349 ms while the loop was in fact blocked for 2.8 s.
A plot grid's first render blocks the dashboard's shared Tornado IOLoop, and the block grows faster than the number of cells in the grid. At 24 cells -- a legal grid, the size widget allows 2-6 rows by 2-6 columns -- one session switching to the grid tab makes every other session on that server unresponsive for 11 seconds.
This is the residual of #1185 after PR 1197 fixed the colormap cold start that dominated it. It was previously assumed to be a first-paint latency for the rendering user only; measuring from a second, idle session shows it is server-side and shared.
Measurements
Fake backend, dummy fixture, Detectors grid expanded to N 1x1 cells, idle 24-core machine. Two browser sessions: one renders the grid, the other sits idle on the Workflows tab and probes. The idle session's
fetchlatency is a server-side measurement -- its own main thread is idle, which itssetIntervallateness (<= 8 ms throughout) confirms.The client column is the browser-side cost of instantiating the model tree. It is far larger, but it is a per-user cost and belongs to #1055, whose "switch into plot tab" target reports 1.7-1.9 s of blocked main thread with 4 plots -- consistent with the 3-cell row here. This issue is about the server column.
Server-side accounting matches the observed block. At 12 cells:
444 ms + 2.31 s = 2.75 s against 2.78 s measured from the idle session.
inneris entirelyPlotGridTabs._poll_for_plot_updates;hold_exitis event dispatch and serialization;freeze_exitis bokeh's model-graph recompute.Steady state scales too. One session on a 12-cell grid costs ~200 ms of handler plus ~40-60 ms of freeze-exit per 1 Hz data tick -- about 25% of a core, per session, per grid. The unconditional 5 s full pass costs 40-68 ms of freeze-exit at 937 models with
inner=0.2 ms, i.e. bokeh recomputing the model graph for a pass that changed nothing.What a cell is made of
74 models per cell, from diffing the document's settled model census at 3 cells against 6 cells (222 models for 3 extra cells). Grouped by what owns them:
ColumnDataSource+ view + selection objects, colorbar + mapper, legend, box-select annotationCustomAction, theToolProxyobjects GridPlot's toolbar merging adds on top, and aTooltip/TooltipIconper toolHTMLpanes, 3SVGIcon, ~2Button, ~6Row/Column,Spacer,Dropdown-- the cell titlebarHalf the models in a cell are not the plot. A quarter is a full interactive toolbar on a tile that can be a couple of hundred pixels wide; a quarter is the titlebar we build ourselves. Both are app-side choices.
One concrete piece of waste inside the plot group: every
GlyphRendererinstantiates four glyph objects, the real one plus copies a live monitoring view never displays.That is 16 of the ~110 plot-internal models per 3 cells. It is a bokeh default, so the question is whether holoviews can be told not to populate them.
Caveats: the synthetic grid cycles the fixture's three cell templates (two single-layer image cells, one two-layer lines cell), so 74 is an average over a slightly heavier mix than a pure image grid; and because the census covers the whole document, models are grouped by type rather than attributed to an individual widget.
ToolProxyat ~3/cell means the GridPlot toolbar merging adds models rather than replacing the per-figure tools -- worth confirming before treating merging as a saving.Open question
What makes it superlinear. Per-cell work inside the pass looks roughly linear from 3 to 12 cells (tab_switch 130/245/490 ms;
inner377/703/1633 ms), yet the measured block goes 0.9 -> 2.8 -> 11.1 s from 6 to 24. Either something in the pass is O(cells^2) only at larger sizes, or the block at 24 cells spans several passes. Instrumenting a 24-cell run is the first step -- it was not captured here because the tour driver's fixed wait window is too short at that size.Directions
Reproducing
Harness and usage notes: https://gist.github.com/SimonHeybrock/e99259966da78a49a8225cd0a470ff9d
Run from the repo root on an otherwise idle machine: under CPU oversubscription the loop's scheduling delay dominates and swamps the effect.
Two measurement traps, both hit during this investigation:
fetchprobe in the rendering page cannot separate a blocked server loop from a blocked browser main thread -- both delay the round trip. Probe from a second, idle session.request_timeunder-reports a blocked loop: a loop that cannot read the socket has not started the clock. It showed 349 ms while the loop was in fact blocked for 2.8 s.