From b9badbef78b927204b455ad36115bdc2722e5d24 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:29:02 -0700 Subject: [PATCH] Avoid re-sorting spread members from scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every object spread builds a member table and sorts its properties by declaration. Chains such as `{ ...a, x, ...b, y }` re-sort the whole accumulated set at each step, and each comparison walked the parent chain of both declarations to find their files. On mui-docs this sort was about 6% of single-checker time. Compute each symbol's sort key once, sort already-sorted input and two sorted runs in linear time, and have spreads list their members in the order of the two (already sorted) property lists. The sorted result is the same; only the work to get there changes. Single-checker check time on mui-docs, five runs each: before 9.004s 9.103s 9.114s 9.157s 9.207s after 8.826s 8.879s 8.940s 8.943s 9.032s │ old.txt │ new.txt │ │ sec/op │ sec/op vs base │ Tsc/bluesky 2.385 ± 5% 2.350 ± 7% ~ (p=0.394 n=6) Tsc/sentry 10.11 ± 3% 10.07 ± 2% ~ (p=0.699 n=6) Tsc/t3code-server 8.042 ± 4% 7.920 ± 2% ~ (p=0.240 n=6) Tsc/mui-docs 6.165 ± 2% 6.105 ± 3% ~ (p=0.394 n=6) geomean 5.880 5.816 -1.09% │ old.txt │ new.txt │ │ rss-B │ rss-B vs base │ Tsc/bluesky 1.175Gi ± 3% 1.176Gi ± 1% ~ (p=1.000 n=6) Tsc/sentry 3.365Gi ± 4% 3.438Gi ± 6% ~ (p=0.132 n=6) Tsc/t3code-server 2.694Gi ± 8% 2.868Gi ± 7% ~ (p=0.485 n=6) Tsc/mui-docs 2.290Gi ± 3% 2.337Gi ± 7% ~ (p=0.589 n=6) geomean 2.222Gi 2.281Gi +2.66% │ old.txt │ new.txt │ │ heap-B │ heap-B vs base │ Tsc/bluesky 915.3Mi ± 0% 915.1Mi ± 0% ~ (p=0.180 n=6) Tsc/sentry 2.481Gi ± 0% 2.482Gi ± 0% ~ (p=0.065 n=6) Tsc/t3code-server 2.178Gi ± 0% 2.179Gi ± 0% ~ (p=0.310 n=6) Tsc/mui-docs 1.732Gi ± 0% 1.732Gi ± 0% ~ (p=0.240 n=6) geomean 1.701Gi 1.701Gi +0.02% --- tsc/internal/checker/checker.go | 48 +++++++++++-- tsc/internal/checker/types_test.go | 45 ++++++++++++ tsc/internal/checker/utilities.go | 110 ++++++++++++++++++++++++++++- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 10ddb3c0c88b4..22c9597ee5198 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -692,6 +692,7 @@ type Checker struct { structuredTypeExtraArena core.Arena[structuredTypeExtra] freeTypeSets *temporaryTypeSet freePropertyCounts *temporaryPropertyCounts + symbolSortKeys []symbolSortKey mergedSymbols map[*ast.Symbol]*ast.Symbol mergedExportsChecked collections.Set[*ast.Symbol] factory ast.NodeFactory @@ -13695,11 +13696,16 @@ func (c *Checker) getSpreadType(left *Type, right *Type, symbol *ast.Symbol, obj } else { indexInfos = c.getUnionIndexInfos([]*Type{left, right}) } + // Both property lists are usually sorted already, so keep the members in + // their order (left properties first) to make sorting them cheap. + var leftOrder, rightOrder []*ast.Symbol for _, rightProp := range c.getPropertiesOfType(right) { if getDeclarationModifierFlagsFromSymbol(rightProp)&(ast.ModifierFlagsPrivate|ast.ModifierFlagsProtected) != 0 { skippedPrivateMembers.Add(rightProp.Name()) } else if c.isSpreadableProperty(rightProp) { - members[rightProp.Name()] = c.getSpreadSymbol(rightProp, readonly) + spreadProp := c.getSpreadSymbol(rightProp, readonly) + members[rightProp.Name()] = spreadProp + rightOrder = append(rightOrder, spreadProp) } } @@ -13732,15 +13738,25 @@ func (c *Checker) getSpreadType(left *Type, right *Type, symbol *ast.Symbol, obj result.SetDeclarations(declarations) links.setNameType(c, c.valueSymbolLinks.Get(leftProp).getNameType()) members[leftProp.Name()] = result + leftOrder = append(leftOrder, result) } } else { - members[leftProp.Name()] = c.getSpreadSymbol(leftProp, readonly) + spreadProp := c.getSpreadSymbol(leftProp, readonly) + members[leftProp.Name()] = spreadProp + leftOrder = append(leftOrder, spreadProp) + } + } + order := leftOrder + for _, prop := range rightOrder { + if members[prop.Name()] == prop { + order = append(order, prop) } } spreadIndexInfos := core.SameMap(indexInfos, func(info *IndexInfo) *IndexInfo { return c.getIndexInfoWithReadonly(info, readonly) }) - spread := c.newAnonymousType(symbol, members, nil, nil, spreadIndexInfos) + spread := c.newObjectType(ObjectFlagsAnonymous, symbol) + c.setStructuredTypeMembersInOrder(spread, members, order, nil, nil, spreadIndexInfos) spread.objectFlags |= ObjectFlagsObjectLiteral | ObjectFlagsContainsObjectOrArrayLiteral | ObjectFlagsContainsSpread | objectFlags return spread } @@ -22544,9 +22560,29 @@ func (c *Checker) getDefaultOrUnknownFromTypeParameter(t *Type) *Type { } func (c *Checker) getNamedMembers(members ast.SymbolTable, container *ast.Symbol) []*ast.Symbol { + return c.getNamedMembersInOrder(members, nil, container) +} + +// getNamedMembersInOrder is getNamedMembers where order, if not nil, holds the +// symbols of members keyed by their names. The result does not depend on the +// order, but sorting is much cheaper when order is mostly sorted already. +func (c *Checker) getNamedMembersInOrder(members ast.SymbolTable, order []*ast.Symbol, container *ast.Symbol) []*ast.Symbol { if len(members) == 0 { return nil } + if order != nil { + debug.Assert(len(order) == len(members)) + if container == nil || container.Flags()&(ast.SymbolFlagsClass|ast.SymbolFlagsInterface) == 0 { + result := make([]*ast.Symbol, 0, len(order)) + for _, symbol := range order { + if c.isNamedMember(symbol, symbol.Name()) { + result = append(result, symbol) + } + } + c.sortSymbols(result) + return result + } + } // For classes and interfaces, we store explicitly declared members ahead of inherited members. This ensures we process // explicitly declared members first in type relations, which is beneficial because explicitly declared members are more // likely to contain discriminating differences. See for example https://github.com/microsoft/TypeScript/tsc/issues/1968. @@ -25723,10 +25759,14 @@ func (c *Checker) cloneTypeReference(source *Type) *Type { } func (c *Checker) setStructuredTypeMembers(t *Type, members ast.SymbolTable, callSignatures []*Signature, constructSignatures []*Signature, indexInfos []*IndexInfo) { + c.setStructuredTypeMembersInOrder(t, members, nil, callSignatures, constructSignatures, indexInfos) +} + +func (c *Checker) setStructuredTypeMembersInOrder(t *Type, members ast.SymbolTable, order []*ast.Symbol, callSignatures []*Signature, constructSignatures []*Signature, indexInfos []*IndexInfo) { t.objectFlags |= ObjectFlagsMembersResolved data := t.AsStructuredType() data.members = members - data.properties = c.getNamedMembers(members, t.symbol) + data.properties = c.getNamedMembersInOrder(members, order, t.symbol) c.setStructuredTypeSignatures(data, callSignatures, constructSignatures, indexInfos) } diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 86de2b089e06e..9bd12e455e09e 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -2,6 +2,8 @@ package checker import ( "encoding/binary" + "fmt" + "math/rand/v2" "runtime" "slices" "strconv" @@ -10,7 +12,11 @@ import ( "unsafe" "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/binder" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/parser" "github.com/microsoft/TypeScript/tsc/internal/testutil/race" + "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/zeebo/xxh3" "gotest.tools/v3/assert" ) @@ -345,6 +351,45 @@ func TestTemporaryPropertyCounts(t *testing.T) { assert.Assert(t, other.getTemporaryPropertyCounts() != outer) } +func TestSortSymbolsMatchesCompareSymbols(t *testing.T) { + t.Parallel() + c := &Checker{fileIndexMap: make(map[*ast.SourceFile]int)} + c.compareSymbols = c.compareSymbolsWorker + var symbols []*ast.Symbol + for i, name := range []string{"/b.ts", "/a.ts"} { + var text strings.Builder + text.WriteString("interface I {") + for j := range 40 { + fmt.Fprintf(&text, " p%d: any;", (j*7)%40) + } + text.WriteString(" }") + file := parser.ParseSourceFile(ast.SourceFileParseOptions{FileName: tspath.RootedFilePathFromAbsolute(name), PathKey: tspath.PathKeyFromCanonical(name)}, text.String(), core.ScriptKindTS) + binder.BindSourceFile(file) + c.fileIndexMap[file] = i + for _, member := range file.Locals["I"].Members() { + symbols = append(symbols, member) + } + } + for i := range 6 { + symbols = append(symbols, c.newSymbol(ast.SymbolFlagsProperty, fmt.Sprintf("x%d", i%3))) + } + expected := slices.Clone(symbols) + slices.SortFunc(expected, c.compareSymbols) + half := slices.Clone(expected[:len(expected)/2]) + twoRuns := append(slices.Clone(expected[len(expected)/2:]), half...) + inputs := [][]*ast.Symbol{slices.Clone(expected), twoRuns} + rng := rand.New(rand.NewPCG(1, 2)) + for range 20 { + shuffled := slices.Clone(expected) + rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + inputs = append(inputs, shuffled) + } + for _, input := range inputs { + c.sortSymbols(input) + assert.Assert(t, slices.Equal(input, expected)) + } +} + func TestIntersectionReductionReentrancy(t *testing.T) { t.Parallel() c, _ := newCompositeTestChecker() diff --git a/tsc/internal/checker/utilities.go b/tsc/internal/checker/utilities.go index 3a7e9993ba879..6d591be688762 100644 --- a/tsc/internal/checker/utilities.go +++ b/tsc/internal/checker/utilities.go @@ -359,8 +359,116 @@ func createSymbolTable(symbols []*ast.Symbol) ast.SymbolTable { return result } +type symbolSortKey struct { + symbol *ast.Symbol + file *ast.SourceFile + fileIndex int + pos int +} + +// sortSymbols sorts symbols in compareSymbols order. Comparing declarations +// finds each node's source file by walking its parents, so the keys are +// computed once per symbol rather than once per comparison. func (c *Checker) sortSymbols(symbols []*ast.Symbol) { - slices.SortFunc(symbols, c.compareSymbols) + if len(symbols) < 2 { + return + } + keys := c.symbolSortKeys[:0] + for _, s := range symbols { + key := symbolSortKey{symbol: s, fileIndex: -1} + if s != nil { + if decls := s.Declarations(); len(decls) != 0 && decls[0] != nil { + key.file = ast.GetSourceFileOfNode(decls[0]) + key.fileIndex = c.fileIndexMap[key.file] + key.pos = decls[0].Pos() + } + } + keys = append(keys, key) + } + // Symbol lists are often already sorted, or two sorted runs. + descents, split := 0, 0 + for i := 1; i < len(keys) && descents < 2; i++ { + if c.compareSymbolSortKeys(keys[i-1], keys[i]) > 0 { + descents++ + split = i + } + } + switch descents { + case 0: + case 1: + // The keys hold the symbols, so the runs merge straight into symbols. + left, right := keys[:split], keys[split:] + out := symbols[:0] + for len(left) != 0 && len(right) != 0 { + if c.compareSymbolSortKeys(left[0], right[0]) <= 0 { + out = append(out, left[0].symbol) + left = left[1:] + } else { + out = append(out, right[0].symbol) + right = right[1:] + } + } + for _, k := range left { + out = append(out, k.symbol) + } + for _, k := range right { + out = append(out, k.symbol) + } + default: + slices.SortFunc(keys, c.compareSymbolSortKeys) + for i := range keys { + symbols[i] = keys[i].symbol + } + } + clear(keys) + if cap(keys) <= 4096 { + c.symbolSortKeys = keys[:0] + } +} + +// compareSymbolSortKeys matches compareSymbolsWorker. +func (c *Checker) compareSymbolSortKeys(k1, k2 symbolSortKey) int { + s1, s2 := k1.symbol, k2.symbol + if s1 == s2 { + return 0 + } + if s1 == nil { + return 1 + } + if s2 == nil { + return -1 + } + if len(s1.Declarations()) != 0 && len(s2.Declarations()) != 0 { + if r := compareSymbolSortKeyDeclarations(k1, k2); r != 0 { + return r + } + } else if len(s1.Declarations()) != 0 { + return -1 + } else if len(s2.Declarations()) != 0 { + return 1 + } + if r := strings.Compare(s1.Name(), s2.Name()); r != 0 { + return r + } + return cmp.Compare(ast.GetSymbolId(s1), ast.GetSymbolId(s2)) +} + +// compareSymbolSortKeyDeclarations matches compareNodes on the first declarations. +func compareSymbolSortKeyDeclarations(k1, k2 symbolSortKey) int { + d1, d2 := k1.symbol.Declarations()[0], k2.symbol.Declarations()[0] + if d1 == d2 { + return 0 + } + if d1 == nil { + return 1 + } + if d2 == nil { + return -1 + } + if k1.file != k2.file { + return k1.fileIndex - k2.fileIndex + } + return k1.pos - k2.pos } func (c *Checker) compareSymbolsWorker(s1, s2 *ast.Symbol) int {