Skip to content

Plot grid first render blocks the shared IOLoop, superlinearly in cell count #1198

Description

@SimonHeybrock

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions