Skip to content

perf(workspace): keep what the walk resolves for the next requests - #1005

Open
psteinroe wants to merge 3 commits into
perf/cache-document-analysisfrom
perf/cache-statement-walk
Open

psteinroe wants to merge 3 commits into
perf/cache-document-analysisfrom
perf/cache-statement-walk

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1004. Part of the rebuild described in #997 (points 3 and 4). Replaces #930, #982 and #984, and takes the shared catalog objects from #953.

Hover, completions, inlay hints and semantic tokens walked the file from its first statement on every request, and resolved every statement in their range again. On a 20,000-statement file that was seconds per keystroke for each of them, and the editor sends several per keystroke.

The design

The state of the walk before a top-level statement only depends on the text before it, its section, and what the CacheKey of #1004 covers (settings, project, database snapshot). The references of a statement also depend on the statement itself. So everything the walk leaves behind is kept in the document's cache (document_cache/walk.rs, WalkCache) under that key, and an edit keeps what comes before it:

  • Checkpoints (pgls_analyser::WalkState): the catalog and the session before a top-level statement, with the section it is in. walk_statements can resume from one and records new ones at the checkpoints it is given. A request records two: at its first statement, and at the statement where the last edit starts, so the next keystroke there resumes right before it, also for requests further down. A document keeps at most 4, least recently used out first.
  • Resolved statements: the references of each top-level statement that a request resolved, with the statements of its function body, by the statement's start. Inlay hints and semantic tokens are computed from them (inlay_hints_of_references, semantic_tokens_of_references), hover and the expand-* action look them up. A request whose statements are all resolved doesn't walk at all.
  • The snapshot that hover and completions last built for a statement: Catalog::snapshot() is the most expensive part of hover after an edit above the hovered statement.

Document::update_content computes a ContentChange: the length of the text that is the same, and the start of the first top-level statement that isn't the same after the edit or doesn't end before it (the splitter looks ahead, so a statement before the edit can end elsewhere). One rule then applies to all three: a checkpoint, and the snapshot of a top-level statement, stay if their statement starts in the unchanged text and no statement before it changed; resolved statements, and the snapshot of a function body, also need their statement unchanged. A checkpoint is also only used if its statement is still in the same migration section (WalkState::fits): a marker typed right before a statement moves it into a new section although the text before its old start is the same.

The workspace's side is server/walk.rs: resolved_statements(range), snapshot_before(statement), hover_references and references_at, on top of one walk that picks the checkpoint, records the new ones and counts the statements it walks (in tests). The server-wide one-entry snapshot_before cache and its hashing of the text before the statement on every request are gone.

Also in this PR:

Timings

Release build, a dbmate file of tables, indexes, SQL functions and queries, on a shared machine (expect ±20% noise; the "before" run was on main at the base of the stack). Each "keystroke" changes a name in one statement, followed by change_file, hover, completions, inlay hints and semantic tokens over the 2 KB before the requested statement. Averages over 20 keystrokes, in ms; diagnostics and code actions over the first 5.

20,000 statements, per keystroke main this PR
edit in the requested statement: hover / completions / inlay hints / tokens 2,438 / 1,898 / 2,733 / 2,699 58 / 236 / 13 / 1.3
edit in the CREATE TABLE 3 statements above: hover / completions / inlay hints / tokens 9,700 / 1,690 / 2,216 / 2,310 1,190 / 184 / 30 / 0.2
edit near the start, request near the start: hover / completions / inlay hints 3.5 / 585 / 17 2.8 / 52 / 2.8
edit near the start, request near the end (nothing to reuse): hover / completions / inlay hints 8,377 / 1,758 / 2,007 1,416 / 211 / 248
code actions after the diagnostics 7,477 7.2
5,000 statements, per keystroke main this PR
edit in the requested statement: hover / completions / inlay hints 302 / 342 / 366 12 / 82 / 0.6
edit in the CREATE TABLE above: hover / completions / inlay hints 375 / 276 / 263 63 / 65 / 4.6
edit near the start, request near the end: hover / completions / inlay hints 740 / 412 / 452 106 / 63 / 45

Unchanged file, 20,000 statements: full semantic tokens 5,615 ms the first time and 5,829 ms again on main; 2,366 ms and 167 ms here. Hover 3,909 ms and 1,675 ms on main; 1,801 ms and 2.5 ms here.

What remains:

  • Hover after an edit above the hovered statement is dominated by Catalog::snapshot(), whose materialization is quadratic in the tables the file defines (materialize.rs does a find and a retain over all columns per relation). That is a change in pgls_catalog of its own.
  • Completions near the end of a long file are mostly pgls_completions::complete itself, which goes over every object the file adds to the snapshot.
  • change_file still re-splits the whole file; that follows later in the stack.

Tests

  • walks_resume_from_checkpoints_before_the_edit counts the statements each request walks: the first hover walks the file and a repeated one nothing; after an edit in the hovered statement it walks that statement; completions after a new statement resume from the previous checkpoint; an edit to the first CREATE TABLE drops everything, and completions and hover then see the new column; the second keystroke in a statement above the request walks two statements; a section marker inserted right before a checkpoint makes the walk skip it; and a document keeps at most 4 checkpoints.
  • walks_from_checkpoints_match_full_walks makes edits early, in the middle and late in a file with up/down sections, unparsable statements, functions and their calls, and compares hover, inlay hints and semantic tokens with the same text opened fresh, with requests from the end of the file backwards. It fails if the resolved statements or the checkpoints are not dropped on an edit.
  • finds_the_statement_like_a_filter_over_all_statements compares the completions lookup with filtering all statements, at every position of a document with every case the filter cares about.
  • pgls_workspace, pgls_analyser (rules specs, Postgres regression), pgls_catalog, pgls_inlay_hints, pgls_semantic_tokens, pgls_hover and pgls_lsp pass, and clippy on the whole workspace.

Not covered

  • Memory: the resolved statements of every statement a request looked at stay until an edit before them. A budget across documents follows in the next PR.
  • The resolved statements keep the statement's tree (shared with the parse cache) and its references, which copy the objects they resolve to.

Update: update_content now returns what an edit invalidated (StaleEntries), and change_file drops it on a thread of its own after releasing the documents lock. On a 20,000-statement file, dropping the analysis, the catalog copies of the checkpoints and the snapshot took 60 to 90 ms of every keystroke near the start of the file (measured with a traced build: the split took 0.2 ms, pruning the statement caches 10 ms). The editor features docs now describe the cached walk.

The walk of the editor features resumes from checkpoints that the
document keeps (pgls_analyser::WalkState), at the statement of the last
request and of the last edit, and the references it resolves are kept
per top-level statement, with the snapshot that hover and completions
last built. All of it lives in the document's cache under its CacheKey,
and an edit keeps what comes before it. Inlay hints and semantic tokens
are computed from the kept references, and a request whose statements are
all resolved doesn't walk. Completions find their statement without
going over the whole file, and the catalog shares the objects of its
clones.
The analysis, the catalog copies of the walk checkpoints and the snapshot
of a long file hold every object it defines, and dropping them took
60 to 90 ms of change_file on a 20,000-statement file. update_content
returns them, and change_file drops them on a thread of their own after
releasing the documents lock. The editor features docs describe the
cached walk.
WebAssembly has no threads, and spawning one doesn't link there.

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