Skip to content

perf(tui): full-screen MaxWidth pass makes View() 51% of CPU — laggy switching with ~55 sessions #1753

Description

@asheshgoplani

Symptom

On a deck with ~55 live sessions the TUI sits at a high steady-state CPU share and
session switching (j/k) feels laggy — each keystroke visibly trails the input.

Sandboxed repro

Fully isolated: throwaway HOME, its own profile, its own tmux socket
([tmux] socket_name), TUI running inside that isolated tmux server. 60 synthetic
sessions created with --cmd "sleep 100000", terminal 220x50.

export HOME=$(mktemp -d); unset XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME TMUX TMUX_PANE
export TMUX_TMPDIR=$HOME/tmuxtmp AGENTDECK_PROFILE=perftest
printf '[tmux]\nsocket_name = "adperf"\n\n[logs]\npprof_enabled = true\n' > $HOME/.agent-deck/config.toml
for i in $(seq 1 60); do agent-deck add $HOME/work -t "perf$i" -cmd "sleep 100000" -g perf; done
for i in $(seq 1 60); do agent-deck -p perftest session start "perf$i"; done
# then run the TUI inside `tmux -L adperf` with AGENTDECK_DEBUG=1

Workload: 240 single-key j/k navigation events, one send-keys per key so no
paste coalescing hides render cost. Metric: the view_render perf log line, which
already records every View() duration.

Profile

go tool pprof against the sandbox process ([logs] pprof_enabled, localhost:6060)
during the switching workload:

      flat  flat%          cum   cum%
         0     0%         650ms 53.72%  bubbletea.(*Program).Run
         0     0%         620ms 51.24%  ui.(*Home).View
         0     0%         450ms 37.19%  ui.(*Home).renderDualColumnLayout
         0     0%         320ms 26.45%  lipgloss.Style.Render
     260ms 21.49%         260ms 21.49%  runtime.memclrNoHeapPointers

View() is 51% of all CPU samples, and renderDualColumnLayout is 37%. Line level:

ROUTINE ==== ui.(*Home).renderDualColumnLayout
         .      100ms  14312:	leftContent := h.renderSessionList(...)
         .       30ms  14343:	leftPanel = ensureExactWidth(leftPanel, leftWidth)
         .      100ms  14347:	mainContent := lipgloss.JoinHorizontal(...)
         .      170ms  14354:	mainContent = lipgloss.NewStyle().MaxWidth(h.width).Render(mainContent)

Root cause

Line 14354 is a "safety net" that runs MaxWidth(h.width) over the entire
already-composed screen
on every single View() — i.e. on every keystroke and
every 2s tick. It re-parses and re-measures every line and every ANSI sequence of
the full frame, costing 170ms of the 450ms layout render (38%), ~14% of total
process CPU
— and in the normal case it truncates nothing:

  • splitPaneWidths() documents and guarantees
    left + paneSeparatorWidth + right == h.width.
  • ensureExactWidth() is applied to both panels immediately before the join, and
    makes every panel line exactly its pane width in display cells.
  • the separator is exactly paneSeparatorWidth display cells wide.

So every joined line is already exactly h.width cells and the pass is a pure
no-op. It can only matter in the degenerate branch of splitPaneWidths(), where a
pane width can come back non-positive and ensureExactWidth() returns its input
untouched.

Secondary, same function: the separator column re-renders the identical constant
" │ " through lipgloss once per screen row (contentHeight times) per frame,
when one render can be reused for every row.

Baseline over 3 runs of the identical workload (n≈748 renders each):

run mean median p90
1 2.672 ms 2.197 ms 4.416 ms
2 2.542 ms 2.201 ms 3.371 ms
3 2.575 ms 2.083 ms 3.197 ms

Proposed fix

Keep the safety net, but only pay for it when it can actually do something: compute
the guaranteed joined width from the component widths and skip the full-screen
MaxWidth pass when it cannot overflow h.width. Hoist the constant separator
render out of the per-row loop. Both are O(1) changes in renderDualColumnLayout
with no behavioural difference on any non-degenerate terminal width.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions