Repository navigation
Vastly improve performance when dealing with long buffer lines - #22
Open
savetheclocktower wants to merge 5 commits into
Open
savetheclocktower wants to merge 5 commits into
savetheclocktower wants to merge 5 commits into
Conversation
When the first requested screen row is the continuation of a soft-wrapped buffer row, start building at the soft wrap instead of at the start of the buffer row. The soft-wrap hunk records the buffer position and indentation needed to resume, so there's no need to rebuild the earlier screen rows of a long buffer row. Resuming partway through a buffer row means working out whether we're still in its leading whitespace, which can span soft wraps. Previously a resumed screen row was always treated as past the leading whitespace. Also fix trailing-whitespace state leaking past a fold: whitespace that was trailing on the buffer row where a fold started says nothing about the buffer row where it ends. The randomized spec can now re-run a single seed via the SEED environment variable, and compares screen lines for a random range (with warm and cold caches) against a display layer that built every screen line from the top.
When soft wrap is on and an edit stays within one buffer row (no newlines inserted or removed, no folds or indent-guide complications), update the spatial index for just the part of the buffer row the edit can affect instead of re-wrapping the whole buffer row. The re-wrap starts on the screen row whose overflow check could read the edited text. That's usually the edit's own screen row or the one above, but the first screen row has no indentation and so may reach further, and hard tabs break the reasoning entirely, so either case is checked explicitly. Re-wrapping stops at the first new soft wrap after the edit that matches an old one shifted by the edit's length. Callers are still told the whole buffer row changed, and its cached screen lines are cleared, because language modes don't report every highlighting change within an edited buffer row. `BufferRowReader` reads a buffer row a chunk at a time, so working on part of a long buffer row doesn't require fetching all of it. This changes layout slightly: after inserting a soft wrap, the scanner now rewinds to the wrap's buffer column and rescans in the new screen row's context. Previously, characters already measured on the old screen row were carried over, so a hard tab carried onto a new screen row could be measured at the wrong tab stop. The fast path depends on this, since both paths must produce the same layout.
The screen line builder called `getCharacterAtPosition` for every character, which allocates a `Point` and crosses into superstring each time. Read through a `BufferRowReader` instead. On a 1M-character buffer row, rendering 50 soft- wrapped screen rows drops from about 6ms to 1.2ms, and ordinary short buffer rows get faster too. `expandHardTabs` and `collapseHardTabs` fetched the whole buffer row with `lineForRow` to scan one screen row of it. Read only as far as the scan goes. With soft wrap on, translating a position on a long buffer row with hard tabs no longer costs time proportional to the buffer row's length. `BufferRowReader` now continues from the end of its chunk when reading forward, rather than re-reading some text before the requested column.
When the language mode reports a highlighting change, clear the cached screen lines and report changes only for the screen rows that contain part of the range, rather than for every screen row of the buffer rows it touches. To keep the cached screen rows that follow an invalidated one in the same buffer row, the screen line builder now skips a cached screen row after a soft wrap, as it already did for cached buffer rows. Otherwise every later screen row would be rebuilt and get a new ID. The second argument to `HighlightIterator#seek` is now a buffer position (the end of the last screen row being built) instead of a buffer row number, so a language mode can limit its work to what's being rendered. This is a breaking change for language modes that use the argument.
…and do some code reformatting.
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR delivers a series of performance improvements that are meant to allow
text-bufferto do smaller increments of work at a time — by changing the unit of work from a buffer row to a screen row.This has a huge performance upside for Pulsar’s longtime nemesis: a large file with very long buffer lines and few (or zero) newline characters. There are further optimizations we could make, but the changes in this PR are enough to vastly improve the experience of interacting with such a file in Pulsar.
The fact that we’re now operating in increments of screen rows prompts a question: what about when soft-wrapping is disabled? Do we lose these optimizations altogether? No! Because soft-wrapping is never truly disabled — only minimized. By default, even when
editor.softWrapis false, Pulsar will force a soft wrap on a line at or before the 500-character mark for performance reasons. (The threshold is configurable, but this constraint cannot be disabled.)So these optimizations will make plenty of difference no matter which mode the user is in simply by no longer committing to a unit of work that is theoretically unbounded (the length of a single buffer line).
Each optimization is its own commit, so I’ll walk through them one at a time.
Commit 1: Stop the screen-line builder from doing throwaway work
The
ScreenLineBuilderinstance is responsible for taking buffer text and turning it into text as it's presented on screen. That includes soft-wrapping long lines, hiding folded text, and expanding hard tab characters into the appropriate number of spaces.As the name implies, screen rows are its currency; it is often asked to build/rebuild only certain screen rows rather than a whole buffer line. But on
master, it still does unnecessary work related to the screen rows that occurred on the same buffer row before the screen row(s) it's asked to render. It rebuilds each of those screen rows and caches them, which is polite… but too eager.ScreenLineBuilder’s screen-line cache is a poor choice for cache “warming” in this manner because of how often it gets invalidated. It makes sense to memoize work you’ve already done in case you can reuse it later, but it does not make sense to pre-fill the cache. Every keystroke ends up clearing some of that cache, so pre-filling will more often than not result in unused work.The performance boost delivered by this change is proof enough of that; skipping this eager caching is responsible for almost all of the gains. I had Claude run some benchmarks on a buffer with one row consisting of 1,000,000 characters:
15.1.2On
masterwe end up doing more and more work as the edits move farther and farther from the beginning of the line, but after this commit the render time is no longer dependent on how far into the row the edit is.Commit 2: Re-wrap only affected screen rows after an edit
We keep something called a spatial index in order to help us convert between buffer coordinates and screen coordinates. This is an important cache that prevents us from doing redundant work during these conversions.
That’s why it was bad for us that, when an edit touched a certain buffer row, we re-wrapped more text than we needed to and cleared the cache for the entire buffer row instead of just the part of the buffer row affected by the edit.
It is tricky to predict exactly what will rewrap once you make a given edit. It’s obvious how adding text can reflow the current screen row and possibly all lines below it on the same buffer row. But it’s also possible for an edit to reflow the row before the current screen row (if it’s a deletion that makes it possible for some of the current screen row’s content to fit on the row above) and even, in rarer scenarios, any arbitrary screen row above the current screen row (though only in scenarios involving hard tab characters).
So another commit in this PR reduces the amount of extra work we do by identifying which screen rows can stay the same and which must be re-wrapped. For screen rows after the edited row, it figures out where (if anywhere) screen rows converge — i.e., the point at which the edit stopped a cascade of re-wraps on subsequent screen lines. The first screen row that does not get re-wrapped after an edit is what tells us we can stop doing work, since all screen rows after that row must also have stayed the same.
This commit also introduces the
BufferRowReaderclass. Its purpose is to optimize the reading of thesuperstringbuffer in JS code. We don’t want to ask for more text than we need (makingsuperstringdo unnecessary work), but we also don’t want to waste time asking for text character-by-character and incurring the same overhead multiple times. SoBufferRowReaderdoes what it says on the tin: it asks for fixed-size chunks of a buffer row as needed, splitting the difference between the two approaches.As with the first commit, the performance gains in this commit mostly come from skipping throwaway work (in this case the unnecessary invalidation and repopulation of the spatial index). The actual improvement depends on where the edit falls in a long buffer row, but with our 1M-character test buffer, a middle-of-the-row edit improved performance from 130ms to 0.16ms (about 130x faster!).
Sadly, there is less of a performance benefit when hard tab characters are present above us in the buffer row, because they defeat our strategy to detect which screen rows will re-wrap in response to an edit. We still enjoy the performance benefit of skipping work on screen rows after the convergence point.
The challenge of hard tabs is as follows: suppose an edit happens on screen row 10 of a buffer row. Any hard tabs that appear on screen rows 2–10 mean that we can’t rule out the possibility of needing to re-wrap screen rows above the edit; so we are forced to re-wrap from the start of the buffer row as before (though, again, we are still able to stop before the end of the buffer row in most cases).
This is a conservative approach and does not rule out further optimizations in the future to better target the edge cases that defeat our strategy.
This commit changes layout behavior slightly, but in a way that is more consistent and predictable. The behavior change is incredibly obscure and only comes into play when the buffer row contains hard tab characters, but here’s a contrived example.
Contrived example
This example uses the following settings:
The text is
a aa→ aa, where→is a hard tab. The tab is followed by two spaces.Before (15.1.2):
After:
Claude’s description:
Commit 3: Apply
BufferRowReadermore broadlyThe
BufferRowReaderclass we introduced in commit 2 was worth applying in several other places. In particular it’s worth swapping in wherever the existing approach is to callgetCharacterAtPositiondirectly on the buffer in a loop.This is a small performance boost compared to other commits, but it’s dead-simple, and helped improve time to rewrap 50 screen rows (on our 1M-character test buffer) from 6ms to 1.2ms.
Commit 4: Re-highlight buffers in increments of screen rows, not buffer rows
In Pulsar, the editor’s language mode is in charge of telling the buffer when certain regions of the buffer need to re-apply syntax highlighting. Internally,
text-bufferremembered that invalidated range, but then expanded the range’s end to the nearest buffer-row boundary when driving re-highlighting: it took theHighlightIteratorgiven to it by the editor and called#seek(startingPoint, endingBufferRow). That was the signature ofHighlightIterator#seek, and it obligated bothtext-bufferand Pulsar to do more work than was strictly necessary.This commit fixes that. First, a reported highlighting change clears only the cached screen rows that touch the invalidated region, rather than all screen rows of buffer rows affected by the edit.
Next, the second argument to
HighlightIterator#seekis now a point rather than a row, and it represents the exact position at which highlighting can stop. This allows the editor (which is in charge of tellingtext-bufferabout all scope boundaries and positions) to minimize the amount of work it does to prepare that information.This is a breaking change, so it means bumping the major version to 16; it also means that we should not bump
text-bufferin Pulsar until we have updated allHighlightIteratorimplementations to understand the new signature. (We can do duck-typing on the second parameter and fall back to the original behavior if it’s an integer; this is responsible and keeps support for any, uh, “unofficial” consumers of highlight iterators. Retaining the first argument as aPoint, rather than consolidating the twoPointparameters into a singleRange, is also necessary because the second argument is optional and is ignored for TextMate-style grammars.)The performance improvement we get for this one is harder to quantify since half of the work has to be done on the Pulsar side. But in our 1M-character test buffer, an edit that affected only one screen row invalidated only one screen row instead of 2,041 — thereby reducing re-render time from 4.8ms to 0.3ms (~16x improvement!). That undercounts the performance benefit because it skips the cost of highlighting — which, for a Tree-sitter grammar, is a function of how much got invalidated at once (and therefore how much of the tree we must run a
highlights.scmquery against). The screencasts below do a better job of illustrating the scale of the improvement.Screencasts
Sounds great in the abstract — but what do all these performance boosts gain us in practical terms?
I pointed my Pulsar installation to this branch’s
text-bufferand wrote some quick code to adapt to the newHighlightIteratorAPI. Here are before/after screencasts for a nightmare scenario: a JSON dump that has zero newlines and is over 100K.First, the control from 1.133.1:
before-optimization.mp4
You can tell when the editor is freezing because the cursor stops blinking; that's also when I start twirling my mouse pointer. It happens on both attempts to delete text.
Now here’s the “after” screencast, though stay tuned for a caveat:
after-optimization.mp4
I was vexed by the freeze that happened in the middle; it happened when I pressed Opt+Delete, which is the macOS keybinding for
editor:delete-to-previous-word-boundary. I opened the profiler and tried to narrow it down.It turns out that’s because I’d had
atom-ide-hyperclickconfigured to show clickable symbols when I hold down Opt — so just pressing it on the way to pressing Opt+Delete triggered a buffer-widetags.scmquery. That’s another pitfall of an obscenely large buffer that we can tackle later; one strategy there might be to allowsymbol-provider-tree-sitterto subtract from its own “suitability” score (part of howsymbols-viewpicks a provider) when the buffer is large enough to produce this sort of freeze.Anyway, that’s my way of saying that the freeze in the middle is unrelated. Once I disabled
atom-ide-hyperclicktemporarily, it went away. You can see that the editing experience is tolerable and that syntax highlighting updates rather quickly. This is especially impressive because JSON is a near-worst-case scenario for syntax highlighting; any edit that turns the JSON invalid (in Tree-sitter’s view) ends up triggering a highlighting update for the entire buffer, since it likely changes the structure of most or all of the syntax tree.In fact, here's a third screencast in greater detail:
after-optimization-2.mp4
This is me deleting and restoring text at the end of a buffer row that is 1.3M+ characters long. Edits are near-instantaneous and just as fast as the edits in screencast 2 that happened much earlier in the buffer row. I widen the window in the middle of the screencast just to make the buffer coordinates visible in the status bar.
The improvement is honestly much, much better than I was expecting. I figured we'd have to disable syntax highlighting for huge files altogether, but these changes alone might be enough to make editing of long lines tolerable.
Tests
The implementations of these optimizations were achieved using one talented robot named Claude and one extremely skeptical human who asks lots of questions and does not trust the robot. I pored over each commit’s diff to make sure I understood the code; and, as with #21, I wrote this entire PR description by hand to ensure my understanding was deep enough to explain to others.
The specs got updated in the following ways:
verifyScreenLinesForRandomRangehelper is related to this.verifySpatialIndexMatchesNewDisplayLayerhelper to supplement the checks that were already present. Without it, we’d have no way of detecting an incorrect incremental update to the index. It also introduces a new randomized spec that tests row re-wrap behavior, plus one that deterministically tests re-wrap behavior in a scenario where an edit on screen line 3 affects screen line 1.seekcontract. It adds a spec to verify that invalidating just one screen row does not also invalidate other screen rows on that buffer row. It makes one of the randomized specs occasionally perform a highlighting-only change. The existing "emits update events from the display layer when the language mode's highlighting changes" spec is unchanged and helps prove the soundness of the approach and the lack of regressions.What’s next?
This implements most of what’s mentioned in #21. As for the rest:
BufferRowReaderdid it, mostly), but there’s a related aspect that isn’t yet optimized: skipping needing to walk the entire start of the line when soft-wrap is disabled. Pulsar, by enforcing eventual soft-wrap even wheneditor.softWrapisfalse, helps us avoid the worst-case scenario here, but it might still be worth fixing this intext-buffer. It would require keeping track of hard tabs on a row-by-row basis — not just the number of hard tabs in each line but their exact positions.But there are other items we could explore that aren’t in #21:
BufferRowReader, though the performance gains would be much more modest.