Skip to content

Reduce allocations on hot path-normalization and overlay-update paths - #63895

Draft
no-yan wants to merge 1 commit into
microsoft:mainfrom
no-yan:perf/path-normalization-allocs
Draft

Reduce allocations on hot path-normalization and overlay-update paths#63895
no-yan wants to merge 1 commit into
microsoft:mainfrom
no-yan:perf/path-normalization-allocs

Conversation

@no-yan

@no-yan no-yan commented Aug 20, 2026

Copy link
Copy Markdown

Note

AI disclosure:
I asked Claude Fable 5 to identify potential improvements to reduce allocation churn. I then selected some of those suggestions and asked Fable to implement them.
I'll keep these PRs as drafts until I've verified them myself.

First of a short series of PRs addressing allocation churn identified while profiling tsgo -p src/tsconfig.json --noEmit on the VS Code repo.

Motivation

In the longest GC cycle, GODEBUG=gctrace=2 shows that each span scan processes only a small fraction of the objects in the span, limiting Green Tea GC's ability to exploit spatial locality.

Specifically, each span scan visits on average only 3–30% of the span's object slots for the highest-traffic size classes, and about 20% of scanned objects fall back to per-object (sparse) scanning.

These statistics can reflect two distinct causes: low live-object density in scanned spans, or poor batching of same-span marks.

Pointer-chasing structures such as linked lists can cause poor batching because objects in the same span may be discovered at different times. As a result, the same span may be scanned repeatedly with few new objects per pass. The gctrace data alone cannot distinguish between the two causes.

This series targets the former by reducing allocation churn in hot paths identified through profiling.

Per-size-class scan density (gctrace=2, baseline, full vscode/src check)

"Slots visited per pass" = avg objects scanned per span-scan pass ÷
object slots per 8 KiB span.

Size class Span-scanned objs Span scan passes Avg objs/pass Slots/span Slots visited per pass Sparse-scanned objs
48 809,045 53,672 15.1 170 9% 37,068
64 559,992 31,426 17.8 128 14% 28,761
208 533,764 48,500 11.0 39 28% 22,313
80 504,522 19,325 26.1 102 26% 15,771
16 497,579 29,781 16.7 512 3% 17,467
32 420,023 34,268 12.3 256 5% 20,216
96 359,825 32,087 11.2 85 13% 20,737
192 329,959 16,159 20.4 42 49% 8,302
112 327,611 11,921 27.5 73 38% 7,116
224 168,877 14,778 11.4 36 32% 7,104
176 143,710 10,355 13.9 46 30% 7,673
160 131,405 8,788 15.0 51 29% 15,588
8 0 0 1024 652,556

Totals across all classes: 5.52M span-scanned objects in 396k span scan
passes (13.9 objs/pass average) plus 1.48M sparse-scanned objects —
21.2% of scanned objects take the per-object fallback. The 8 B class
and all classes ≥ 576 B are never span-scanned.

This PR

  • overlayfs: processChanges re-hashed the overlay content on every
    LSP edit even though newOverlay had just computed the same hash.
    Drop the second hash.
  • tspath.GetNormalizedAbsolutePath: accumulate the slow
    (dot/dot-dot) path in a pre-sized []byte buffer instead of repeated
    string concatenation, turning O(k²) copying into O(k) appends for
    paths with many segments after the first change.
  • module.normalizePathForCJSResolution: use GetBaseFileName for
    the final-segment check instead of splitting the whole path into a
    []string just to look at its last element. This runs for every
    relative import during module resolution.

Results

The CJS normalization chain drops from 240k–293k to 66k–98k allocations
per full vscode/src check (heap-profile attribution, deterministic under
--singleThreaded). The existing BenchmarkGetNormalizedAbsolutePath
shows −14 to −34% and far fewer allocations on long/multi-segment paths.

Known tradeoff: short non-normalized toy inputs (e.g. "/a/./") lose
the old zero-alloc string-slice references (6→23 allocs per 20 calls).
Real-world corpora are dominated by long absolute paths, where this
change wins — see the end-to-end numbers below.

Verification

  • Full vscode/src output is byte-identical to main (43 errors).
  • GetNormalizedAbsolutePath is covered by the existing differential
    fuzz test against the reference implementation
    (FuzzGetNormalizedAbsolutePath); during development,
    normalizePathForCJSResolution passed a 2.5M–3.3M-case differential
    fuzz against the previous implementation.

Series-wide effect (all four PRs combined)

Measured on vscode/src (--singleThreaded on a quiet machine unless
noted; warm ABAB pairs):

Metric Before After Change
GC wall time / run 968 / 952 ms 793 / 907 ms −11.5%
GC CPU time / run 6,731 / 6,634 ms 5,453 / 6,269 ms −12.3%
Span scan passes / run 771.7k / 823.6k 755.1k / 745.9k −5.9%
Sparse-scanned objects / run 2.50M / 2.65M 2.36M / 2.38M −7.9%
Sparse-scan share 18.7–20.4% 17.9–19.3% lower in every pair
User CPU 18.17 / 17.94 s 17.70 / 17.39 s −2.6 to −3.1%
System CPU (default parallel mode, hyperfine 10 runs ×2 sessions) 19.25 / 17.18 s 11.55 / 13.58 s −40% / −21%
Peak RSS 3.24 / 3.57 GB 3.26 / 3.51 GB ±2% (unchanged, as expected)

The system-CPU drop is consistent with the allocation-rate reduction:
fewer span acquisitions mean fewer madvise calls on Darwin, which is
where the effect is largest in parallel mode (a third session showed
−23%). Wall-clock deltas trend favorably but are within run-to-run
variance and are not claimed.

🤖 Generated with Claude Code

Three allocation-count hot spots identified while profiling a large
real-world corpus (VSCode):

- overlayfs: avoid re-hashing the same content twice on every LSP
  edit (newOverlay already computes the hash).
- tspath.GetNormalizedAbsolutePath: accumulate the slow (dot/dot-dot)
  path via a pre-sized []byte buffer instead of repeated string
  concatenation, turning O(k^2) copying into O(k) appends for paths
  with many segments after the first change.
- module.normalizePathForCJSResolution: use GetBaseFileName for the
  final-segment check instead of splitting the whole path.

GetNormalizedAbsolutePath is covered by the existing
FuzzGetNormalizedAbsolutePath against the reference implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@no-yan

no-yan commented Aug 20, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant