Skip to content

perf(workspace): keep catalog checkpoints from before an edit - #982

Closed
psteinroe wants to merge 1 commit into
chore/remove-dead-codefrom
perf/workspace-walk-checkpoints
Closed

psteinroe wants to merge 1 commit into
chore/remove-dead-codefrom
perf/workspace-walk-checkpoints

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #977.

Hover, completions and inlay hints walk the statements before the cursor so that the catalog follows the file (#974). They did that from the start of the file after every change: they mapped each statement (parse cache lookup, a lexer pass for identifier parameters, copying the text) and applied it to the catalog. In a 5,000-statement file that took about 45 ms per keystroke for inlay hints, and 245 ms at 20,000 statements.

The state of the walk before a top-level statement only depends on the text before it, plus the database snapshot and the settings. An edit leaves that state valid for every statement before the edit, so the walk can resume from there.

What changed

  • pgls_analyser::walk_statements can resume from a WalkState, and records the states at the checkpoints you pass it. A WalkState holds the unit's catalog and session, the catalog of the up sections when it is in a down section, and the start of its section.
  • WorkspaceServer::walk_statements takes the start of the statements that the request visits. It records two checkpoints: one at the request's top-level statement, and one at the statement where the last change of the document starts (Document::edited_at). The next keystroke in that statement keeps the second one, also when the request is further down. The next walk resumes from the last valid checkpoint before its statement, and only maps the statements from there on (Document::iter_from).
  • A checkpoint stays valid as long as:
    • Document::update_content doesn't drop it. It keeps a checkpoint while the text before it is unchanged and the statements that start before it have the same ranges. The splitter looks ahead, so an edit after a statement can change where that statement ends. That is why the ranges are compared too. pgls_statement_splitter::common_prefix is now public for this.
    • the settings version and the CatalogBase haven't changed;
    • it is still in the same migration section (WalkState::fits). A marker typed right before the checkpoint's statement starts a new section there, even though the text before the checkpoint is the same.
  • A document keeps at most 4 checkpoints, least recently used out first. clear_caches drops them, so the CacheBudget eviction from perf(workspace): bound the memory of the editor walk cache #953 covers them too. DocumentCacheStats reports them.

When the walk resumes, the AnalysedFileContext it hands to the visitor only knows the statements from the checkpoint on (previous_stmts, stmt_count). The workspace visitors don't use those. The analyser always walks from the start, so lint is unchanged.

Numbers

Release build, a dbmate file of tables, indexes, SQL functions and queries. Each "keystroke" changes a name in one statement, followed by hover, completions and inlay hints in the requested group. Averages over 20 keystrokes. #977 → this PR:

per keystroke 5,000 statements 20,000 statements
edit in the requested statement (typing where you hover)
hover 45.6 → 6.1 ms 253 → 34 ms
completions 45.3 → 36.6 ms 180 → 132 ms
inlay hints 44.1 → 6.0 ms 245 → 31 ms
edit in the CREATE TABLE 3 statements above
hover 94.5 → 58.9 ms 892 → 744 ms
completions 44.6 → 40.4 ms 173 → 154 ms
inlay hints 48.2 → 6.9 ms 231 → 61 ms
edit near the start, request near the start
hover / completions / inlay hints 8.9 / 22.6 / 1.1 → 5.2 / 20.3 / 0.7 ms 13.4 / 63.2 / 2.2 → 6.0 / 58.0 / 1.0 ms
edit near the start, request near the end (nothing to reuse)
hover / completions / inlay hints 106 / 54 / 54 → 102 / 50 / 51 ms 893 / 183 / 232 → 892 / 171 / 250 ms

The machine is shared, so expect about ±15% noise. The last case is the same walk as before, plus recording two checkpoints.

What the checkpoints don't fix:

  • Hover after an edit above the statement is dominated by Catalog::snapshot(), which builds the Snapshot that pgls_hover reads. At 20,000 statements that took 630 ms of the 744 ms. materialize.rs does a tables.iter().find and a columns.retain over all columns for each relation the file defines, which is quadratic in the number of tables. When you type inside the hovered statement, the snapshot comes from the snapshot_before cache, which is why that case is fast. A linear materialization would be a separate change in pgls_catalog.
  • Completions keep an O(n) floor from get_statement_for_completions: Document::count() maps every statement, and the GetCompletionsFilter pass visits every statement.
  • Recording a checkpoint clones the catalog, and evicting one drops it. In a quick profile at 20,000 statements (5,000 tables), cloning and dropping catalogs was most of what remained for inlay hints. A persistent map in Catalog would make both cheap.

Memory, release, test defaults: memory_bounds one document retained 25.3 → 26.0 MiB, all open retained 220.7 → 228.1 MiB, peak 242.4 → 249.8 MiB. edit_churn 32.9/36.1 → 33.5/36.6 MiB with 3 checkpoints at the end. Both pass unchanged.

Tests

  • walks_resume_from_checkpoints_before_the_edit counts the statements each request walks. A cfg(test) counter on WorkspaceServer does the counting. It checks these cases:
    • the first hover walks the whole file, and after an edit in the hovered statement it walks one statement;
    • completions after a new statement resume from the previous checkpoint;
    • an edit to the first CREATE TABLE drops the checkpoints, and completions and hover then see the new column;
    • hover resumes from the checkpoint that completions recorded;
    • 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.
  • 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. It compares hover and inlay hints against the same text opened fresh, with requests from the end of the file backwards. If I disable dropping stale checkpoints, it fails. If I disable fits, the walker's debug_assert fails.
  • pgls_workspace (including the memory and edit-churn tests), pgls_analyser, pgls_statement_splitter and clippy pass.

Hover, completions and inlay hints walked every statement before the cursor after each change,
mapping and applying all of them to the catalog. The walk now records its state at the
top-level statement of the request and at the statement where the last change starts, and the
next walk resumes from the last checkpoint before its statement. A checkpoint stays valid while
the text before it and the statements that start before it don't change, and while the settings
and the database snapshot are the same. A section marker inserted right before it moves it into
another section, so it is checked against the current sections too.

A document keeps at most four checkpoints, and drops them with its other caches.
@psteinroe

Copy link
Copy Markdown
Collaborator Author

Superseded by #1005 (rebuild of the document caches and walk).

@psteinroe psteinroe closed this Oct 26, 2026
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