From 71ca2e7f42789cdc2cfeff0b29276ed83a916d63 Mon Sep 17 00:00:00 2001 From: no-yan <63000297+no-yan@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:21:51 +0900 Subject: [PATCH] Reduce allocations on hot path-normalization and overlay-update paths 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 --- tsc/internal/module/resolver.go | 6 +++-- tsc/internal/project/overlayfs.go | 4 ++- tsc/internal/tspath/path.go | 45 ++++++++++++++++++++----------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/tsc/internal/module/resolver.go b/tsc/internal/module/resolver.go index b408b7d4c027a..184abdebba892 100644 --- a/tsc/internal/module/resolver.go +++ b/tsc/internal/module/resolver.go @@ -2063,8 +2063,10 @@ func MatchPatternOrExact(patterns *ParsedPatterns, candidate string) core.Patter // in `.` are actually normalized to `./` before proceeding with the resolution algorithm. func normalizePathForCJSResolution(containingDirectory string, moduleName string) string { combined := tspath.CombinePaths(containingDirectory, moduleName) - parts := tspath.GetPathComponents(combined, "") - lastPart := parts[len(parts)-1] + // Equivalent to checking whether the last element of + // tspath.GetPathComponents(combined, "") is "." or "..", but without + // allocating a component slice for every relative import. + lastPart := tspath.GetBaseFileName(combined) if lastPart == "." || lastPart == ".." { return tspath.EnsureTrailingDirectorySeparator(tspath.NormalizePath(combined)) } diff --git a/tsc/internal/project/overlayfs.go b/tsc/internal/project/overlayfs.go index 476008cda21d3..4ca09d9907c83 100644 --- a/tsc/internal/project/overlayfs.go +++ b/tsc/internal/project/overlayfs.go @@ -376,8 +376,10 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma } } if len(change.Changes) > 0 { + // o was just rebuilt via newOverlay in the loop above, which already + // computed o.hash from o.content — recomputing it here would hash the + // same content a second time. o.version = change.Version - o.hash = xxh3.HashString128(o.content) o.matchesDiskText = false newOverlays[path] = o } diff --git a/tsc/internal/tspath/path.go b/tsc/internal/tspath/path.go index 8b464c6752484..ba5a80c208cdf 100644 --- a/tsc/internal/tspath/path.go +++ b/tsc/internal/tspath/path.go @@ -1,6 +1,7 @@ package tspath import ( + "bytes" "cmp" "slices" "strings" @@ -391,6 +392,18 @@ func GetNormalizedAbsolutePathWithoutRoot(fileName string, currentDirectory stri return absolutePath[rootLength:] } +// startNormalized allocates the normalization buffer for fileName, seeded with +// its already-normalized prefix. Pre-sizing to len(fileName) avoids the append +// growth chain (the normalized result never needs more than the input length). +func startNormalized(fileName string, upTo int) []byte { + return append(make([]byte, 0, len(fileName)), fileName[:upTo]...) +} + +// isDotDotBytes reports whether b is exactly "..". +func isDotDotBytes(b []byte) bool { + return len(b) == 2 && b[0] == '.' && b[1] == '.' +} + func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string { rootLength := GetRootLength(fileName) if rootLength == 0 && currentDirectory != "" { @@ -415,9 +428,11 @@ func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string length := len(fileName) root := fileName[:rootLength] // `normalized` is only initialized once `fileName` is determined to be non-normalized. - // `changed` is set at the same time. + // `changed` is set at the same time. It accumulates via append (not string concatenation) + // so that paths with many segments after the first change don't pay for repeated + // full-string copies. var changed bool - var normalized string + var normalized []byte var segmentStart int index := rootLength normalizedUpTo := index @@ -437,7 +452,7 @@ func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string if index > segmentStart { // Seen superfluous separator if !changed { - normalized = fileName[:max(rootLength, segmentStart-1)] + normalized = startNormalized(fileName, max(rootLength, segmentStart-1)) changed = true } if index == length { @@ -456,7 +471,7 @@ func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string if segmentLength == 1 && fileName[index] == '.' { // "." segment (skip) if !changed { - normalized = fileName[:normalizedUpTo] + normalized = startNormalized(fileName, normalizedUpTo) changed = true } } else if segmentLength == 2 && fileName[index] == '.' && fileName[index+1] == '.' { @@ -464,36 +479,36 @@ func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string if !seenNonDotDotSegment { if changed { if len(normalized) == rootLength { - normalized += ".." + normalized = append(normalized, ".."...) } else { - normalized += "/.." + normalized = append(normalized, "/.."...) } } else { normalizedUpTo = index + 2 } } else if !changed { if normalizedUpTo-1 >= 0 { - normalized = fileName[:max(rootLength, strings.LastIndexByte(fileName[:normalizedUpTo-1], '/'))] + normalized = startNormalized(fileName, max(rootLength, strings.LastIndexByte(fileName[:normalizedUpTo-1], '/'))) } else { - normalized = fileName[:normalizedUpTo] + normalized = startNormalized(fileName, normalizedUpTo) } changed = true - seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && normalized != ".." && !strings.HasSuffix(normalized, "/..") + seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && !isDotDotBytes(normalized) && !bytes.HasSuffix(normalized, []byte("/..")) } else { - lastSlash := strings.LastIndexByte(normalized, '/') + lastSlash := bytes.LastIndexByte(normalized, '/') if lastSlash != -1 { normalized = normalized[:max(rootLength, lastSlash)] } else { - normalized = root + normalized = append(normalized[:0], root...) } - seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && normalized != ".." && !strings.HasSuffix(normalized, "/..") + seenNonDotDotSegment = (len(normalized) != rootLength || rootLength != 0) && !isDotDotBytes(normalized) && !bytes.HasSuffix(normalized, []byte("/..")) } } else if changed { if len(normalized) != rootLength { - normalized += "/" + normalized = append(normalized, '/') } seenNonDotDotSegment = true - normalized += fileName[segmentStart:segmentEnd] + normalized = append(normalized, fileName[segmentStart:segmentEnd]...) } else { seenNonDotDotSegment = true normalizedUpTo = segmentEnd @@ -501,7 +516,7 @@ func GetNormalizedAbsolutePath(fileName string, currentDirectory string) string index = segmentEnd + 1 } if changed { - return normalized + return string(normalized) } if length > rootLength { return RemoveTrailingDirectorySeparators(fileName)