From 161e925ce483ac764376a659a3bc8c46ee72b0b5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:23:09 -0700 Subject: [PATCH] Presize checker type-list cache keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large type lists repeatedly grow temporary hash buffers even though their encoded length is already known. These discarded allocations account for a substantial part of checker allocation traffic. Reserve enough capacity before writing each list, retaining the small inline path and the exact existing hash byte stream. Allocation tests cover the inline boundary and large lists, and a fresh-program benchmark includes parsing and binding rather than relying only on warmed ASTs. CPU wall timings are noisy on this machine; report bytes and allocation counts instead. goos: linux goarch: amd64 pkg: github.com/microsoft/TypeScript/tsc/internal/checker cpu: Intel(R) Core(TM) i9-10900K CPU @ 3.70GHz │ key-before.txt │ key-reserve.txt │ │ B/op │ B/op vs base │ TypeListKey/8-20 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ TypeListKey/46-20 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ TypeListKey/47-20 576.0 ± 0% 208.0 ± 0% -63.89% (p=0.002 n=6) TypeListKey/256-20 2.375Ki ± 0% 1.125Ki ± 0% -52.63% (p=0.002 n=6) TypeListKey/4096-20 81.25Ki ± 0% 18.00Ki ± 0% -77.85% (p=0.002 n=6) geomean ² -48.03% ² ¹ all samples are equal ² summaries must be >0 to compute geomean │ key-before.txt │ key-reserve.txt │ │ allocs/op │ allocs/op vs base │ TypeListKey/8-20 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ TypeListKey/46-20 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ TypeListKey/47-20 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.002 n=6) TypeListKey/256-20 4.000 ± 0% 1.000 ± 0% -75.00% (p=0.002 n=6) TypeListKey/4096-20 13.000 ± 0% 1.000 ± 0% -92.31% (p=0.002 n=6) geomean ² -60.50% ² ¹ all samples are equal ² summaries must be >0 to compute geomean --- tsc/internal/checker/checker.go | 7 +++ tsc/internal/checker/checker_test.go | 20 +++++++++ tsc/internal/checker/types_test.go | 67 ++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 143d1b4b5596b..8dd981cafba08 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -17806,6 +17806,12 @@ func (b *keyBuilder) spill() { b.inlineLength = 0 } +func (b *keyBuilder) reserve(size int) { + if b.overflowBuffer != nil || b.inlineLength+size > len(b.inlineBuffer) { + b.overflowBuffer = slices.Grow(b.overflowBuffer, b.inlineLength+size) + } +} + func (b *keyBuilder) writeByte(c byte) { if b.inlineLength == len(b.inlineBuffer) { b.spill() @@ -17854,6 +17860,7 @@ func (b *keyBuilder) writeType(t *Type) { } func (b *keyBuilder) writeTypes(types []*Type) { + b.reserve(8 + 4*len(types)) b.writeInt(len(types)) for _, t := range types { b.writeType(t) diff --git a/tsc/internal/checker/checker_test.go b/tsc/internal/checker/checker_test.go index 0ca836c03b1ea..31cea19751525 100644 --- a/tsc/internal/checker/checker_test.go +++ b/tsc/internal/checker/checker_test.go @@ -157,6 +157,26 @@ func BenchmarkCheck(b *testing.B) { } } +func BenchmarkCheckFreshProgram(b *testing.B) { + fs := bundled.WrapFS(osvfs.FS()) + rootPath := tspath.RootedDirectoryPathFromAbsolute(filepath.Join(repo.TestDataPath(), "fixtures/compiler")) + host := compiler.NewCompilerHost(fs, bundled.LibPath(), nil, nil, nil) + parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile(rootPath.ResolveFile("tsconfig.json"), &core.CompilerOptions{}, nil, fs, nil) + assert.Equal(b, len(errors), 0, "Expected no errors in parsed command line") + ctx := b.Context() + + b.ReportAllocs() + for b.Loop() { + program := compiler.NewProgram(compiler.ProgramOptions{Config: parsed, Host: host}) + program.BindSourceFiles() + c, _ := checker.NewChecker(program, nil) + for _, file := range program.GetSourceFiles() { + assert.Equal(b, len(c.GetDiagnostics(ctx, file)), 0) + } + assert.Equal(b, len(c.GetGlobalDiagnostics()), 0) + } +} + func TestTypeAllocationAcrossCheckerGrowth(t *testing.T) { t.Parallel() var content strings.Builder diff --git a/tsc/internal/checker/types_test.go b/tsc/internal/checker/types_test.go index 8b21b07dc8160..a914dd424f8ea 100644 --- a/tsc/internal/checker/types_test.go +++ b/tsc/internal/checker/types_test.go @@ -1,10 +1,15 @@ package checker import ( + "encoding/binary" "runtime" + "strconv" + "strings" "testing" "unsafe" + "github.com/microsoft/TypeScript/tsc/internal/testutil/race" + "github.com/zeebo/xxh3" "gotest.tools/v3/assert" ) @@ -73,3 +78,65 @@ func TestTypeHeaderSize(t *testing.T) { t.Parallel() assert.Equal(t, unsafe.Sizeof(Type{}), 16+3*unsafe.Sizeof((*Checker)(nil))) } + +func TestKeyBuilder(t *testing.T) { + t.Parallel() + for _, count := range []int{0, 1, 46, 47, 256, 4096} { + t.Run(strconv.Itoa(count), func(t *testing.T) { + t.Parallel() + types := make([]*Type, count) + var expected []byte + prefix := strings.Repeat("x", count) + expected = append(expected, prefix...) + expected = append(expected, 17) + expected = binary.LittleEndian.AppendUint32(expected, 123) + expected = binary.LittleEndian.AppendUint64(expected, uint64(count)) + for i := range types { + types[i] = &Type{id: TypeId(i + 1)} + expected = binary.LittleEndian.AppendUint32(expected, uint32(i+1)) + } + expected = append(expected, "suffix"...) + var builder keyBuilder + builder.writeString(prefix) + builder.writeByte(17) + builder.writeUint32(123) + builder.writeTypes(types) + builder.writeString("suffix") + assert.Equal(t, builder.hash(), CacheHashKey(xxh3.Hash128(expected))) + assert.Equal(t, builder.hash(), CacheHashKey(xxh3.Hash128(expected))) + }) + } +} + +func TestTypeListKeyAllocations(t *testing.T) { //nolint:paralleltest + if race.Enabled { + t.Skip("race instrumentation changes hashing allocations") + } + for _, count := range []int{8, 46, 47, 256, 4096} { + types := make([]*Type, count) + for i := range types { + types[i] = &Type{id: TypeId(i + 1)} + } + allocations := testing.AllocsPerRun(10, func() { getTypeListKey(types) }) + expected := float64(0) + if count > 46 { + expected = 1 + } + assert.Equal(t, allocations, expected, "type list length %d", count) + } +} + +func BenchmarkTypeListKey(b *testing.B) { + for _, count := range []int{8, 46, 47, 256, 4096} { + b.Run(strconv.Itoa(count), func(b *testing.B) { + types := make([]*Type, count) + for i := range types { + types[i] = &Type{id: TypeId(i + 1)} + } + b.ReportAllocs() + for b.Loop() { + getTypeListKey(types) + } + }) + } +}