From b3668213578007efd9938fc3a1e4acc19481a78e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:16:25 -0700 Subject: [PATCH 1/3] Avoid hashing keys in active mapper instantiation caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every instantiation under an active mapper built and hashed a 128-bit key before probing the mapper's cache, even when the mapper had just been pushed and its cache was necessarily empty. Most keys carry no alias, so the type ID alone identifies the entry. Measured with --checkers 4 (n=8): │ before.txt │ after.txt │ │ sec/op │ sec/op vs base │ Tsc/bluesky 2.756 ± 3% 2.742 ± 3% ~ (p=0.366 n=8) Tsc/sentry 11.20 ± 3% 11.19 ± 4% ~ (p=0.742 n=8) Tsc/t3code-server 10.84 ± 3% 10.72 ± 2% -1.10% (p=0.038 n=8) geomean 6.942 6.902 -0.58% │ before.txt │ after.txt │ │ rss-B │ rss-B vs base │ Tsc/bluesky 1.190Gi ± 1% 1.182Gi ± 3% ~ (p=0.798 n=8) Tsc/sentry 3.438Gi ± 7% 3.518Gi ± 6% ~ (p=0.645 n=8) Tsc/t3code-server 2.852Gi ± 2% 2.879Gi ± 3% ~ (p=0.721 n=8) geomean 2.268Gi 2.287Gi +0.86% │ before.txt │ after.txt │ │ heap-B │ heap-B vs base │ Tsc/bluesky 934.9Mi ± 0% 934.8Mi ± 0% ~ (p=0.959 n=8) Tsc/sentry 2.607Gi ± 0% 2.609Gi ± 0% ~ (p=0.382 n=8) Tsc/t3code-server 2.303Gi ± 0% 2.302Gi ± 0% ~ (p=0.083 n=8) geomean 1.763Gi 1.763Gi -0.00% --- tsc/internal/checker/checker.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 10ddb3c0c88b4..d5e1f032ff59c 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -22624,16 +22624,15 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl return c.errorType } index := c.findActiveMapper(m) + var key CacheHashKey if index == -1 { + // A newly pushed mapper has an empty cache, and the result is not cached below. c.pushActiveMapper(m) - } - var b keyBuilder - b.writeType(t) - b.writeAlias(alias) - key := b.hash() - cache := c.activeTypeMappersCaches[core.IfElse(index != -1, index, len(c.activeTypeMappersCaches)-1)] - if cachedType, ok := cache[key]; ok { - return cachedType + } else { + key = getActiveMapperCacheKey(t, alias) + if cachedType, ok := c.activeTypeMappersCaches[index][key]; ok { + return cachedType + } } c.TotalInstantiationCount++ c.instantiationCount++ @@ -22642,13 +22641,25 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl if index == -1 { c.popActiveMapper() } else { - cache[key] = result + c.activeTypeMappersCaches[index][key] = result } c.instantiationStack[len(c.instantiationStack)-1] = nil c.instantiationStack = c.instantiationStack[:len(c.instantiationStack)-1] return result } +// Without an alias the key is just the type ID, which avoids hashing. Hashed keys have +// a nonzero high half with overwhelming probability, so the two kinds do not collide. +func getActiveMapperCacheKey(t *Type, alias *TypeAlias) CacheHashKey { + if alias == nil { + return CacheHashKey{Lo: uint64(t.id)} + } + var b keyBuilder + b.writeType(t) + b.writeAlias(alias) + return b.hash() +} + func (c *Checker) getCircularTypeNames() []string { typeCounts := make(map[*Type]int) var circularTypeNames []string From 55dce91171484a61c580a8c29c9c10fc014a0738 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:27:48 -0700 Subject: [PATCH 2/3] Clear active mapper caches in constant time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instantiation cache of an active mapper is cleared every time the mapper is popped. Clearing a Go map takes time proportional to its capacity, which stays large once a map has grown, so instantiating under a mapper whose cache once held many entries paid for that size on every pop. The caches are now small open-addressing tables whose entries record the generation they were written in; clearing starts a new generation. They also hash keys with a multiply rather than a full 128-bit hash. Measured with --checkers 4 (n=8): │ before.txt │ after.txt │ │ sec/op │ sec/op vs base │ Tsc/bluesky 2.716 ± 2% 2.708 ± 5% ~ (p=0.279 n=8) Tsc/sentry 11.14 ± 1% 11.01 ± 1% ~ (p=0.130 n=8) Tsc/t3code-server 9.997 ± 3% 9.462 ± 2% -5.35% (p=0.000 n=8) geomean 6.712 6.559 -2.29% │ before.txt │ after.txt │ │ rss-B │ rss-B vs base │ Tsc/bluesky 1.191Gi ± 1% 1.180Gi ± 1% -0.95% (p=0.028 n=8) Tsc/sentry 3.495Gi ± 5% 3.399Gi ± 7% ~ (p=0.878 n=8) Tsc/t3code-server 2.855Gi ± 5% 2.844Gi ± 3% ~ (p=0.798 n=8) geomean 2.282Gi 2.251Gi -1.36% │ before.txt │ after.txt │ │ heap-B │ heap-B vs base │ Tsc/bluesky 935.2Mi ± 0% 935.1Mi ± 0% ~ (p=0.279 n=8) Tsc/sentry 2.611Gi ± 0% 2.611Gi ± 0% ~ (p=0.798 n=8) Tsc/t3code-server 2.305Gi ± 0% 2.305Gi ± 0% ~ (p=0.505 n=8) geomean 1.765Gi 1.765Gi -0.00% --- tsc/internal/checker/checker.go | 21 +++---- tsc/internal/checker/clearablemap.go | 87 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 tsc/internal/checker/clearablemap.go diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index d5e1f032ff59c..5f650b74fa71b 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -920,7 +920,7 @@ type Checker struct { ctx context.Context packagesMap map[string]bool activeMappers []*TypeMapper - activeTypeMappersCaches []map[CacheHashKey]*Type + activeTypeMappersCaches []clearableMap[*Type] ambientModulesOnce sync.Once ambientModules []*ast.Symbol withinUnreachableCode bool @@ -22630,7 +22630,7 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl c.pushActiveMapper(m) } else { key = getActiveMapperCacheKey(t, alias) - if cachedType, ok := c.activeTypeMappersCaches[index][key]; ok { + if cachedType, ok := c.activeTypeMappersCaches[index].get(key); ok { return cachedType } } @@ -22641,7 +22641,7 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl if index == -1 { c.popActiveMapper() } else { - c.activeTypeMappersCaches[index][key] = result + c.activeTypeMappersCaches[index].set(key, result) } c.instantiationStack[len(c.instantiationStack)-1] = nil c.instantiationStack = c.instantiationStack[:len(c.instantiationStack)-1] @@ -22685,13 +22685,10 @@ func (c *Checker) pushActiveMapper(mapper *TypeMapper) { lastIndex := len(c.activeTypeMappersCaches) if cap(c.activeTypeMappersCaches) > lastIndex { - // The cap may contain an empty map from popActiveMapper; reuse it. + // The cap may contain an empty cache from popActiveMapper; reuse it. c.activeTypeMappersCaches = c.activeTypeMappersCaches[:lastIndex+1] - if c.activeTypeMappersCaches[lastIndex] == nil { - c.activeTypeMappersCaches[lastIndex] = make(map[CacheHashKey]*Type, 1) - } } else { - c.activeTypeMappersCaches = append(c.activeTypeMappersCaches, make(map[CacheHashKey]*Type, 1)) + c.activeTypeMappersCaches = append(c.activeTypeMappersCaches, clearableMap[*Type]{}) } } @@ -22699,9 +22696,9 @@ func (c *Checker) popActiveMapper() { c.activeMappers[len(c.activeMappers)-1] = nil c.activeMappers = c.activeMappers[:len(c.activeMappers)-1] - // Clear the map, but leave it in the list for later reuse. + // Clear the cache, but leave it in the list for later reuse. lastIndex := len(c.activeTypeMappersCaches) - 1 - clear(c.activeTypeMappersCaches[lastIndex]) + c.activeTypeMappersCaches[lastIndex].clear() c.activeTypeMappersCaches = c.activeTypeMappersCaches[:lastIndex] } @@ -22710,8 +22707,8 @@ func (c *Checker) findActiveMapper(mapper *TypeMapper) int { } func (c *Checker) clearActiveMapperCaches() { - for _, cache := range c.activeTypeMappersCaches { - clear(cache) + for i := range c.activeTypeMappersCaches { + c.activeTypeMappersCaches[i].clear() } } diff --git a/tsc/internal/checker/clearablemap.go b/tsc/internal/checker/clearablemap.go new file mode 100644 index 0000000000000..08564219e70b7 --- /dev/null +++ b/tsc/internal/checker/clearablemap.go @@ -0,0 +1,87 @@ +package checker + +import "math/bits" + +// clearableMap is a hash table keyed by 128-bit keys that can be cleared in constant time. It +// is used for short-lived caches that are cleared and reused many times: a Go map takes time +// proportional to its capacity to clear, which stays large once it has grown. Here each entry +// records the generation it was written in, and clearing starts a new one. +type clearableMap[V any] struct { + entries []clearableMapEntry[V] + generation uint32 + count int + shift uint8 +} + +type clearableMapEntry[V any] struct { + key CacheHashKey + value V + generation uint32 +} + +func (m *clearableMap[V]) slot(key CacheHashKey) int { + return int(((key.Lo ^ key.Hi) * 0x9e3779b97f4a7c15) >> m.shift) +} + +func (m *clearableMap[V]) get(key CacheHashKey) (V, bool) { + var zero V + if m.count == 0 { + return zero, false + } + mask := len(m.entries) - 1 + for i := m.slot(key); ; i = (i + 1) & mask { + e := &m.entries[i] + if e.generation != m.generation { + return zero, false + } + if e.key == key { + return e.value, true + } + } +} + +func (m *clearableMap[V]) set(key CacheHashKey, value V) { + if (m.count+1)*4 > len(m.entries)*3 { + m.grow() + } + mask := len(m.entries) - 1 + for i := m.slot(key); ; i = (i + 1) & mask { + e := &m.entries[i] + if e.generation != m.generation { + *e = clearableMapEntry[V]{key: key, value: value, generation: m.generation} + m.count++ + return + } + if e.key == key { + e.value = value + return + } + } +} + +func (m *clearableMap[V]) grow() { + old := m.entries + oldGeneration := m.generation + size := max(len(old)*2, 8) + m.entries = make([]clearableMapEntry[V], size) + m.shift = uint8(64 - bits.TrailingZeros(uint(size))) + m.generation = 1 + m.count = 0 + for i := range old { + if e := &old[i]; e.generation == oldGeneration { + m.set(e.key, e.value) + } + } +} + +func (m *clearableMap[V]) clear() { + if m.count == 0 { + return + } + m.count = 0 + m.generation++ + if m.generation == 0 { + clear(m.entries) + m.generation = 1 + } +} From c989ed8fa85bd9d0c394707b02fb647317a156e0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:44:36 -0700 Subject: [PATCH 3/3] Clear inference visited sets in constant time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inference states are pooled, and each one's visited map is cleared when it is returned to the pool. Like the mapper caches, a pooled map that once grew large paid for its capacity on every clear, and every probe paid for hashing a struct key. Measured with --checkers 4: │ before.txt │ after.txt │ │ sec/op │ sec/op vs base │ Tsc/bluesky 2.747 ± 2% 2.725 ± 3% ~ (p=0.645 n=8) Tsc/sentry 11.20 ± 2% 11.01 ± 2% ~ (p=0.239 n=16) Tsc/t3code-server 9.504 ± 1% 9.287 ± 1% -2.28% (p=0.000 n=16) geomean 6.637 6.532 -1.58% │ before.txt │ after.txt │ │ rss-B │ rss-B vs base │ Tsc/bluesky 1.190Gi ± 2% 1.183Gi ± 1% ~ (p=0.382 n=8) Tsc/sentry 3.473Gi ± 5% 3.366Gi ± 3% ~ (p=0.171 n=16) Tsc/t3code-server 2.915Gi ± 3% 2.839Gi ± 3% ~ (p=0.515 n=16) geomean 2.293Gi 2.244Gi -2.11% │ before.txt │ after.txt │ │ heap-B │ heap-B vs base │ Tsc/bluesky 935.3Mi ± 0% 935.3Mi ± 0% ~ (p=0.959 n=8) Tsc/sentry 2.611Gi ± 0% 2.612Gi ± 0% ~ (p=0.128 n=16) Tsc/t3code-server 2.305Gi ± 0% 2.306Gi ± 0% ~ (p=0.323 n=16) geomean 1.765Gi 1.765Gi +0.02% --- tsc/internal/checker/inference.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tsc/internal/checker/inference.go b/tsc/internal/checker/inference.go index 02c7d009b9ec5..9fadf41334ef7 100644 --- a/tsc/internal/checker/inference.go +++ b/tsc/internal/checker/inference.go @@ -26,7 +26,7 @@ type InferenceState struct { bivariant bool expandingFlags ExpandingFlags propagationType *Type - visited map[InferenceKey]InferencePriority + visited clearableMap[InferencePriority] sourceStack []*Type targetStack []*Type next *InferenceState @@ -42,7 +42,7 @@ func (c *Checker) getInferenceState() *InferenceState { } func (c *Checker) putInferenceState(n *InferenceState) { - clear(n.visited) + n.visited.clear() *n = InferenceState{ inferences: n.inferences[:0], visited: n.visited, @@ -344,15 +344,15 @@ func (c *Checker) inferFromContravariantTypesIfStrictFunctionTypes(n *InferenceS // such that we would go on inferring forever, even though we would never infer // between the same pair of types. func (c *Checker) invokeOnce(n *InferenceState, source *Type, target *Type, action func(c *Checker, n *InferenceState, source *Type, target *Type)) { - key := InferenceKey{source: source.id, target: target.id, priority: n.priority, contravariant: n.contravariant, bivariant: n.bivariant} - if status, ok := n.visited[key]; ok { + key := CacheHashKey{ + Lo: uint64(source.id)<<32 | uint64(target.id), + Hi: uint64(uint32(n.priority))<<2 | uint64(core.IfElse(n.contravariant, 2, 0)) | uint64(core.IfElse(n.bivariant, 1, 0)), + } + if status, ok := n.visited.get(key); ok { n.inferencePriority = min(n.inferencePriority, status) return } - if n.visited == nil { - n.visited = make(map[InferenceKey]InferencePriority) - } - n.visited[key] = InferencePriorityCircularity + n.visited.set(key, InferencePriorityCircularity) saveInferencePriority := n.inferencePriority n.inferencePriority = InferencePriorityMaxValue // We stop inferring and report a circularity if we encounter duplicate recursion identities on both @@ -374,7 +374,7 @@ func (c *Checker) invokeOnce(n *InferenceState, source *Type, target *Type, acti n.targetStack = n.targetStack[:len(n.targetStack)-1] n.sourceStack = n.sourceStack[:len(n.sourceStack)-1] n.expandingFlags = saveExpandingFlags - n.visited[key] = n.inferencePriority + n.visited.set(key, n.inferencePriority) n.inferencePriority = min(n.inferencePriority, saveInferencePriority) }