Skip to content

perf: skip the log rewrite when the render would be identical - #1

Merged
bsorescu merged 2 commits into
bsorescu:mainfrom
dngr2:perf/skip-identical-render
Aug 16, 2026
Merged

perf: skip the log rewrite when the render would be identical#1
bsorescu merged 2 commits into
bsorescu:mainfrom
dngr2:perf/skip-identical-render

Conversation

@dngr2

@dngr2 dngr2 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Hi — I came across herdr-mobile, ran it on Debian, and went looking for something useful to contribute. This is the first of two performance findings.

The problem

refresh_output() clears and rewrites every row on every poll, including when the agent is idle and the output is byte-identical. _last_read was already being stored, but never compared.

Measured on a 200-line ANSI pane (rules, wide gaps, truecolor SGR):

median worst
refresh_output(), identical content 20.35 ms 58.22 ms

That runs every READ_POLL_SECONDS, per open screen. On the phone-over-SSH setup this app is for, it is also a full 200-row repaint pushed down the link for no visible change.

After: 0.24 ms median — a 98.8% reduction on idle polls.

I checked the pipeline first

The text transforms looked like the obvious target and are not: 1.98 ms for the same pane, across 430 strip_ansi calls. The rewrite was ~90% of the cost, so that is what this changes. Deduplicating strip_ansi would have been effort spent on a non-problem.

Why the key is (content, width)

This is the part worth reviewing. collapse_wide_rules fits output to the RichLog's current width, and there is no on_resize handler — a rotate is picked up because the next poll re-renders.

So a plain if content == last: return would silently break rotation: the rules would stay cut for the old width until the agent happened to print something. Intermittent and hard to trace back.

tests/test_render_skip.py covers that case specifically. I mutation-tested all three:

Mutation Caught by
Drop width from the key width test fails
Never skip both skip tests fail
Always skip 6 fail, incl. 4 of your existing tests

The width read happens before clear(), consistently, so the comparison is like-for-like poll to poll. Status can still flip while the text stands still, so _sync_remote_bar_buttons() is still called on the skip path — it is the redraw that is skipped, not the state.

Tests

177 pass (your 174, unmodified, plus 3). Verified on Debian 12, Python 3.14.7, via scripts/test.sh.

A second PR follows for the larger finding: the polling CLI calls run on the event loop, and a 300 ms call stalls the UI for 310 ms. Happy to adjust or drop either if you would rather they were done differently.

An idle agent returns byte-identical output on every poll, but
refresh_output() cleared and rewrote all 200 rows anyway. Measured on a
200-line ANSI pane: 20.35ms median, 58ms worst, every READ_POLL_SECONDS,
per open screen — and on the phone-over-SSH setup this app targets, a
full repaint pushed down the link for no visible change.

_last_read was already stored but never compared. Comparing it drops an
idle poll to 0.24ms.

The key is (content, width), not content alone. collapse_wide_rules fits
the output to the RichLog's width, and there is no on_resize handler —
a rotate is picked up precisely because the next poll re-renders. Keying
on content alone would leave a rotated phone showing rules cut for the
old width until the agent happened to print something. tests/
test_render_skip.py covers that case specifically and fails if the key
is weakened.

The text pipeline was measured first and is not the bottleneck: 1.98ms
for the same pane, 430 strip_ansi calls. The rewrite was ~90% of the
cost, so that is what this changes.

@bsorescu bsorescu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is careful work, and the (content, width) reasoning is the part I'd have gotten wrong; I confirmed the rotation test is the one that fails if the key is weakened. One fix before I merge: showing the remote bar changes the RichLog's height, not its width, so the key matches and we skip — but scroll_end() only lives on the render path. On main the next poll re-pinned to the bottom; with the skip the log stays ~2 rows off, and since the bar auto-shows exactly when an agent goes blocked (and a blocked agent's output doesn't change), the dialog's last rows sit below the viewport indefinitely. Adding log.scroll_end(animate=False, immediate=True) on the skip path in both screens fixes it with the suite green; while you're in there, dropping the _sync_remote_bar_buttons() call from the skip path currently leaves all 177 tests passing, so that case deserves a test too.

Showing the remote bar changes the RichLog's height, not its width, so the
(content, width) key still matches and the render is skipped — but scroll_end()
only ran on the render path. The bar takes two rows from the viewport, which
raises max_scroll_y without moving scroll_y, leaving the log two rows off the
bottom (49/49 before, 49/51 after).

It stays there: the bar auto-shows exactly when an agent goes blocked, and a
blocked agent's output does not change, so no later poll rewrites the log
either. The last rows of a dialog sit under the bar indefinitely.

Re-pin on the skip path in both screens.

Two tests, both of which fail without their fix. The re-pin test opens w3:p1
rather than wA:p1: wA:p1 is "blocked" in the fixture, so the bar is already up
and the height never changes, and the test passes whether or not the log is
re-pinned.
@dngr2

dngr2 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Fixed — you were right, and the re-pin was the smaller half of it.

Reproduced the two rows exactly: scroll_y=49 max=49 before the bar, scroll_y=49 max=51 after, so is_vertical_scroll_end goes false and only the render path would have corrected it. Both screens now call scroll_end(animate=False, immediate=True) on the skip path.

On the missing test for _sync_remote_bar_buttons(): added, and it does fail when the call is dropped.

The scroll test needed one thing worth flagging, because my first version of it was exactly the problem you were pointing at. Opening wA:p1 proves nothing here — it is "blocked" in the fixture, so the bar is already up and the height never changes, and the test passed with and without the fix. It opens w3:p1 ("working") instead, so the bar genuinely goes from hidden to shown. Both new tests now fail against the unpatched code; I checked by reverting each fix separately.

179 passing.

@bsorescu
bsorescu merged commit 7c8913c into bsorescu:main Aug 16, 2026
@bsorescu

Copy link
Copy Markdown
Owner

Merged. Before merging I re-ran your claims: reverting each fix separately fails exactly its own test (re-pin, button-sync, and the content-only key weakening). The w3:p1-over-wA:p1 reasoning on the scroll test was exactly right. Thanks for the careful work — and Linux is now marked confirmed in the README, with credit.

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.

2 participants