Skip to content

feat(vt): add SafeEmulator.View/Update for batched locked access#887

Open
LXXero wants to merge 1 commit into
charmbracelet:mainfrom
LXXero:vt-view-batched-reads
Open

feat(vt): add SafeEmulator.View/Update for batched locked access#887
LXXero wants to merge 1 commit into
charmbracelet:mainfrom
LXXero:vt-view-batched-reads

Conversation

@LXXero

@LXXero LXXero commented Jun 6, 2026

Copy link
Copy Markdown

Problem

SafeEmulator.CellAt takes an RLock (plus its deferred unlock) on every call. Any consumer that walks the cell grid per frame — i.e., every renderer built on vt — pays this per cell: an 80×24 grid is ~2k lock acquisitions per pass, and real applications run multiple passes per frame on much larger grids.

Profiling xerotty (a terminal emulator built on vt) under continuous redraw with Linux perf: after eliminating our own per-cell costs, SafeEmulator.CellAt + its defer wrapper remained the top CPU entry at ~16% of total process time (~15k lock+defer pairs per frame on a 240×65 grid at 20fps ≈ 300k lock ops/sec, all to read cells that cannot change mid-frame).

Change

bbolt-style scoped accessors, purely additive:

func (se *SafeEmulator) View(fn func(*Emulator))   // batched reads under one RLock
func (se *SafeEmulator) Update(fn func(*Emulator)) // batched writes under one Lock

Renderers wrap each frame's grid walk in one View; bulk mutations (e.g. replaying a snapshot via SetCell) batch under one Update. Existing per-call methods unchanged.

Benchmark

An 80×24 grid walk, per-cell CellAt vs the same reads under one View:

BenchmarkCellAtGridWalk-16     162933    14081 ns/op
BenchmarkViewGridWalk-16      1885207     1255 ns/op

11× — and the gap widens with grid size (lock cost is per-cell, savings are per-batch).

Testing

  • TestSafeEmulatorViewUpdate — basic read/write through the accessors
  • TestSafeEmulatorViewConcurrent — grid-walking Views against concurrent Write pressure, -race clean
  • Benchmarks above included

Related: #879 (same SafeEmulator synchronization layer).

SafeEmulator.CellAt takes an RLock (plus its defer) on every call.
A renderer walking the cell grid each frame pays that per cell —
tens of thousands of lock acquisitions per frame on a large grid.
Profiling a real terminal application (xerotty) under continuous
redraw showed SafeEmulator.CellAt and its defer wrapper as the top
CPU entry (~16% of total) once other per-cell costs were removed.

View(fn) / Update(fn) expose the underlying Emulator under a single
read/write lock acquisition, bbolt-style, so bulk reads (render
passes, snapshots) and bulk writes (cell-by-cell replay) batch into
one lock each:

    BenchmarkCellAtGridWalk-16    162933    14081 ns/op
    BenchmarkViewGridWalk-16     1885207     1255 ns/op

(80x24 grid walk, 11x. The gap widens with grid size.)

Includes -race tests for both, with concurrent Write pressure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant