Skip to content

perf(workspace): split only the statements around a change - #1007

Open
psteinroe wants to merge 4 commits into
perf/document-cache-budgetfrom
perf/split-around-changes
Open

psteinroe wants to merge 4 commits into
perf/document-cache-budgetfrom
perf/split-around-changes

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1006. Part of the rebuild described in #997. Replaces #964 (the fix from #963 it carried is on main now).

Every change_file split the whole document again (lexer, splitter, and the blank-line recovery pass) and scanned every line for suppression comments. That is linear in the file and runs on every keystroke:

change_file, one keystroke in the middle of the file before after
1,000 statements (89 KB), release 1.42 ms 0.032 ms
20,000 statements (1.8 MB), release 29.5 ms 0.56 ms
1,000 statements, debug 33.6 ms 0.56 ms
20,000 statements, debug 661 ms 9.6 ms

Measured with a throwaway example (not committed) that types and deletes a character in a table name in the middle of a migration-style file, 200 times, through WorkspaceServer::change_file.

Incremental split

pgls_statement_splitter::split_edited(old, previous, new) returns the same result as split(new):

  1. Find the change. The common prefix and suffix of the old and new text give the changed part, compared in 64-byte blocks. Several edits at once become one changed part.

  2. Re-split a region. The region runs from EDIT_CONTEXT_STATEMENTS = 2 statements before the change to as many after it, split on its own with split. The statements before the region are kept, and the ones after it are shifted by the change in length. Errors are kept and shifted the same way, lexer errors first and then splitter errors, as split orders them.

  3. Check the edges. The two statements at each edge of the region have to come out exactly as in the previous split. That shows the region lines up with the statements around it:

    • the splitter decides statement boundaries from the tokens of the statement and its neighbours (look_back, look_ahead);
    • the blank-line recovery pass works per coarse statement;
    • the lexer is stateless outside of strings, comments and dollar quotes, which the next point excludes.

    The first and the last statement of the region, the ones that are split without their real neighbours (no look_back at the start, the end of the region instead of the next statement), are taken from the previous split as well. Without that, an error that a statement reports at the end of the text (Expected FROM_KW on delete followed by EOF) leaked into the result.

  4. Fall back to a full split when the change, or one byte on either side of it, touches $ ' " / * - \. Those can open or close a string, a dollar quote, a comment or a psql command, and so change how everything after them is lexed. A full split is also used when the edge statements don't match.

The Document keeps the SplitResult of its content and passes it to split_edited in update_content. The ContentChange of #1005 is computed from the previous and the new ranges as before, so the walk's caches keep what comes before the change. SplitResult derives Clone/PartialEq and records how many of its errors come from the lexer.

Suppressions

Suppressions::from now returns early when the text doesn't contain -ignore. Every suppression comment contains it, and the check is a single substring search. Without line suppressions the line index isn't needed, so it isn't built either. This took 2.5 ms of the remaining 3 ms at 20,000 statements. With suppressions in the file, the parser still runs over all lines as before.

One small fix in the same code: LineIndex::line_for_offset was a linear scan over all lines. It is a binary search now, which matters when a file with suppressions has many diagnostics.

Tests

  • split_edited_is_split: 3,000 random edits over 10 documents built from statements the splitter treats differently: $$ and BEGIN ATOMIC functions, comments, quoted ;, blank lines inside statements, statements without ;, invalid SQL. The edits insert, delete or replace text, including fragments that cut or merge statements (;, \n\n, select, begin atomic, ', $$, /*, --, \d), sometimes two at once. After each edit the result must equal split(new), ranges and errors included. Each edit starts from the previous incremental result, so errors would accumulate. SPLIT_SEED and SPLIT_EDITS change the run; locally, 12 seeds × 50,000 edits passed in release. About 43% of these edits are split incrementally; the rest touch a quote or comment and fall back. Normal typing (letters, spaces, ;, parentheses) is incremental.
  • updates_split_like_the_whole_document (workspace): 1,000 edits through Document::update_content. After each, the document's split and syntax diagnostics equal those of a full split.
  • documents_without_suppressions_skip_the_lines: the fast path gives the same suppressions as parsing the lines.
  • The edit-churn and memory tests from perf(workspace): one memory budget for the caches of the open documents #1006 pass unchanged. The churn test drops from 42 s to 32 s in debug.

Not covered

  • Changes inside strings, comments and dollar-quoted bodies fall back to a full split. Typing inside a SQL function body therefore still costs a full split. Handling it would need the lexer to resume from a known state.
  • The pruning of the statement caches in update_content still hashes the text of every statement, about 0.3 ms at 20,000 statements in release. It could use the changed region too.

Update: This is #964 rebuilt on the new stack. With the whole stack, the benchmark of #1005 (release, 20 keystrokes) measures change_file at 2.7 to 2.9 ms per keystroke at 5,000 statements and 12 to 16 ms at 20,000, against 7 to 9 ms and 41 to 59 ms on main. What is left at 20,000 statements is mostly the pruning of the statement caches (about 10 ms), which hashes the text of every statement; it could use the changed region instead.

The rest of this update: The splitter and suppression changes are the same; main already skips the lines of a diagnostic when there are no line suppressions, so that fix is gone.

pgls_statement_splitter::split_edited re-splits the statements around the
changed part of the text and keeps the others, with the same result as
split. It falls back to a full split when the change touches a quote, a
comment, a dollar quote or a psql command, or when the statements at the
edges of the region don't match the previous split. The document keeps
its SplitResult for the next change. Suppressions skip the lines of a
document without suppression comments, and LineIndex looks lines up with
a binary search.

This branch has not been deployed

No deployments
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