Skip to content

Every render request walks the whole renderable tree: one spinner costs ~23% of a core on an idle app #1339

Closed

Description

@jclaveau

A single animated element makes an otherwise idle app cost ~23% of a core, because every render request walks the entire renderable tree — re-querying yoga layout and visibility for every node, whether or not anything changed.

Measured with opencode's TUI, but nothing in the hot path belongs to opencode.

Profile

bun --cpu-prof --cpu-prof-md, 330s of an idle session with one <spinner> turning at 80ms, 10429 samples at 1ms:

Self% Self Function Location
14.7% 48.69s updateLayout src/Renderable.ts:1440 — Total 100%
6.5% 21.49s render native
5.9% 19.53s get visible src/Renderable.ts:338
4.1% 13.81s tick opentui-spinner scheduler
3.4% 11.46s bufferDrawTextBufferView native
2.9% 9.64s updateTextFromNodes src/renderables/Text.ts:86
2.2% 7.41s yogaNodeGetComputedLayout native
2.2% 7.32s updateLayout src/Renderable.ts:1397
2.0% 6.85s updateFromLayout src/Renderable.ts:1094
1.3% 4.45s renderSelf src/renderables/Box.ts:269

updateLayout (Renderable.ts:1440) is entered from render 642 times and recurses into itself 3942 times, calling get visible (131 direct) and yogaNodeGetComputedLayout per node.

Grouped: layout + visibility ≈ 28% of all samples, drawing ≈ 12%, text re-derivation ≈ 6%, the spinner scheduler ≈ 5%.

Why this looks like a missing dirty check

The spinner itself is well behaved — its tick advances an index and calls this.requestRender() on itself, no tree mutation:

this._currentFrameIndex = (this._currentFrameIndex + 1) % this._frames.length
this.requestRender()

Nothing about that changes geometry, yet the whole tree is re-laid-out. yogaNodeIsDirty already appears in the profile (0.9% in an earlier run), so the information needed to skip an unchanged subtree exists — it just doesn't stop the recursion.

What it is not

Two hypotheses I tested against the same app and both are wrong:

variant idle CPU
baseline (deep tree, 80x24) 23.1%, 23.1%
same app, ~8x fewer children mounted 20.9%, 21.4%
same app, viewport 40x10 instead of 80x24 22.7%, 21.6%

So it tracks neither the number of mounted children nor the number of visible cells. ScrollBox's viewportCulling (default true) filters what is painted via _getVisibleChildren(), but every child still participates in the layout walk.

Suggestion

Prune the updateLayout recursion when a subtree's yoga node is not dirty and its computed position/size is unchanged. An animation that changes one glyph inside a fixed-size box should cost one cell of redraw, not an O(tree) walk.

Separately, opentui-spinner's scheduler accounts for ~5% of all samples (tick self 4.1% plus _advanceFrame) purely to advance a frame index — worth a look on its own.

Versions: @opentui/core 0.4.5, opentui-spinner 0.0.7, bun 1.3.13, Linux.

Happy to share the raw .cpuprofile (8.4MB) or re-run with different flags.

opencode-idle-spinner.cpuprofile.gz
opencode-idle-spinner-profile.md.gz

Activity

  1. dajiaohuang commented on Aug 18, 2026

    @dajiaohuang
    Contributor

    The profile points to a real hot path, but there are two materially different optimization boundaries in the current renderer:

    1. prune a subtree only when its Yoga node is clean and its computed bounds are unchanged, or
    2. introduce separate paint-dirty/damage state so visual-only updates can bypass layout while still visiting the affected renderables.

    Yoga dirtiness alone is not a safe paint predicate: text/style/cursor/animation state can change without a layout change. Could a maintainer indicate which direction fits the renderer's intended invalidation model, and what benchmark/invariant should gate the change (for example, no output change across the existing render snapshots plus a target reduction for the 10k-node spinner case)? That decision determines whether this is a focused traversal optimization or a broader invalidation redesign.

  2. simonklee commented on Aug 25, 2026

    @simonklee
    Member

    @jclaveau Thanks for the issue on this. @kommander is looking into this atm and it has been on our backlog for some time. It's one perf issue we really want to land.

  3. simonklee commented on Nov 10, 2026

    @simonklee
    Member

    Thanks for the detailed report. Since the render tree moved to native (#1479, #1783, #1951), a spinner tick no longer walks the tree or repaints unchanged rows. If you still see high idle CPU on a current release, please reopen with numbers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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