From 78cff2bd437455653d8dc4efbadbb0f3a4bc9733 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:22:52 -0700 Subject: [PATCH 1/3] Pre-size member, export, and locals symbol tables Symbol tables created on demand by GetMembers, GetExports, and GetLocals always started empty, so binding a class, interface, enum, object literal, namespace, or source file grew the entry slice repeatedly and, once past the linear-search limit, built the hash index and rebuilt it again as it filled up. The declaration that owns the table already tells us roughly how many entries it will receive: the number of class/interface/type literal members, object literal properties, enum members, top-level statements, or parameters and type parameters. Use that count as a capacity hint when the table is first created so it is usually allocated once at the right size. The hint only affects allocation; insertion order, and therefore iteration order, is unchanged. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/ast/utilities.go | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/tsc/internal/ast/utilities.go b/tsc/internal/ast/utilities.go index f178903372dc2..581582f5659c6 100644 --- a/tsc/internal/ast/utilities.go +++ b/tsc/internal/ast/utilities.go @@ -100,15 +100,86 @@ func GetSymbolTable(data *SymbolTable) SymbolTable { } func GetMembers(symbol *Symbol, arena *SymbolExtraArena) SymbolTable { - return GetSymbolTable(&symbol.ensureExtra(arena).members) + extra := symbol.ensureExtra(arena) + if extra.members == nil { + extra.members = NewSymbolTableWithCapacity(membersCapacityHint(symbol)) + } + return extra.members } func GetExports(symbol *Symbol, arena *SymbolExtraArena) SymbolTable { - return GetSymbolTable(&symbol.ensureExtra(arena).exports) + extra := symbol.ensureExtra(arena) + if extra.exports == nil { + extra.exports = NewSymbolTableWithCapacity(exportsCapacityHint(symbol)) + } + return extra.exports } func GetLocals(container *Node) SymbolTable { - return GetSymbolTable(&container.LocalsContainerData().Locals) + data := container.LocalsContainerData() + if data.Locals == nil { + data.Locals = NewSymbolTableWithCapacity(localsCapacityHint(container)) + } + return data.Locals +} + +// The capacity hints below estimate from the declaration syntax how many entries a symbol table will +// hold, so that the binder can fill a table without repeatedly growing it. + +func membersCapacityHint(symbol *Symbol) int { + decls := symbol.Declarations() + if len(decls) == 0 || decls[0] == nil { + return 0 + } + decl := decls[0] + switch decl.Kind { + case KindClassDeclaration, KindClassExpression, KindInterfaceDeclaration, KindTypeLiteral: + return len(decl.Members()) + case KindObjectLiteralExpression, KindJsxAttributes: + return len(decl.Properties()) + } + return 0 +} + +func exportsCapacityHint(symbol *Symbol) int { + decls := symbol.Declarations() + if len(decls) == 0 || decls[0] == nil { + return 0 + } + decl := decls[0] + switch decl.Kind { + case KindEnumDeclaration: + return len(decl.Members()) + case KindSourceFile: + return len(decl.Statements()) + case KindModuleDeclaration: + if body := decl.Body(); body != nil && body.Kind == KindModuleBlock { + return len(body.Statements()) + } + } + return 0 +} + +func localsCapacityHint(container *Node) int { + switch container.Kind { + case KindSourceFile: + return len(container.Statements()) + case KindModuleDeclaration: + if body := container.Body(); body != nil && body.Kind == KindModuleBlock { + return len(body.Statements()) + } + case KindFunctionDeclaration, KindMethodDeclaration, KindMethodSignature, KindConstructor, + KindGetAccessor, KindSetAccessor, KindFunctionExpression, KindArrowFunction, + KindCallSignature, KindConstructSignature, KindFunctionType, KindConstructorType: + n := len(container.TypeParameters()) + if params := container.ParameterList(); params != nil { + n += len(params.Nodes) + } + return n + case KindTypeAliasDeclaration, KindJSTypeAliasDeclaration: + return len(container.TypeParameters()) + } + return 0 } // Determines if a node is missing (either `nil` or empty) From 7bc6b184919106d917684eb346fc2306af996fa5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:31:26 -0700 Subject: [PATCH 2/3] Size module locals and exports by their declarations The capacity hints for source file and namespace symbol tables used the number of top-level statements. That badly overshoots whenever most statements declare nothing that lands in the table: a module with many private declarations and a single export reserved an entry slot, and past the linear-search limit a hash index, for every statement in its exports table, and expression-heavy scripts did the same for locals. Besides the wasted memory, a hint above the linear-search limit forces hashed lookups on a table that ends up tiny. Instead, count what the binder will actually declare, in a single pass over the statements without allocating. Exports count declarations with an export modifier (or every declaration in an ambient export context), export specifiers, `export * as ns`, export assignments, and one shared entry for any number of plain `export *` declarations. Locals count declarations (one per variable declarator), import bindings, and non-exported import aliases, leaving out unnamed default exports and exported import aliases, which only produce exports. --- tsc/internal/ast/utilities.go | 110 ++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/tsc/internal/ast/utilities.go b/tsc/internal/ast/utilities.go index 581582f5659c6..20b3d629d832b 100644 --- a/tsc/internal/ast/utilities.go +++ b/tsc/internal/ast/utilities.go @@ -150,24 +150,18 @@ func exportsCapacityHint(symbol *Symbol) int { switch decl.Kind { case KindEnumDeclaration: return len(decl.Members()) - case KindSourceFile: - return len(decl.Statements()) - case KindModuleDeclaration: - if body := decl.Body(); body != nil && body.Kind == KindModuleBlock { - return len(body.Statements()) - } + case KindSourceFile, KindModuleDeclaration: + _, exports := moduleCapacityHints(decl) + return exports } return 0 } func localsCapacityHint(container *Node) int { switch container.Kind { - case KindSourceFile: - return len(container.Statements()) - case KindModuleDeclaration: - if body := container.Body(); body != nil && body.Kind == KindModuleBlock { - return len(body.Statements()) - } + case KindSourceFile, KindModuleDeclaration: + locals, _ := moduleCapacityHints(container) + return locals case KindFunctionDeclaration, KindMethodDeclaration, KindMethodSignature, KindConstructor, KindGetAccessor, KindSetAccessor, KindFunctionExpression, KindArrowFunction, KindCallSignature, KindConstructSignature, KindFunctionType, KindConstructorType: @@ -182,6 +176,98 @@ func localsCapacityHint(container *Node) int { return 0 } +// moduleCapacityHints estimates the number of locals and exports a source file or namespace declares by counting +// the declarations among its top-level statements, mirroring where the binder puts them (see declareModuleMember). +// Statements that declare nothing, such as expression statements, are not counted. +func moduleCapacityHints(container *Node) (locals int, exports int) { + var statements []*Node + switch container.Kind { + case KindSourceFile: + statements = container.Statements() + case KindModuleDeclaration: + if body := container.Body(); body != nil && body.Kind == KindModuleBlock { + statements = body.Statements() + } + } + // Declarations in an ambient export context are implicitly exported. + exportContext := container.Flags&NodeFlagsExportContext != 0 + hasExportStar := false + for _, statement := range statements { + count := 0 + switch statement.Kind { + case KindVariableStatement: + count = len(statement.AsVariableStatement().DeclarationList.AsVariableDeclarationList().Declarations.Nodes) + case KindFunctionDeclaration, KindClassDeclaration, KindInterfaceDeclaration, KindTypeAliasDeclaration, + KindEnumDeclaration: + count = 1 + case KindJSTypeAliasDeclaration: + // JSDoc typedefs are implicitly exported from modules. + locals++ + exports++ + continue + case KindModuleDeclaration: + if IsAmbientModule(statement) { + // Ambient modules and global augmentations are never exported. + locals++ + continue + } + count = 1 + case KindImportEqualsDeclaration: + // An exported import alias is only declared as an export. + if statement.ModifierFlags()&ModifierFlagsExport != 0 { + exports++ + } else { + locals++ + } + continue + case KindImportDeclaration, KindJSImportDeclaration: + if clause := statement.ImportClause(); clause != nil { + if clause.Name() != nil { + locals++ + } + if bindings := clause.AsImportClause().NamedBindings; bindings != nil { + if bindings.Kind == KindNamedImports { + locals += len(bindings.Elements()) + } else { + locals++ + } + } + } + continue + case KindExportDeclaration: + switch clause := statement.AsExportDeclaration().ExportClause; { + case clause == nil: + // All `export *` declarations share a single entry. + hasExportStar = true + case clause.Kind == KindNamedExports: + exports += len(clause.Elements()) + default: + exports++ + } + continue + case KindExportAssignment: + exports++ + continue + default: + continue + } + flags := statement.ModifierFlags() + if flags&ModifierFlagsExport == 0 && !exportContext { + locals += count + continue + } + exports += count + // An unnamed default export has no local symbol. + if flags&ModifierFlagsDefault == 0 || statement.Name() != nil { + locals += count + } + } + if hasExportStar { + exports++ + } + return locals, exports +} + // Determines if a node is missing (either `nil` or empty) func NodeIsMissing(node *Node) bool { return node == nil || node.Loc.Pos() == node.Loc.End() && node.Loc.Pos() >= 0 && node.Kind != KindEndOfFile From 8a39fac1bf08cade5f3153b34137b834266ed7f3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:12:21 -0700 Subject: [PATCH 3/3] Split class capacity hints between members and exports The class members hint counted every class element, but the binder declares static elements in the class's exports rather than its members, and static blocks and stray semicolons declare nothing. A class made up mostly of static members therefore reserved a large entry slice, and past the linear-search limit a hash index, for an instance member table that might hold only a constructor, while its exports table still started empty and grew one entry at a time. Count the class elements once and attribute each to the table it lands in: instance elements, a single constructor entry shared by all of its overloads, and constructor parameter properties go to members; static elements plus the implicit `prototype` property go to exports. Overloaded methods and accessor pairs may still be counted more than once, which keeps the hint within a small constant factor of the actual size. --- tsc/internal/ast/utilities.go | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tsc/internal/ast/utilities.go b/tsc/internal/ast/utilities.go index 20b3d629d832b..57d2c89580477 100644 --- a/tsc/internal/ast/utilities.go +++ b/tsc/internal/ast/utilities.go @@ -133,7 +133,10 @@ func membersCapacityHint(symbol *Symbol) int { } decl := decls[0] switch decl.Kind { - case KindClassDeclaration, KindClassExpression, KindInterfaceDeclaration, KindTypeLiteral: + case KindClassDeclaration, KindClassExpression: + members, _ := classCapacityHints(decl) + return members + case KindInterfaceDeclaration, KindTypeLiteral: return len(decl.Members()) case KindObjectLiteralExpression, KindJsxAttributes: return len(decl.Properties()) @@ -150,6 +153,9 @@ func exportsCapacityHint(symbol *Symbol) int { switch decl.Kind { case KindEnumDeclaration: return len(decl.Members()) + case KindClassDeclaration, KindClassExpression: + _, exports := classCapacityHints(decl) + return exports case KindSourceFile, KindModuleDeclaration: _, exports := moduleCapacityHints(decl) return exports @@ -176,6 +182,38 @@ func localsCapacityHint(container *Node) int { return 0 } +// classCapacityHints estimates the number of instance members and static exports a class declares, mirroring +// where the binder puts them (see declareClassMember). +func classCapacityHints(class *Node) (members int, exports int) { + // Every class has a static 'prototype' property. + exports = 1 + hasConstructor := false + for _, member := range class.Members() { + switch { + case member.Kind == KindSemicolonClassElement || member.Kind == KindClassStaticBlockDeclaration: + // Declares no symbol in either table. + case IsStatic(member): + exports++ + case member.Kind == KindConstructor: + // All constructor overloads share a single symbol. + if !hasConstructor { + hasConstructor = true + members++ + } + if params := member.ParameterList(); params != nil { + for _, param := range params.Nodes { + if param.ModifierFlags()&ModifierFlagsParameterPropertyModifier != 0 { + members++ + } + } + } + default: + members++ + } + } + return members, exports +} + // moduleCapacityHints estimates the number of locals and exports a source file or namespace declares by counting // the declarations among its top-level statements, mirroring where the binder puts them (see declareModuleMember). // Statements that declare nothing, such as expression statements, are not counted.