Skip to content

perf(resolve): keep the members an import surfaces for the query asking again - #1358

Merged
HuiJun merged 5 commits into
developfrom
perf/memoize-namespace-children
Oct 21, 2026
Merged

HuiJun merged 5 commits into
developfrom
perf/memoize-namespace-children

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What and why

The 0.11.0 performance record against v0.10.1 (#1337) leaves one regression after #1348: the language server's ReferencesCold (44 ms → ~1.0 s, 9.8 MiB → ~950 MiB per query) and RenameWarm (20 ms → ~1.7 s, 1.4 MiB → ~1.5 GiB), bisected by exact allocation counts to #1062 (hidden colliding imports). #1348 removed the per-member garbage; what remains is the member lists themselves, rebuilt over and over: since #1062 made enumerating what a wildcard import surfaces expensive, every document a references query indexes and every occurrence a rename rewrites enumerated the same imports again. The resolver's per-edge memo (enumMembers, issue #633) was cleared when the outermost enumeration returned, so it only ever served one enumeration.

The enumeration is now kept for the next one asking the same:

  • enumMembers, the per-edge memo (the members an import edge surfaces into its own namespace), stays as it was: provisional, filled within one enumeration and cleared when the outermost returns — inside a re-import cycle every edge is cut, and that memo is what keeps a cycle linear rather than once per path. Beside it, enumKept keeps across enumerations the edges whose result was not cut, and enumResults keeps what importedMembersInto/ImportedMembersUnfiltered answer for a (target namespace, referring namespace, import, member kinds) — the top-level enumeration, which the per-edge memo never covered.
  • Both are journaled (journalNew) with the import's document, so an edit that invalidates that document drops them, as the resolver's other tables. Each kept entry also carries what its computation read (keptMembers: the members and a readCapture taken with capturing), replayed into the reader's frame on every hit the way SharedCollisions does, so a later document reading a kept enumeration depends on the documents it read and is dropped with them. MemoSize counts both tables.
  • An enumeration is kept only when nothing it read was still being computed, the rule the collisions memo (hidesBelow) applies: enumCut counts the cuts — an import edge skipped because it is on the enumeration stack (a re-import cycle), an import target still resolving (resolvingImports), a filter condition in progress (inCondition) — and a result computed while the count moved, or while either is set, is returned but not kept. Neither table is read while a filter condition's own names resolve (inCondition), which admit every member (KerML 8.2.4): a kept filtered enumeration would otherwise answer unfiltered.

The members returned are the same; callers iterate the slices or copy from them (withoutHiddenMembers filters into a fresh slice), none appends to one.

How it was verified

  • TestImportedMembersAreEnumeratedOncePerResolver (internal/semantic/resolve/enumeration_memo_test.go): enumerating the same import again allocates 26 objects on develop (the whole enumeration, the three members re-exported through two wildcard imports), 0 on this branch.
  • TestImportedMembersKeptAreDroppedOnEdit: with the tracked index of frames_test.go, the imported package gains a member through put (the workspace's invalidation path) and the import enumerates again and surfaces it.
  • TestImportedMembersKeptMakeTheirReaderDepend: two tracked frames enumerate the same import; the second, which reads the kept enumeration, is among the imported document's dependents and is dropped by an edit of it (on the first head of this branch the dependents were the first reader only).
  • TestImportedMembersInAConditionAreNotTheFilteredOnes: Lib::*[@Safety] enumerated once brings the one admitted member, inside InCondition both, outside again one (on the first head the condition got the kept filtered one).
  • TestImportedMemberEnumerationDoesNotWalkRoutesPerMember (perf(resolve): enumerate imported members without walking their routes #1348's test) now measures the enumeration itself (enumerateImported) rather than its kept result, so it keeps its per-member meaning: 1.09 allocations per member.
  • go test ./internal/semantic/... ./internal/frontend/lsp/... ./tests/resolve/... ./internal/check/..., gofmt -l . empty, go vet ./..., go build ./..., python3 scripts/changelog.py check.

A/B over six interleaved rounds, develop a662c2b | this branch at its final head (benchstat, n=6, allocations exact; this machine's timings carry ±5–30%):

row sec/op B/op allocs/op
lsp ReferencesCold 1 027 ms → 208 ms (−80%) 950.1 MiB → 134.7 MiB (−86%) 1 944.5k → 124.0k (−94%)
lsp RenameWarm 1 717 ms → 51 ms (−97%) 1 548.7 MiB → 960 KiB (−99.9%) 3 740.3k → 4.9k (−99.9%)
lsp ReferencesWarm 219 µs → 220 µs (~) 339.4 KiB (=) 1 017 (=)
lsp WorkspaceUpdate 258 ms → 261 ms (~) 57.15 → 57.21 MiB (~) 710.6k → 710.3k (~)
lsp FormatEdits 0.93 → 0.95 ms (~) 704.3 KiB (=) 85 (=)
tests/perf WorkspaceEdit/vehicle/reindex+diagnostics 277 → 279 ms (~) 152.5 → 141.7 MiB (−7.1%) 930.8k → 883.6k (−5.1%)
tests/perf GRPCParseFileUncached 264 → 288 ms (~, ±17%) 180.0 → 177.1 MiB (−1.6%) 1 021.1k → 990.7k (−3.0%)
tests/perf WorkspaceEdit/synthetic (+ reindex), Analyze/synthetic, ImportValues/* (8 sizes) ~ ~ =

For comparison, 0.10.1 measured 44 ms / 9.8 MiB / 44.6k on ReferencesCold and 20 ms / 1.4 MiB / 13.7k on RenameWarm (the record, #1337); the rest of the distance is #1062's enumeration itself, carried in the record.

The two gates a resolver change must hold, both sides on this machine: TestPackagesImportingEachOtherInACycleAnalysePromptly/n=8 (tests/model, -race) 0.50 s on develop, 0.54 s here — a first form of the reads replay, which appended each read to every capture open above it, had taken it to 2.44 s against its 5 s deadline, which is why a capture now records only its own reads and adopts the captures settled or replayed inside it as children, folded into a separate snapshot on its first replay (flatten; the snapshot keeps a settled capture immutable, as SharedCollisions replays from parallel batch workers — TestCapturesReplayInParallelWithoutRacing); and TestInterfaceRecordDifferential/examples/self-model (-race) 17.0 s on develop, 15.4 s here. TestCapturesNoteEachDocumentOnceHoweverOftenItRepeats now checks what the outer capture replays into a frame rather than its own list, and that a top-level capture is settled.

Checklist

  • make test and make lint pass locally (the packages above; the full suite runs in CI)
  • Tests added or updated for the change
  • Documentation extended where it already covers the surface (none covers this memo)
  • Changelog entry added as changes/unreleased/memoize-imported-members.fixed.md, not as an edit to CHANGELOG.md
  • baselines regenerated and make docs-counts run if a gate count moved (none moved)
  • No internal work-item labels (waves, slices, F4, K5) in the body, docs, or changelog

Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/cf22d7a746814d20b2f0eaf11682fef9
Open in Devin Desktop: https://nasa-jpl-demo.devinenterprise.com/desktop/session/cf22d7a746814d20b2f0eaf11682fef9?variant=devin
Requested by: @HuiJun


Devin Review

…ng again

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

A kept enumeration is replayed into its reader's frame (what it read is captured as the collisions memo does) so an edit of a document it read drops the reader too, is not read while a filter condition's own names resolve (which admit every member), and is counted by MemoSize.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review October 21, 2026 16:21
A read inside a nested capturing was appended to every capture open above it and a hit replayed its reads into them again, once per nesting level; with kept enumerations that made an eight-package import cycle five times slower to analyse. A capture records its own reads and takes the captures settled or replayed inside it as children, folded into its lists once on its first replay.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits October 21, 2026 17:36
…shot

A capture replayed from parallel batch workers must not be written after it settles: flatten now builds a separate snapshot of the capture's reads and its children's, under the capture's Once, reading only the children's own lists; capturing settles every capture, adopted or top-level, as before.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
Co-Authored-By: jason.han <hanhuijun@gmail.com>
@HuiJun
HuiJun merged commit 5db0f74 into develop Oct 21, 2026
28 checks passed
@HuiJun
HuiJun deleted the perf/memoize-namespace-children branch October 21, 2026 19:46
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