Skip to content

perf: streaming highlight + perceived-latency fixes for large diffs (rebase of #288) - #394

Open
r-vdp wants to merge 4 commits into
agavra:mainfrom
r-vdp:rvdp/pr288-rebased
Open

perf: streaming highlight + perceived-latency fixes for large diffs (rebase of #288)#394
r-vdp wants to merge 4 commits into
agavra:mainfrom
r-vdp:rvdp/pr288-rebased

Conversation

@r-vdp

@r-vdp r-vdp commented May 30, 2026

Copy link
Copy Markdown
Contributor

This is #288 by @ptoussai rebased onto current main, since the original branch is still based on pre-v0.13.0 and no longer applies. Authorship is preserved on all four commits.

Supersedes #288.

ptoussai added 4 commits June 3, 2026 14:40
The parse phase now emits highlight jobs instead of highlighting inline.
A background worker drains them in parallel and streams results back to
the main thread via an mpsc channel; the main loop patches spans into
diff_files in place between frames.

Effect: the file list and diff structure appear after the parse cost
alone, instead of after parse + full synchronous highlight. Total work
is unchanged (slightly more, from thread + channel overhead) but the
visible-frame wait shrinks on big diffs.

Notes worth recording:

* git2 / hg / jj state is mostly !Send. Container-grammar full-file
  content is fetched on the main thread during parse and embedded into
  jobs as owned Strings; the worker never touches VCS state.
* Workers use thread::scope + an atomic-cursor pulled from a shared
  Vec<HighlightJob>. Beats static chunking when one giant file sits
  alongside many small ones.
* Per-hunk jobs and full-file (Vue/Svelte/...) jobs share a single
  HighlightJob enum and a single dispatch path.
* HighlightSession bundles the receiver and a cancel AtomicBool. On
  diff swap (commit reselection, :e reload), the old session is
  dropped and its cancel flag set; the worker exits at its next
  channel send or cancel check.
* Adaptive event-poll cadence: 30ms while a session is live so
  streamed spans land within a frame, 100ms idle to keep wakeups low.

Tests use a synchronous streaming::run_blocking helper so spans are
present deterministically; production code path is the worker.
Pending jobs now live in an Arc<Mutex<VecDeque<HighlightJob>>> instead
of an Arc<Vec> with an atomic cursor. The worker still pops jobs in
queue order, but the App can reorder the remaining queue by file-index
distance whenever the user moves between files.

Why: when the user clicks the last file in a 565-file commit, the
worker is still grinding through files near index 0 that aren't on
screen. The visible diff sits unhighlighted for several seconds while
the worker walks the queue. Sorting pending jobs by abs_diff from the
current file_idx puts visible work first.

Called from:
* jump_to_file (file list click, } / { navigation)
* update_current_file_from_cursor (cursor / scroll moved into a new file)

The sort is O(n log n) on the remaining queue, run under a mutex held
only long enough to reorder. Workers contend on the same mutex per
pop, but the lock is held for microseconds and navigation events are
human-paced, so contention is negligible in practice.
The main loop used to read one event and redraw, read one event and
redraw. On a big diff where a single render takes ~10 ms, holding j
or wheel-scrolling fast queues events faster than the loop can consume
them, and the backlog shows up as 200-600 ms of input lag - the worst
case being a direction reversal, where the user has to wait for the
Up backlog to drain before the first Down is even read.

Now after the blocking event::poll(poll_timeout), the loop keeps
pulling events with event::poll(Duration::ZERO) until the queue is
empty (or a 64-event cap is hit, in case of a paste or stuck key),
then redraws once. The cap is a safety; in practice almost all bursts
drain in well under 64 events.

The existing 'continue' shortcuts inside the match (Ctrl+C, ZQ, dd,
;X, pending state setters) now use 'continue 'main' so they short-
circuit straight to the outer loop's render path. Those keys aren't
the high-throughput case batching is meant to help with, and keeping
them per-event means each one renders before the next is read - which
is what the user expects for atomic-action keys.
When the user wheel-scrolls the file list while the diff panel has
focus, the render-time 'sync selection to diff's current file' path
selects the diff's file, and ratatui's List widget then auto-adjusts
the offset on render to keep that selection visible - undoing the
wheel. Net effect: the file list stays glued to whichever file the
diff cursor is on, even when the user is trying to scroll past it.

Track an 'I have manually scrolled the file list' flag, set by
file_list_viewport_scroll_up/down. While the flag is set, skip the
render-time sync entirely, so the user's offset stays put. The flag
is cleared whenever the diff's current_file_idx changes for a reason
other than file-list wheel (jump_to_file, cursor scrolling into a
different file via update_current_file_from_cursor), which is the
natural moment to resume tracking.

Pre-existing bug, surfaced now that the rest of the loop is fast
enough that wheel-scrolling the file list is actually responsive
enough to expose the snap-back.
@r-vdp
r-vdp force-pushed the rvdp/pr288-rebased branch from 280aa4a to b31cccc Compare June 3, 2026 11:59

@agavra agavra 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 for rebasing this @r-vdp - I'm curious whether this was something you needed in your local testing. I'm not eager to make the diff rendering a lot more complicated unless people are regularly viewing massive diffs. There are probably a lot more low-hanging fruits to do first before we implement streaming highlighting.

@r-vdp

r-vdp commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Yeah, I hit this when I was working on a remote box over ssh on a slow connection, going through diffs was painfully slow.

I have some other, smaller diff, perf improvements sitting locally, I can re-order my patches and submit those instead if you're not eager on merging this one.

@agavra

agavra commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Yeah let's start with those, I also bet that for slow connections the best thing we can do is auto-collapse some files similarly to how github does it for large diffs since it's mostly about transferring bytes rather than actual loading times (maybe? i'm not actually sure how SSH terminals work in detail).

I know there's a lot of hype going on recently about Pierre's diffs being blazing fast, but I think that's more novelity than use and I imagine it can create a lot of bugs if we start doing things async.

@r-vdp

r-vdp commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

The main issue that I encountered was the frame-by-frame rendering being super slow. Every up or down scroll movement would take multiple seconds to rerender the viewport.

I'll review my patch stack and see what else is upstreamable.

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.

3 participants