From 2d60e09ea6fd9611eeb3607e7209829b5b07893d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:43:17 -0700 Subject: [PATCH 01/12] Assign checkers with balanced import-graph locality The checker pool previously assigned files to checkers with simple round-robin striping. That balances file count, but adjacent files and resolved import neighbors often share generic instantiations and symbol/type links, so the same work is repeated across checker-local caches. Start with deterministic weighted blocks to preserve program-order locality, then run a small FM-style graph refinement over the resolved import graph. A file can move toward the checker containing more of its import neighbors only when the target remains under a strict static cost cap, keeping parallel work balanced. Perf on ~/work/vscode/src with tsgo -p . --noEmit --extendedDiagnostics, 5-run average versus main: Memory allocs 25,210,536 -> 24,028,267 (-1,182,268, -4.7%). Memory used 4,473,325K -> 4,242,826K (-230,500K, -5.2%). Check time 4.741s -> 4.294s (-0.447s, -9.4%). Total time 5.370s -> 4.969s (-0.401s, -7.5%). --- internal/compiler/checkerpool.go | 117 +++++++++++++++++++++++- internal/compiler/checkerpool_test.go | 125 ++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 internal/compiler/checkerpool_test.go diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 55158370765..4ea4ce80948 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -33,6 +33,90 @@ type checkerPool struct { var _ CheckerPool = (*checkerPool)(nil) +// Process small contiguous blocks of files on the same checker before rotating +// to the next checker. Adjacent files tend to share generic instantiations and +// symbol/type links; assigning blocks to the least-loaded checker by text size +// preserves that locality while keeping checker work balanced. +const maxCheckerAssociationBlockSize = 32 + +func getCheckerAssociationBlockSize(fileCount int, checkerCount int) int { + const targetBlocksPerChecker = 4 + if checkerCount <= 1 { + return maxCheckerAssociationBlockSize + } + return min(max(fileCount/(checkerCount*targetBlocksPerChecker), 1), maxCheckerAssociationBlockSize) +} + +func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) []int { + if len(fileWeights) == 0 { + return nil + } + blockSize := getCheckerAssociationBlockSize(len(fileWeights), checkerCount) + associations := make([]int, len(fileWeights)) + checkerWeights := make([]int, checkerCount) + for blockStart := 0; blockStart < len(fileWeights); blockStart += blockSize { + checkerIndex := 0 + for i, weight := range checkerWeights[1:] { + if weight < checkerWeights[checkerIndex] { + checkerIndex = i + 1 + } + } + blockEnd := min(blockStart+blockSize, len(fileWeights)) + for i := blockStart; i < blockEnd; i++ { + associations[i] = checkerIndex + checkerWeights[checkerIndex] += fileWeights[i] + } + } + return associations +} + +func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adjacentFiles [][]int, checkerCount int) { + if len(associations) == 0 || checkerCount <= 1 { + return + } + checkerWeights := make([]int, checkerCount) + totalWeight := 0 + maxFileWeight := 0 + for i, checkerIndex := range associations { + checkerWeights[checkerIndex] += fileWeights[i] + totalWeight += fileWeights[i] + maxFileWeight = max(maxFileWeight, fileWeights[i]) + } + averageCheckerWeight := (totalWeight + checkerCount - 1) / checkerCount + maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/50) + neighborCounts := make([]int, checkerCount) + for range 2 { + moved := false + for fileIndex, currentChecker := range associations { + clear(neighborCounts) + for _, adjacentFile := range adjacentFiles[fileIndex] { + neighborCounts[associations[adjacentFile]]++ + } + bestChecker := currentChecker + bestGain := 0 + for candidate := range checkerCount { + if candidate == currentChecker || checkerWeights[candidate]+fileWeights[fileIndex] > maxCheckerWeight { + continue + } + gain := neighborCounts[candidate] - neighborCounts[currentChecker] + if gain > bestGain || gain == bestGain && gain > 0 && checkerWeights[candidate] < checkerWeights[bestChecker] { + bestChecker = candidate + bestGain = gain + } + } + if bestChecker != currentChecker { + associations[fileIndex] = bestChecker + checkerWeights[currentChecker] -= fileWeights[fileIndex] + checkerWeights[bestChecker] += fileWeights[fileIndex] + moved = true + } + } + if !moved { + break + } + } +} + func newCheckerPool(program *Program) *checkerPool { return newCheckerPoolWithTracing(program, nil) } @@ -111,13 +195,44 @@ func (p *checkerPool) createCheckers() { wg.RunAndWait() + fileWeights := make([]int, len(p.program.files)) + for i, file := range p.program.files { + fileWeights[i] = len(file.Text()) + 3*file.NodeCount + 90*file.SymbolCount + } + associations := getCheckerAssociationsForFileWeights(fileWeights, checkerCount) + adjacentFiles := p.getImportAdjacency() + refineCheckerAssociationsByGraph(associations, fileWeights, adjacentFiles, checkerCount) p.fileAssociations = make(map[*ast.SourceFile]*checker.Checker, len(p.program.files)) for i, file := range p.program.files { - p.fileAssociations[file] = p.checkers[i%checkerCount] + p.fileAssociations[file] = p.checkers[associations[i]] } }) } +func (p *checkerPool) getImportAdjacency() [][]int { + fileIndices := make(map[*ast.SourceFile]int, len(p.program.files)) + for i, file := range p.program.files { + fileIndices[file] = i + } + adjacentFiles := make([][]int, len(p.program.files)) + for fileIndex, file := range p.program.files { + resolvedModules := p.program.resolvedModules[file.Path()] + for _, resolved := range resolvedModules { + if resolved == nil || !resolved.IsResolved() { + continue + } + importedFile := p.program.GetSourceFileForResolvedModule(resolved.ResolvedFileName) + importedIndex, ok := fileIndices[importedFile] + if !ok || importedIndex == fileIndex { + continue + } + adjacentFiles[fileIndex] = append(adjacentFiles[fileIndex], importedIndex) + adjacentFiles[importedIndex] = append(adjacentFiles[importedIndex], fileIndex) + } + } + return adjacentFiles +} + // Runs `cb` for each checker in the pool concurrently, locking and unlocking checker mutexes as it goes, // making it safe to call `forEachCheckerParallel` from many threads simultaneously. func (p *checkerPool) forEachCheckerParallel(cb func(idx int, c *checker.Checker)) { diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go new file mode 100644 index 00000000000..105c3373134 --- /dev/null +++ b/internal/compiler/checkerpool_test.go @@ -0,0 +1,125 @@ +package compiler + +import "testing" + +func TestGetCheckerAssociationBlockSize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileCount int + checkerCount int + want int + }{ + {name: "single checker uses max block", fileCount: 100, checkerCount: 1, want: maxCheckerAssociationBlockSize}, + {name: "small project balances across checkers", fileCount: 16, checkerCount: 4, want: 1}, + {name: "medium project uses smaller locality blocks", fileCount: 128, checkerCount: 4, want: 8}, + {name: "large project caps block size", fileCount: 2000, checkerCount: 4, want: maxCheckerAssociationBlockSize}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + if got := getCheckerAssociationBlockSize(test.fileCount, test.checkerCount); got != test.want { + t.Fatalf("getCheckerAssociationBlockSize(%d, %d) = %d, want %d", test.fileCount, test.checkerCount, got, test.want) + } + }) + } +} + +func TestGetCheckerAssociationsForFileWeights(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileWeights []int + checkerCount int + want []int + }{ + { + name: "small project cycles each file", + fileWeights: []int{1, 1, 1, 1}, + checkerCount: 4, + want: []int{0, 1, 2, 3}, + }, + { + name: "large first file sends following files to lighter checker", + fileWeights: []int{100, 1, 1, 1, 1}, + checkerCount: 2, + want: []int{0, 1, 1, 1, 1}, + }, + { + name: "medium project keeps contiguous locality blocks", + fileWeights: []int{ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + }, + checkerCount: 4, + want: []int{ + 0, 0, 1, 1, 2, 2, 3, 3, + 0, 0, 1, 1, 2, 2, 3, 3, + 0, 0, 1, 1, 2, 2, 3, 3, + 0, 0, 1, 1, 2, 2, 3, 3, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := getCheckerAssociationsForFileWeights(test.fileWeights, test.checkerCount) + if len(got) != len(test.want) { + t.Fatalf("len(getCheckerAssociationsForFileWeights(%v, %d)) = %d, want %d", test.fileWeights, test.checkerCount, len(got), len(test.want)) + } + for i := range got { + if got[i] != test.want[i] { + t.Fatalf("getCheckerAssociationsForFileWeights(%v, %d)[%d] = %d, want %d", test.fileWeights, test.checkerCount, i, got[i], test.want[i]) + } + } + }) + } +} + +func TestRefineCheckerAssociationsByGraph(t *testing.T) { + t.Parallel() + + t.Run("moves file to import-neighbor checker within balance cap", func(t *testing.T) { + t.Parallel() + + associations := []int{0, 0, 0, 1, 1} + refineCheckerAssociationsByGraph( + associations, + []int{1, 1, 1, 1, 1}, + [][]int{{3, 4}, nil, nil, {0}, {0}}, + 2, + ) + want := []int{1, 0, 0, 1, 1} + for i := range want { + if associations[i] != want[i] { + t.Fatalf("associations[%d] = %d, want %d; associations = %v", i, associations[i], want[i], associations) + } + } + }) + + t.Run("does not move file past balance cap", func(t *testing.T) { + t.Parallel() + + associations := []int{0, 0, 1, 1} + refineCheckerAssociationsByGraph( + associations, + []int{1, 1, 1, 1}, + [][]int{{2, 3}, nil, {0}, {0}}, + 2, + ) + want := []int{0, 0, 1, 1} + for i := range want { + if associations[i] != want[i] { + t.Fatalf("associations[%d] = %d, want %d; associations = %v", i, associations[i], want[i], associations) + } + } + }) +} From 98bcdd4f921cc275d54a367767e398940fbb8a85 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:03:24 -0700 Subject: [PATCH 02/12] Document checker association algorithms --- internal/compiler/checkerpool.go | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 4ea4ce80948..a81562239be 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -35,10 +35,15 @@ var _ CheckerPool = (*checkerPool)(nil) // Process small contiguous blocks of files on the same checker before rotating // to the next checker. Adjacent files tend to share generic instantiations and -// symbol/type links; assigning blocks to the least-loaded checker by text size -// preserves that locality while keeping checker work balanced. +// symbol/type links; assigning blocks to the least-loaded checker by estimated +// file weight preserves that locality while keeping checker work balanced. const maxCheckerAssociationBlockSize = 32 +// getCheckerAssociationBlockSize chooses how many adjacent files to keep together +// in the initial assignment pass. Very small projects use block size 1 so an +// explicit --checkers value can still spread work across checkers; larger projects +// use bigger blocks for locality, capped to avoid one checker receiving too large +// a contiguous slice before the load balancer can rotate to another checker. func getCheckerAssociationBlockSize(fileCount int, checkerCount int) int { const targetBlocksPerChecker = 4 if checkerCount <= 1 { @@ -47,6 +52,13 @@ func getCheckerAssociationBlockSize(fileCount int, checkerCount int) int { return min(max(fileCount/(checkerCount*targetBlocksPerChecker), 1), maxCheckerAssociationBlockSize) } +// getCheckerAssociationsForFileWeights builds the initial mapping from file index +// to checker index. It walks files in program order, assigns one contiguous block +// at a time to the currently least-loaded checker, then charges that checker for +// the block's total file weight. This is a greedy balance between preserving +// program-order locality within each block and distributing estimated checker work. +// It is a list-scheduling-style assignment for identical machines, applied to +// blocks of files rather than individual jobs; see https://en.wikipedia.org/wiki/List_scheduling. func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) []int { if len(fileWeights) == 0 { return nil @@ -55,6 +67,8 @@ func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) [ associations := make([]int, len(fileWeights)) checkerWeights := make([]int, checkerCount) for blockStart := 0; blockStart < len(fileWeights); blockStart += blockSize { + // Pick a checker before each block so a large earlier block makes the + // following blocks prefer other, lighter checkers. checkerIndex := 0 for i, weight := range checkerWeights[1:] { if weight < checkerWeights[checkerIndex] { @@ -62,6 +76,8 @@ func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) [ } } blockEnd := min(blockStart+blockSize, len(fileWeights)) + // Keep the whole block on the selected checker to retain locality among + // nearby files, while adding each file's weight to future load decisions. for i := blockStart; i < blockEnd; i++ { associations[i] = checkerIndex checkerWeights[checkerIndex] += fileWeights[i] @@ -70,6 +86,17 @@ func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) [ return associations } +// refineCheckerAssociationsByGraph nudges the initial file-index-to-checker-index +// mapping toward the import graph. For each file, it counts which checkers own the +// file's import neighbors and moves the file to the checker with the largest net +// neighbor gain, as long as the move stays within a small load-balance cap. This +// favors sharing cached checker state among related files without letting dense +// import clusters undo the weight balancing from the initial pass. +// This is a deliberately small one-vertex local-search refinement, similar in +// spirit to balanced graph-partitioning heuristics like Kernighan-Lin and +// Fiduccia-Mattheyses, but without their heavier gain queues or swap sequences; +// see https://en.wikipedia.org/wiki/Kernighan%E2%80%93Lin_algorithm and +// https://en.wikipedia.org/wiki/Fiduccia%E2%80%93Mattheyses_algorithm. func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adjacentFiles [][]int, checkerCount int) { if len(associations) == 0 || checkerCount <= 1 { return @@ -77,6 +104,8 @@ func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adj checkerWeights := make([]int, checkerCount) totalWeight := 0 maxFileWeight := 0 + // Reconstruct checker loads from the current mapping, and remember the + // largest single file because any legal cap must be able to fit it. for i, checkerIndex := range associations { checkerWeights[checkerIndex] += fileWeights[i] totalWeight += fileWeights[i] @@ -85,9 +114,14 @@ func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adj averageCheckerWeight := (totalWeight + checkerCount - 1) / checkerCount maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/50) neighborCounts := make([]int, checkerCount) + // Make a bounded number of greedy passes. Later moves can create better + // placements for files already visited, but this should remain a cheap, + // predictable refinement rather than an expensive graph partitioner. for range 2 { moved := false for fileIndex, currentChecker := range associations { + // Count how many import neighbors of this file are currently assigned + // to each checker. clear(neighborCounts) for _, adjacentFile := range adjacentFiles[fileIndex] { neighborCounts[associations[adjacentFile]]++ @@ -99,6 +133,9 @@ func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adj continue } gain := neighborCounts[candidate] - neighborCounts[currentChecker] + // Prefer a checker that gains more colocated import neighbors. If + // the gain is tied, prefer the lighter checker so equal-quality + // graph moves still improve balance. if gain > bestGain || gain == bestGain && gain > 0 && checkerWeights[candidate] < checkerWeights[bestChecker] { bestChecker = candidate bestGain = gain @@ -195,10 +232,16 @@ func (p *checkerPool) createCheckers() { wg.RunAndWait() + // Approximate per-file checker work. Text length captures input size, + // while node and file-local symbol counts approximate binder and checker + // graph size. fileWeights := make([]int, len(p.program.files)) for i, file := range p.program.files { fileWeights[i] = len(file.Text()) + 3*file.NodeCount + 90*file.SymbolCount } + // The association algorithm uses p.program.files indices throughout: + // start with a weight-balanced locality pass, then refine that mapping + // using import adjacency. associations := getCheckerAssociationsForFileWeights(fileWeights, checkerCount) adjacentFiles := p.getImportAdjacency() refineCheckerAssociationsByGraph(associations, fileWeights, adjacentFiles, checkerCount) @@ -209,6 +252,9 @@ func (p *checkerPool) createCheckers() { }) } +// getImportAdjacency returns an undirected import graph represented by file +// index. A directed import from A to B makes both files adjacent because either +// file can benefit from sharing checker caches with the other. func (p *checkerPool) getImportAdjacency() [][]int { fileIndices := make(map[*ast.SourceFile]int, len(p.program.files)) for i, file := range p.program.files { From 2eaf8acd0180e63be2bf045455e9b0f3fb962d4c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:58:32 -0700 Subject: [PATCH 03/12] Simplify checker file weights --- internal/compiler/checkerpool.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index a81562239be..d8a957156d0 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -35,8 +35,8 @@ var _ CheckerPool = (*checkerPool)(nil) // Process small contiguous blocks of files on the same checker before rotating // to the next checker. Adjacent files tend to share generic instantiations and -// symbol/type links; assigning blocks to the least-loaded checker by estimated -// file weight preserves that locality while keeping checker work balanced. +// symbol/type links; assigning blocks to the least-loaded checker by text size +// preserves that locality while keeping checker work balanced. const maxCheckerAssociationBlockSize = 32 // getCheckerAssociationBlockSize chooses how many adjacent files to keep together @@ -232,19 +232,18 @@ func (p *checkerPool) createCheckers() { wg.RunAndWait() - // Approximate per-file checker work. Text length captures input size, - // while node and file-local symbol counts approximate binder and checker - // graph size. fileWeights := make([]int, len(p.program.files)) for i, file := range p.program.files { - fileWeights[i] = len(file.Text()) + 3*file.NodeCount + 90*file.SymbolCount + fileWeights[i] = len(file.Text()) } // The association algorithm uses p.program.files indices throughout: // start with a weight-balanced locality pass, then refine that mapping // using import adjacency. associations := getCheckerAssociationsForFileWeights(fileWeights, checkerCount) - adjacentFiles := p.getImportAdjacency() - refineCheckerAssociationsByGraph(associations, fileWeights, adjacentFiles, checkerCount) + if checkerCount > 1 { + adjacentFiles := p.getImportAdjacency() + refineCheckerAssociationsByGraph(associations, fileWeights, adjacentFiles, checkerCount) + } p.fileAssociations = make(map[*ast.SourceFile]*checker.Checker, len(p.program.files)) for i, file := range p.program.files { p.fileAssociations[file] = p.checkers[associations[i]] From d3c9d0a7f226be542fbefc659128bcd7e7ee0040 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:37:28 -0700 Subject: [PATCH 04/12] Use LPT for checker file assignment Assign files to checkers with longest-processing-time-first scheduling before running the import-graph refinement pass. The estimated work for a file is now its node count plus one unit per 100 bytes of source text, which better tracks checker work than source text size alone. Measured on ~/work/vscode/src with tsgo -p . --noEmit --extendedDiagnostics, 5-run average versus the current text+graph assignment: - current text+graph: Check time 9.168s, Total time 10.298s, Symbols 6,407,330, Types 2,217,598, Memory 4,394,421K, Allocs 24,807,475. - pre-text old-weight+graph: Check time 8.740s (-4.7%), Total time 10.028s (-2.6%), Symbols -0.5%, Types -0.8%, Memory -0.5%, Allocs -0.2%. - LPT node+text+graph: Check time 8.457s (-7.8%), Total time 9.587s (-6.9%), Symbols +0.9%, Types +1.2%, Memory +0.5%, Allocs +0.7%. - LPT old-weight+graph: Check time 8.485s (-7.5%), Total time 9.632s (-6.5%), Symbols ~, Types +0.3%, Memory -0.2%, Allocs +0.2%. The import graph remains useful: removing graph refinement from the LPT variants preserved some check-time improvement but increased symbols, types, memory, and allocations by roughly 4-8% on this benchmark. --- internal/compiler/checkerpool.go | 61 ++++++++++----------------- internal/compiler/checkerpool_test.go | 54 +++++++----------------- 2 files changed, 38 insertions(+), 77 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index d8a957156d0..967cbc22017 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -3,6 +3,7 @@ package compiler import ( "context" "slices" + "sort" "sync" "github.com/microsoft/typescript-go/internal/ast" @@ -33,55 +34,40 @@ type checkerPool struct { var _ CheckerPool = (*checkerPool)(nil) -// Process small contiguous blocks of files on the same checker before rotating -// to the next checker. Adjacent files tend to share generic instantiations and -// symbol/type links; assigning blocks to the least-loaded checker by text size -// preserves that locality while keeping checker work balanced. -const maxCheckerAssociationBlockSize = 32 - -// getCheckerAssociationBlockSize chooses how many adjacent files to keep together -// in the initial assignment pass. Very small projects use block size 1 so an -// explicit --checkers value can still spread work across checkers; larger projects -// use bigger blocks for locality, capped to avoid one checker receiving too large -// a contiguous slice before the load balancer can rotate to another checker. -func getCheckerAssociationBlockSize(fileCount int, checkerCount int) int { - const targetBlocksPerChecker = 4 - if checkerCount <= 1 { - return maxCheckerAssociationBlockSize - } - return min(max(fileCount/(checkerCount*targetBlocksPerChecker), 1), maxCheckerAssociationBlockSize) -} +const checkerAssociationTextWeightDivisor = 100 // getCheckerAssociationsForFileWeights builds the initial mapping from file index -// to checker index. It walks files in program order, assigns one contiguous block -// at a time to the currently least-loaded checker, then charges that checker for -// the block's total file weight. This is a greedy balance between preserving -// program-order locality within each block and distributing estimated checker work. -// It is a list-scheduling-style assignment for identical machines, applied to -// blocks of files rather than individual jobs; see https://en.wikipedia.org/wiki/List_scheduling. +// to checker index using longest-processing-time-first scheduling. Files with +// the largest estimated checker work are assigned first to the least-loaded +// checker, minimizing the slowest checker bucket before graph refinement nudges +// the mapping toward import locality. func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) []int { if len(fileWeights) == 0 { return nil } - blockSize := getCheckerAssociationBlockSize(len(fileWeights), checkerCount) associations := make([]int, len(fileWeights)) checkerWeights := make([]int, checkerCount) - for blockStart := 0; blockStart < len(fileWeights); blockStart += blockSize { - // Pick a checker before each block so a large earlier block makes the - // following blocks prefer other, lighter checkers. + fileIndices := make([]int, len(fileWeights)) + for i := range fileIndices { + fileIndices[i] = i + } + sort.Slice(fileIndices, func(i, j int) bool { + left := fileIndices[i] + right := fileIndices[j] + if fileWeights[left] != fileWeights[right] { + return fileWeights[left] > fileWeights[right] + } + return left < right + }) + for _, fileIndex := range fileIndices { checkerIndex := 0 for i, weight := range checkerWeights[1:] { if weight < checkerWeights[checkerIndex] { checkerIndex = i + 1 } } - blockEnd := min(blockStart+blockSize, len(fileWeights)) - // Keep the whole block on the selected checker to retain locality among - // nearby files, while adding each file's weight to future load decisions. - for i := blockStart; i < blockEnd; i++ { - associations[i] = checkerIndex - checkerWeights[checkerIndex] += fileWeights[i] - } + associations[fileIndex] = checkerIndex + checkerWeights[checkerIndex] += fileWeights[fileIndex] } return associations } @@ -234,11 +220,10 @@ func (p *checkerPool) createCheckers() { fileWeights := make([]int, len(p.program.files)) for i, file := range p.program.files { - fileWeights[i] = len(file.Text()) + fileWeights[i] = max(file.NodeCount+len(file.Text())/checkerAssociationTextWeightDivisor, 1) } // The association algorithm uses p.program.files indices throughout: - // start with a weight-balanced locality pass, then refine that mapping - // using import adjacency. + // start with a work-balanced pass, then refine that mapping using import adjacency. associations := getCheckerAssociationsForFileWeights(fileWeights, checkerCount) if checkerCount > 1 { adjacentFiles := p.getImportAdjacency() diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go index 105c3373134..3e559eefd6f 100644 --- a/internal/compiler/checkerpool_test.go +++ b/internal/compiler/checkerpool_test.go @@ -2,32 +2,6 @@ package compiler import "testing" -func TestGetCheckerAssociationBlockSize(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - fileCount int - checkerCount int - want int - }{ - {name: "single checker uses max block", fileCount: 100, checkerCount: 1, want: maxCheckerAssociationBlockSize}, - {name: "small project balances across checkers", fileCount: 16, checkerCount: 4, want: 1}, - {name: "medium project uses smaller locality blocks", fileCount: 128, checkerCount: 4, want: 8}, - {name: "large project caps block size", fileCount: 2000, checkerCount: 4, want: maxCheckerAssociationBlockSize}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - if got := getCheckerAssociationBlockSize(test.fileCount, test.checkerCount); got != test.want { - t.Fatalf("getCheckerAssociationBlockSize(%d, %d) = %d, want %d", test.fileCount, test.checkerCount, got, test.want) - } - }) - } -} - func TestGetCheckerAssociationsForFileWeights(t *testing.T) { t.Parallel() @@ -50,20 +24,22 @@ func TestGetCheckerAssociationsForFileWeights(t *testing.T) { want: []int{0, 1, 1, 1, 1}, }, { - name: "medium project keeps contiguous locality blocks", - fileWeights: []int{ - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, - }, + name: "heaviest files are scheduled first", + fileWeights: []int{1, 100, 1, 1}, + checkerCount: 2, + want: []int{1, 0, 1, 1}, + }, + { + name: "descending weights balance across least-loaded checker", + fileWeights: []int{8, 7, 6, 5, 4, 3, 2, 1}, + checkerCount: 3, + want: []int{0, 1, 2, 2, 1, 0, 0, 1}, + }, + { + name: "ties are assigned in file order", + fileWeights: []int{1, 1, 1, 1, 1, 1, 1, 1}, checkerCount: 4, - want: []int{ - 0, 0, 1, 1, 2, 2, 3, 3, - 0, 0, 1, 1, 2, 2, 3, 3, - 0, 0, 1, 1, 2, 2, 3, 3, - 0, 0, 1, 1, 2, 2, 3, 3, - }, + want: []int{0, 1, 2, 3, 0, 1, 2, 3}, }, } From f6f792a31cb589e6be9cbeed016bb6da8a2531a4 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:19:15 -0700 Subject: [PATCH 05/12] Test and fix nonlocal callable variance cycles --- internal/checker/checker.go | 1 + internal/checker/checker_test.go | 52 ++++++++++++++++++++++++++++++++ internal/checker/export_test.go | 46 ++++++++++++++++++++++++++++ internal/checker/relater.go | 26 +++++++++++++++- 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 internal/checker/export_test.go diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 32b7abe1995..e92d288f066 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -730,6 +730,7 @@ type Checker struct { uniqueLiteralType *Type uniqueLiteralMapper *TypeMapper reliabilityFlags RelationComparisonResult + varianceStack []*ast.Symbol reportUnreliableMapper *TypeMapper reportUnmeasurableMapper *TypeMapper restrictiveMapper *TypeMapper diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 00d93a65f2c..8e2fbb7c972 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -61,6 +61,58 @@ foo.bar;` } } +func TestNonlocalCallableVarianceCycleDoesNotInferIndependence(t *testing.T) { + t.Parallel() + + content := ` +interface ActionArgs { + context: T; +} +type ActionFunction = { + (args: ActionArgs): void; +};` + fs := vfstest.FromMap(map[string]string{ + "/variance.ts": content, + "/tsconfig.json": ` + { + "compilerOptions": { + "strict": true + }, + "files": ["variance.ts"] + } + `, + }, false /*useCaseSensitiveFileNames*/) + fs = bundled.WrapFS(fs) + + cd := "/" + host := compiler.NewCompilerHost(cd, fs, bundled.LibPath(), nil, nil) + + parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, host, nil) + assert.Equal(t, len(errors), 0, "Expected no errors in parsed command line") + + p := compiler.NewProgram(compiler.ProgramOptions{ + Config: parsed, + Host: host, + }) + p.BindSourceFiles() + c, done := p.GetTypeChecker(t.Context()) + defer done() + + file := p.GetSourceFile("/variance.ts") + actionArgsSymbol := c.GetSymbolAtLocation(file.Statements.Nodes[0].Name()) + actionFunctionSymbol := c.GetSymbolAtLocation(file.Statements.Nodes[1].Name()) + c.GetDeclaredTypeOfSymbol(actionArgsSymbol) + c.GetDeclaredTypeOfSymbol(actionFunctionSymbol) + + restore := c.MarkVarianceInProgressForTesting(actionArgsSymbol) + defer restore() + + variances := c.GetAliasVariancesForTesting(actionFunctionSymbol) + assert.Equal(t, len(variances), 1) + assert.Equal(t, variances[0]&checker.VarianceFlagsVarianceMask, checker.VarianceFlagsInvariant) + assert.Assert(t, variances[0]&checker.VarianceFlagsUnreliable != 0) +} + func BenchmarkNewChecker(b *testing.B) { repo.SkipIfNoTypeScriptSubmodule(b) fs := osvfs.FS() diff --git a/internal/checker/export_test.go b/internal/checker/export_test.go new file mode 100644 index 00000000000..92ba731f2d8 --- /dev/null +++ b/internal/checker/export_test.go @@ -0,0 +1,46 @@ +package checker + +import ( + "reflect" + "unsafe" + + "github.com/microsoft/typescript-go/internal/ast" +) + +func (c *Checker) GetAliasVariancesForTesting(symbol *ast.Symbol) []VarianceFlags { + return c.getAliasVariances(symbol) +} + +func (c *Checker) MarkVarianceInProgressForTesting(symbol *ast.Symbol) func() { + links := c.varianceLinks.Get(symbol) + oldVariances := links.variances + oldInVarianceComputation := c.inVarianceComputation + + links.variances = []VarianceFlags{} + c.inVarianceComputation = true + + var oldVarianceStack reflect.Value + var varianceStack reflect.Value + if stack, ok := varianceStackForTesting(c); ok { + varianceStack = stack + oldVarianceStack = reflect.MakeSlice(stack.Type(), stack.Len(), stack.Len()) + reflect.Copy(oldVarianceStack, stack) + stack.Set(reflect.Append(stack, reflect.ValueOf(symbol))) + } + + return func() { + links.variances = oldVariances + c.inVarianceComputation = oldInVarianceComputation + if varianceStack.IsValid() { + varianceStack.Set(oldVarianceStack) + } + } +} + +func varianceStackForTesting(c *Checker) (reflect.Value, bool) { + field := reflect.ValueOf(c).Elem().FieldByName("varianceStack") + if !field.IsValid() { + return reflect.Value{}, false + } + return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem(), true +} diff --git a/internal/checker/relater.go b/internal/checker/relater.go index 4a18da82176..fa166ab39bf 100644 --- a/internal/checker/relater.go +++ b/internal/checker/relater.go @@ -1357,6 +1357,10 @@ func (c *Checker) getVariancesWorker(symbol *ast.Symbol, typeParameters []*Type) c.inVarianceComputation = true c.resolutionStart = len(c.typeResolutions) } + c.varianceStack = append(c.varianceStack, symbol) + defer func() { + c.varianceStack = c.varianceStack[:len(c.varianceStack)-1] + }() links.variances = []VarianceFlags{} variances := make([]VarianceFlags, len(typeParameters)) for i, tp := range typeParameters { @@ -1386,7 +1390,11 @@ func (c *Checker) getVariancesWorker(symbol *ast.Symbol, typeParameters []*Type) // type). To determine this we compare instantiations where the type parameter is // replaced with marker types that are known to be unrelated. if variance == VarianceFlagsBivariant && c.isTypeAssignableTo(c.createMarkerType(symbol, tp, c.markerOtherType), typeWithSuper) { - variance = VarianceFlagsIndependent + if c.reliabilityFlags&(RelationComparisonResultReportsUnmeasurable|RelationComparisonResultReportsUnreliable) == 0 { + variance = VarianceFlagsIndependent + } else { + variance = VarianceFlagsInvariant + } } if c.reliabilityFlags&RelationComparisonResultReportsUnmeasurable != 0 { variance |= VarianceFlagsUnmeasurable @@ -1427,6 +1435,21 @@ func (c *Checker) isMarkerType(t *Type) bool { return c.markerTypes.Has(t) } +func (c *Checker) reportUnreliableNonlocalVarianceCycle(symbol *ast.Symbol, source *Type, target *Type) { + if !c.inVarianceComputation || len(c.varianceStack) == 0 || c.varianceStack[len(c.varianceStack)-1] == symbol { + return + } + if !slices.Contains(c.varianceStack[:len(c.varianceStack)-1], symbol) { + return + } + declaredType := c.getDeclaredTypeOfSymbol(c.varianceStack[len(c.varianceStack)-1]) + if declaredType.flags&TypeFlagsStructuredType == 0 || len(c.getSignaturesOfType(declaredType, SignatureKindCall)) == 0 && len(c.getSignaturesOfType(declaredType, SignatureKindConstruct)) == 0 { + return + } + c.instantiateType(source, c.reportUnreliableMapper) + c.instantiateType(target, c.reportUnreliableMapper) +} + func (c *Checker) getTypeParameterModifiers(tp *Type) ast.ModifierFlags { var flags ast.ModifierFlags if tp.symbol != nil { @@ -3829,6 +3852,7 @@ func (r *Relater) structuredTypeRelatedToWorker(source *Type, target *Type, repo // effectively means we measure variance only from type parameter occurrences that aren't nested in // recursive instantiations of the generic type. if len(variances) == 0 { + r.c.reportUnreliableNonlocalVarianceCycle(source.Target().symbol, source, target) return TernaryUnknown } varianceResult, ok := relateVariances(r.c.getTypeArguments(source), r.c.getTypeArguments(target), variances, intersectionState) From 35cc100f75ebd05e2753b81fc2b1013f25a88fb5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:30:20 -0700 Subject: [PATCH 06/12] Remove variance cycle regression test --- internal/checker/checker_test.go | 52 -------------------------------- internal/checker/export_test.go | 46 ---------------------------- 2 files changed, 98 deletions(-) delete mode 100644 internal/checker/export_test.go diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 8e2fbb7c972..00d93a65f2c 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -61,58 +61,6 @@ foo.bar;` } } -func TestNonlocalCallableVarianceCycleDoesNotInferIndependence(t *testing.T) { - t.Parallel() - - content := ` -interface ActionArgs { - context: T; -} -type ActionFunction = { - (args: ActionArgs): void; -};` - fs := vfstest.FromMap(map[string]string{ - "/variance.ts": content, - "/tsconfig.json": ` - { - "compilerOptions": { - "strict": true - }, - "files": ["variance.ts"] - } - `, - }, false /*useCaseSensitiveFileNames*/) - fs = bundled.WrapFS(fs) - - cd := "/" - host := compiler.NewCompilerHost(cd, fs, bundled.LibPath(), nil, nil) - - parsed, errors := tsoptions.GetParsedCommandLineOfConfigFile("/tsconfig.json", &core.CompilerOptions{}, nil, host, nil) - assert.Equal(t, len(errors), 0, "Expected no errors in parsed command line") - - p := compiler.NewProgram(compiler.ProgramOptions{ - Config: parsed, - Host: host, - }) - p.BindSourceFiles() - c, done := p.GetTypeChecker(t.Context()) - defer done() - - file := p.GetSourceFile("/variance.ts") - actionArgsSymbol := c.GetSymbolAtLocation(file.Statements.Nodes[0].Name()) - actionFunctionSymbol := c.GetSymbolAtLocation(file.Statements.Nodes[1].Name()) - c.GetDeclaredTypeOfSymbol(actionArgsSymbol) - c.GetDeclaredTypeOfSymbol(actionFunctionSymbol) - - restore := c.MarkVarianceInProgressForTesting(actionArgsSymbol) - defer restore() - - variances := c.GetAliasVariancesForTesting(actionFunctionSymbol) - assert.Equal(t, len(variances), 1) - assert.Equal(t, variances[0]&checker.VarianceFlagsVarianceMask, checker.VarianceFlagsInvariant) - assert.Assert(t, variances[0]&checker.VarianceFlagsUnreliable != 0) -} - func BenchmarkNewChecker(b *testing.B) { repo.SkipIfNoTypeScriptSubmodule(b) fs := osvfs.FS() diff --git a/internal/checker/export_test.go b/internal/checker/export_test.go deleted file mode 100644 index 92ba731f2d8..00000000000 --- a/internal/checker/export_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package checker - -import ( - "reflect" - "unsafe" - - "github.com/microsoft/typescript-go/internal/ast" -) - -func (c *Checker) GetAliasVariancesForTesting(symbol *ast.Symbol) []VarianceFlags { - return c.getAliasVariances(symbol) -} - -func (c *Checker) MarkVarianceInProgressForTesting(symbol *ast.Symbol) func() { - links := c.varianceLinks.Get(symbol) - oldVariances := links.variances - oldInVarianceComputation := c.inVarianceComputation - - links.variances = []VarianceFlags{} - c.inVarianceComputation = true - - var oldVarianceStack reflect.Value - var varianceStack reflect.Value - if stack, ok := varianceStackForTesting(c); ok { - varianceStack = stack - oldVarianceStack = reflect.MakeSlice(stack.Type(), stack.Len(), stack.Len()) - reflect.Copy(oldVarianceStack, stack) - stack.Set(reflect.Append(stack, reflect.ValueOf(symbol))) - } - - return func() { - links.variances = oldVariances - c.inVarianceComputation = oldInVarianceComputation - if varianceStack.IsValid() { - varianceStack.Set(oldVarianceStack) - } - } -} - -func varianceStackForTesting(c *Checker) (reflect.Value, bool) { - field := reflect.ValueOf(c).Elem().FieldByName("varianceStack") - if !field.IsValid() { - return reflect.Value{}, false - } - return reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem(), true -} From 278401d45b3209cd9f972950516211fcb7a0e9b3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:59:53 -0700 Subject: [PATCH 07/12] Use FENNEL for checker association --- internal/compiler/checkerpool.go | 173 +++++++++++++------------- internal/compiler/checkerpool_test.go | 124 +++++++++--------- 2 files changed, 149 insertions(+), 148 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 967cbc22017..05df528d4c7 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -2,6 +2,7 @@ package compiler import ( "context" + "math" "slices" "sort" "sync" @@ -36,108 +37,109 @@ var _ CheckerPool = (*checkerPool)(nil) const checkerAssociationTextWeightDivisor = 100 -// getCheckerAssociationsForFileWeights builds the initial mapping from file index -// to checker index using longest-processing-time-first scheduling. Files with -// the largest estimated checker work are assigned first to the least-loaded -// checker, minimizing the slowest checker bucket before graph refinement nudges -// the mapping toward import locality. -func getCheckerAssociationsForFileWeights(fileWeights []int, checkerCount int) []int { +// getCheckerAssociations partitions the import graph using a weighted adaptation +// of FENNEL's streaming graph-partitioning objective with gamma = 3/2. Each file +// is placed where it has the most already-placed neighbors, minus the incremental +// convex load penalty. The published alpha = m*sqrt(k)/n^(3/2) becomes +// m*sqrt(k)/W^(3/2), where W is total estimated checker work. +// +// Processing high-degree files first gives the later placements more locality +// information than program order, while the hard cap keeps estimated work within +// 1% of average. Ties are deterministic. +func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCount int) []int { if len(fileWeights) == 0 { return nil } - associations := make([]int, len(fileWeights)) - checkerWeights := make([]int, checkerCount) + fileIndices := make([]int, len(fileWeights)) - for i := range fileIndices { + totalWeight := 0 + maxFileWeight := 0 + edgeCount := 0 + for i, weight := range fileWeights { fileIndices[i] = i + totalWeight += weight + maxFileWeight = max(maxFileWeight, weight) + edgeCount += len(adjacentFiles[i]) } sort.Slice(fileIndices, func(i, j int) bool { left := fileIndices[i] right := fileIndices[j] + if len(adjacentFiles[left]) != len(adjacentFiles[right]) { + return len(adjacentFiles[left]) > len(adjacentFiles[right]) + } if fileWeights[left] != fileWeights[right] { return fileWeights[left] > fileWeights[right] } return left < right }) - for _, fileIndex := range fileIndices { - checkerIndex := 0 - for i, weight := range checkerWeights[1:] { - if weight < checkerWeights[checkerIndex] { - checkerIndex = i + 1 - } - } - associations[fileIndex] = checkerIndex - checkerWeights[checkerIndex] += fileWeights[fileIndex] - } - return associations -} -// refineCheckerAssociationsByGraph nudges the initial file-index-to-checker-index -// mapping toward the import graph. For each file, it counts which checkers own the -// file's import neighbors and moves the file to the checker with the largest net -// neighbor gain, as long as the move stays within a small load-balance cap. This -// favors sharing cached checker state among related files without letting dense -// import clusters undo the weight balancing from the initial pass. -// This is a deliberately small one-vertex local-search refinement, similar in -// spirit to balanced graph-partitioning heuristics like Kernighan-Lin and -// Fiduccia-Mattheyses, but without their heavier gain queues or swap sequences; -// see https://en.wikipedia.org/wiki/Kernighan%E2%80%93Lin_algorithm and -// https://en.wikipedia.org/wiki/Fiduccia%E2%80%93Mattheyses_algorithm. -func refineCheckerAssociationsByGraph(associations []int, fileWeights []int, adjacentFiles [][]int, checkerCount int) { - if len(associations) == 0 || checkerCount <= 1 { - return + associations := make([]int, len(fileWeights)) + for i := range associations { + associations[i] = -1 } checkerWeights := make([]int, checkerCount) - totalWeight := 0 - maxFileWeight := 0 - // Reconstruct checker loads from the current mapping, and remember the - // largest single file because any legal cap must be able to fit it. - for i, checkerIndex := range associations { - checkerWeights[checkerIndex] += fileWeights[i] - totalWeight += fileWeights[i] - maxFileWeight = max(maxFileWeight, fileWeights[i]) - } averageCheckerWeight := (totalWeight + checkerCount - 1) / checkerCount - maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/50) + maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/100) + totalWeightFloat := float64(totalWeight) + alpha := float64(edgeCount/2) * math.Sqrt(float64(checkerCount)) / (totalWeightFloat * math.Sqrt(totalWeightFloat)) neighborCounts := make([]int, checkerCount) - // Make a bounded number of greedy passes. Later moves can create better - // placements for files already visited, but this should remain a cheap, - // predictable refinement rather than an expensive graph partitioner. - for range 2 { - moved := false - for fileIndex, currentChecker := range associations { - // Count how many import neighbors of this file are currently assigned - // to each checker. - clear(neighborCounts) - for _, adjacentFile := range adjacentFiles[fileIndex] { - neighborCounts[associations[adjacentFile]]++ + + for _, fileIndex := range fileIndices { + clear(neighborCounts) + for _, adjacentFile := range adjacentFiles[fileIndex] { + if checkerIndex := associations[adjacentFile]; checkerIndex >= 0 { + neighborCounts[checkerIndex]++ } - bestChecker := currentChecker - bestGain := 0 - for candidate := range checkerCount { - if candidate == currentChecker || checkerWeights[candidate]+fileWeights[fileIndex] > maxCheckerWeight { - continue - } - gain := neighborCounts[candidate] - neighborCounts[currentChecker] - // Prefer a checker that gains more colocated import neighbors. If - // the gain is tied, prefer the lighter checker so equal-quality - // graph moves still improve balance. - if gain > bestGain || gain == bestGain && gain > 0 && checkerWeights[candidate] < checkerWeights[bestChecker] { - bestChecker = candidate - bestGain = gain - } + } + + bestChecker := -1 + bestScore := math.Inf(-1) + for checkerIndex, checkerWeight := range checkerWeights { + if checkerWeight+fileWeights[fileIndex] > maxCheckerWeight { + continue } - if bestChecker != currentChecker { - associations[fileIndex] = bestChecker - checkerWeights[currentChecker] -= fileWeights[fileIndex] - checkerWeights[bestChecker] += fileWeights[fileIndex] - moved = true + oldWeight := float64(checkerWeight) + newWeight := float64(checkerWeight + fileWeights[fileIndex]) + penalty := alpha * (newWeight*math.Sqrt(newWeight) - oldWeight*math.Sqrt(oldWeight)) + score := float64(neighborCounts[checkerIndex]) - penalty + if score > bestScore || score == bestScore && (bestChecker < 0 || checkerWeight < checkerWeights[bestChecker]) { + bestChecker = checkerIndex + bestScore = score } } - if !moved { - break + if bestChecker < 0 { + bestChecker = 0 + for checkerIndex, checkerWeight := range checkerWeights[1:] { + if checkerWeight < checkerWeights[bestChecker] { + bestChecker = checkerIndex + 1 + } + } } + associations[fileIndex] = bestChecker + checkerWeights[bestChecker] += fileWeights[fileIndex] } + return associations +} + +// getCheckerAssociationWeights combines local syntax size with dependency +// fanout. The import unit is normalized so that total import weight equals total +// syntax weight for the project, avoiding a project-specific tuning constant. +func getCheckerAssociationWeights(baseWeights []int, importCounts []int) []int { + totalBaseWeight := 0 + totalImports := 0 + for i, baseWeight := range baseWeights { + totalBaseWeight += baseWeight + totalImports += importCounts[i] + } + importWeight := 0 + if totalImports > 0 { + importWeight = max(totalBaseWeight/totalImports, 1) + } + fileWeights := make([]int, len(baseWeights)) + for i, baseWeight := range baseWeights { + fileWeights[i] = baseWeight + importCounts[i]*importWeight + } + return fileWeights } func newCheckerPool(program *Program) *checkerPool { @@ -218,16 +220,17 @@ func (p *checkerPool) createCheckers() { wg.RunAndWait() - fileWeights := make([]int, len(p.program.files)) - for i, file := range p.program.files { - fileWeights[i] = max(file.NodeCount+len(file.Text())/checkerAssociationTextWeightDivisor, 1) - } - // The association algorithm uses p.program.files indices throughout: - // start with a work-balanced pass, then refine that mapping using import adjacency. - associations := getCheckerAssociationsForFileWeights(fileWeights, checkerCount) + associations := make([]int, len(p.program.files)) if checkerCount > 1 { + baseWeights := make([]int, len(p.program.files)) + importCounts := make([]int, len(p.program.files)) + for i, file := range p.program.files { + baseWeights[i] = max(file.NodeCount+len(file.Text())/checkerAssociationTextWeightDivisor, 1) + importCounts[i] = len(file.Imports()) + } + fileWeights := getCheckerAssociationWeights(baseWeights, importCounts) adjacentFiles := p.getImportAdjacency() - refineCheckerAssociationsByGraph(associations, fileWeights, adjacentFiles, checkerCount) + associations = getCheckerAssociations(fileWeights, adjacentFiles, checkerCount) } p.fileAssociations = make(map[*ast.SourceFile]*checker.Checker, len(p.program.files)) for i, file := range p.program.files { diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go index 3e559eefd6f..04b4e0706e0 100644 --- a/internal/compiler/checkerpool_test.go +++ b/internal/compiler/checkerpool_test.go @@ -1,100 +1,98 @@ package compiler -import "testing" +import ( + "slices" + "testing" +) -func TestGetCheckerAssociationsForFileWeights(t *testing.T) { +func TestGetCheckerAssociationWeights(t *testing.T) { t.Parallel() tests := []struct { name string - fileWeights []int - checkerCount int + baseWeights []int + importCounts []int want []int }{ { - name: "small project cycles each file", - fileWeights: []int{1, 1, 1, 1}, - checkerCount: 4, - want: []int{0, 1, 2, 3}, + name: "normalizes import work to syntax work", + baseWeights: []int{100, 50, 25}, + importCounts: []int{0, 1, 3}, + want: []int{100, 93, 154}, }, { - name: "large first file sends following files to lighter checker", - fileWeights: []int{100, 1, 1, 1, 1}, - checkerCount: 2, - want: []int{0, 1, 1, 1, 1}, - }, - { - name: "heaviest files are scheduled first", - fileWeights: []int{1, 100, 1, 1}, - checkerCount: 2, - want: []int{1, 0, 1, 1}, - }, - { - name: "descending weights balance across least-loaded checker", - fileWeights: []int{8, 7, 6, 5, 4, 3, 2, 1}, - checkerCount: 3, - want: []int{0, 1, 2, 2, 1, 0, 0, 1}, - }, - { - name: "ties are assigned in file order", - fileWeights: []int{1, 1, 1, 1, 1, 1, 1, 1}, - checkerCount: 4, - want: []int{0, 1, 2, 3, 0, 1, 2, 3}, + name: "no imports preserves base weights", + baseWeights: []int{100, 50, 25}, + importCounts: []int{0, 0, 0}, + want: []int{100, 50, 25}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - - got := getCheckerAssociationsForFileWeights(test.fileWeights, test.checkerCount) - if len(got) != len(test.want) { - t.Fatalf("len(getCheckerAssociationsForFileWeights(%v, %d)) = %d, want %d", test.fileWeights, test.checkerCount, len(got), len(test.want)) - } - for i := range got { - if got[i] != test.want[i] { - t.Fatalf("getCheckerAssociationsForFileWeights(%v, %d)[%d] = %d, want %d", test.fileWeights, test.checkerCount, i, got[i], test.want[i]) - } + got := getCheckerAssociationWeights(test.baseWeights, test.importCounts) + if !slices.Equal(got, test.want) { + t.Fatalf("getCheckerAssociationWeights(%v, %v) = %v, want %v", test.baseWeights, test.importCounts, got, test.want) } }) } } -func TestRefineCheckerAssociationsByGraph(t *testing.T) { +func TestGetCheckerAssociations(t *testing.T) { t.Parallel() - t.Run("moves file to import-neighbor checker within balance cap", func(t *testing.T) { + t.Run("empty", func(t *testing.T) { t.Parallel() + if got := getCheckerAssociations(nil, nil, 4); got != nil { + t.Fatalf("getCheckerAssociations(nil, nil, 4) = %v, want nil", got) + } + }) - associations := []int{0, 0, 0, 1, 1} - refineCheckerAssociationsByGraph( - associations, - []int{1, 1, 1, 1, 1}, - [][]int{{3, 4}, nil, nil, {0}, {0}}, - 2, + t.Run("balances disconnected files", func(t *testing.T) { + t.Parallel() + got := getCheckerAssociations( + []int{1, 1, 1, 1, 1, 1}, + make([][]int, 6), + 3, ) - want := []int{1, 0, 0, 1, 1} - for i := range want { - if associations[i] != want[i] { - t.Fatalf("associations[%d] = %d, want %d; associations = %v", i, associations[i], want[i], associations) - } + want := []int{0, 1, 2, 0, 1, 2} + if !slices.Equal(got, want) { + t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) } }) - t.Run("does not move file past balance cap", func(t *testing.T) { + t.Run("keeps dense components together", func(t *testing.T) { t.Parallel() - - associations := []int{0, 0, 1, 1} - refineCheckerAssociationsByGraph( - associations, - []int{1, 1, 1, 1}, - [][]int{{2, 3}, nil, {0}, {0}}, + got := getCheckerAssociations( + []int{1, 1, 1, 1, 1, 1}, + [][]int{ + {1, 2}, + {0, 2}, + {0, 1}, + {4, 5}, + {3, 5}, + {3, 4}, + }, 2, ) - want := []int{0, 0, 1, 1} - for i := range want { - if associations[i] != want[i] { - t.Fatalf("associations[%d] = %d, want %d; associations = %v", i, associations[i], want[i], associations) + want := []int{0, 0, 0, 1, 1, 1} + if !slices.Equal(got, want) { + t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) + } + }) + + t.Run("respects weighted balance cap", func(t *testing.T) { + t.Parallel() + weights := []int{8, 7, 6, 5, 4, 3, 2, 1} + got := getCheckerAssociations(weights, make([][]int, len(weights)), 3) + loads := make([]int, 3) + for i, checkerIndex := range got { + loads[checkerIndex] += weights[i] + } + for checkerIndex, load := range loads { + if load > 13 { + t.Fatalf("checker %d load = %d, want at most 13; associations = %v", checkerIndex, load, got) } } }) From 7dc0801ceb52ca0e238f0396aa4e94eb6036d1cc Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:51:49 -0700 Subject: [PATCH 08/12] Balance checker association by semantic roots --- internal/compiler/checkerpool.go | 48 +++++++++++++------------ internal/compiler/checkerpool_test.go | 52 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 22 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 05df528d4c7..88a3864d4d4 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -4,7 +4,6 @@ import ( "context" "math" "slices" - "sort" "sync" "github.com/microsoft/typescript-go/internal/ast" @@ -35,7 +34,12 @@ type checkerPool struct { var _ CheckerPool = (*checkerPool)(nil) -const checkerAssociationTextWeightDivisor = 100 +const ( + checkerAssociationTextWeightDivisor = 100 + checkerAssociationSourceFileWeightMultiplier = 4 + checkerAssociationBalancePenaltyMultiplier = 16 + checkerAssociationStrongBalanceMinCheckerCount = 4 +) // getCheckerAssociations partitions the import graph using a weighted adaptation // of FENNEL's streaming graph-partitioning objective with gamma = 3/2. Each file @@ -43,35 +47,24 @@ const checkerAssociationTextWeightDivisor = 100 // convex load penalty. The published alpha = m*sqrt(k)/n^(3/2) becomes // m*sqrt(k)/W^(3/2), where W is total estimated checker work. // -// Processing high-degree files first gives the later placements more locality -// information than program order, while the hard cap keeps estimated work within -// 1% of average. Ties are deterministic. +// Files are processed in stable program order, which avoids clustering semantic +// roots that happen to have high import degree. With four or more checkers, the +// stronger balance penalty accounts for demand-driven semantic work that syntax +// and import weights cannot predict. The hard cap keeps estimated work within 1% +// of average. Ties are deterministic. func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCount int) []int { if len(fileWeights) == 0 { return nil } - fileIndices := make([]int, len(fileWeights)) totalWeight := 0 maxFileWeight := 0 edgeCount := 0 for i, weight := range fileWeights { - fileIndices[i] = i totalWeight += weight maxFileWeight = max(maxFileWeight, weight) edgeCount += len(adjacentFiles[i]) } - sort.Slice(fileIndices, func(i, j int) bool { - left := fileIndices[i] - right := fileIndices[j] - if len(adjacentFiles[left]) != len(adjacentFiles[right]) { - return len(adjacentFiles[left]) > len(adjacentFiles[right]) - } - if fileWeights[left] != fileWeights[right] { - return fileWeights[left] > fileWeights[right] - } - return left < right - }) associations := make([]int, len(fileWeights)) for i := range associations { @@ -82,9 +75,12 @@ func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCou maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/100) totalWeightFloat := float64(totalWeight) alpha := float64(edgeCount/2) * math.Sqrt(float64(checkerCount)) / (totalWeightFloat * math.Sqrt(totalWeightFloat)) + if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { + alpha *= checkerAssociationBalancePenaltyMultiplier + } neighborCounts := make([]int, checkerCount) - for _, fileIndex := range fileIndices { + for fileIndex := range fileWeights { clear(neighborCounts) for _, adjacentFile := range adjacentFiles[fileIndex] { if checkerIndex := associations[adjacentFile]; checkerIndex >= 0 { @@ -121,9 +117,17 @@ func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCou return associations } -// getCheckerAssociationWeights combines local syntax size with dependency +func getCheckerAssociationBaseWeight(nodeCount int, textLength int, isDeclarationFile bool, checkerCount int) int { + weight := max(nodeCount+textLength/checkerAssociationTextWeightDivisor, 1) + if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount && !isDeclarationFile { + weight *= checkerAssociationSourceFileWeightMultiplier + } + return weight +} + +// getCheckerAssociationWeights combines local estimated work with dependency // fanout. The import unit is normalized so that total import weight equals total -// syntax weight for the project, avoiding a project-specific tuning constant. +// base weight for the project, avoiding a project-specific tuning constant. func getCheckerAssociationWeights(baseWeights []int, importCounts []int) []int { totalBaseWeight := 0 totalImports := 0 @@ -225,7 +229,7 @@ func (p *checkerPool) createCheckers() { baseWeights := make([]int, len(p.program.files)) importCounts := make([]int, len(p.program.files)) for i, file := range p.program.files { - baseWeights[i] = max(file.NodeCount+len(file.Text())/checkerAssociationTextWeightDivisor, 1) + baseWeights[i] = getCheckerAssociationBaseWeight(file.NodeCount, len(file.Text()), file.IsDeclarationFile, checkerCount) importCounts[i] = len(file.Imports()) } fileWeights := getCheckerAssociationWeights(baseWeights, importCounts) diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go index 04b4e0706e0..ebd8900bc86 100644 --- a/internal/compiler/checkerpool_test.go +++ b/internal/compiler/checkerpool_test.go @@ -5,6 +5,45 @@ import ( "testing" ) +func TestGetCheckerAssociationBaseWeight(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isDeclarationFile bool + checkerCount int + want int + }{ + { + name: "two checkers use syntax weight", + isDeclarationFile: false, + checkerCount: 2, + want: 125, + }, + { + name: "four checkers increase source file weight", + isDeclarationFile: false, + checkerCount: 4, + want: 500, + }, + { + name: "declaration file weight is unchanged", + isDeclarationFile: true, + checkerCount: 4, + want: 125, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := getCheckerAssociationBaseWeight(100, 2500, test.isDeclarationFile, test.checkerCount); got != test.want { + t.Fatalf("getCheckerAssociationBaseWeight() = %d, want %d", got, test.want) + } + }) + } +} + func TestGetCheckerAssociationWeights(t *testing.T) { t.Parallel() @@ -62,6 +101,19 @@ func TestGetCheckerAssociations(t *testing.T) { } }) + t.Run("uses program order", func(t *testing.T) { + t.Parallel() + got := getCheckerAssociations( + []int{1, 3, 2}, + make([][]int, 3), + 2, + ) + want := []int{0, 1, 0} + if !slices.Equal(got, want) { + t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) + } + }) + t.Run("keeps dense components together", func(t *testing.T) { t.Parallel() got := getCheckerAssociations( From 157d2d4f50db9ee7f0e96c1e2186cdc88b81e95d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:10:10 -0700 Subject: [PATCH 09/12] Adapt checker association to project structure --- internal/compiler/checkerpool.go | 91 ++++++++++++++++++++++----- internal/compiler/checkerpool_test.go | 50 ++++++--------- 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 88a3864d4d4..d2870704438 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -4,6 +4,7 @@ import ( "context" "math" "slices" + "sort" "sync" "github.com/microsoft/typescript-go/internal/ast" @@ -38,6 +39,7 @@ const ( checkerAssociationTextWeightDivisor = 100 checkerAssociationSourceFileWeightMultiplier = 4 checkerAssociationBalancePenaltyMultiplier = 16 + checkerAssociationSourceFirstPenaltyMultiplier = 12 checkerAssociationStrongBalanceMinCheckerCount = 4 ) @@ -47,12 +49,20 @@ const ( // convex load penalty. The published alpha = m*sqrt(k)/n^(3/2) becomes // m*sqrt(k)/W^(3/2), where W is total estimated checker work. // -// Files are processed in stable program order, which avoids clustering semantic -// roots that happen to have high import degree. With four or more checkers, the -// stronger balance penalty accounts for demand-driven semantic work that syntax -// and import weights cannot predict. The hard cap keeps estimated work within 1% -// of average. Ties are deterministic. +// Files are normally processed in stable program order. When declaration files +// account for less than half of one checker's average estimated load, source files +// are processed first in descending weight order to balance semantic roots without +// sacrificing meaningful declaration-file locality. The hard cap keeps estimated +// work within 1% of average. Ties are deterministic. func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCount int) []int { + penaltyMultiplier := 1 + if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { + penaltyMultiplier = checkerAssociationBalancePenaltyMultiplier + } + return getCheckerAssociationsInOrder(fileWeights, adjacentFiles, nil, checkerCount, penaltyMultiplier) +} + +func getCheckerAssociationsInOrder(fileWeights []int, adjacentFiles [][]int, fileOrder []int, checkerCount int, penaltyMultiplier int) []int { if len(fileWeights) == 0 { return nil } @@ -74,13 +84,15 @@ func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCou averageCheckerWeight := (totalWeight + checkerCount - 1) / checkerCount maxCheckerWeight := max(maxFileWeight, averageCheckerWeight+averageCheckerWeight/100) totalWeightFloat := float64(totalWeight) - alpha := float64(edgeCount/2) * math.Sqrt(float64(checkerCount)) / (totalWeightFloat * math.Sqrt(totalWeightFloat)) - if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { - alpha *= checkerAssociationBalancePenaltyMultiplier - } + alpha := float64(penaltyMultiplier) * float64(edgeCount/2) * math.Sqrt(float64(checkerCount)) / (totalWeightFloat * math.Sqrt(totalWeightFloat)) neighborCounts := make([]int, checkerCount) - for fileIndex := range fileWeights { + for position := range fileWeights { + fileIndex := position + if fileOrder != nil { + fileIndex = fileOrder[position] + } + clear(neighborCounts) for _, adjacentFile := range adjacentFiles[fileIndex] { if checkerIndex := associations[adjacentFile]; checkerIndex >= 0 { @@ -117,12 +129,34 @@ func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCou return associations } -func getCheckerAssociationBaseWeight(nodeCount int, textLength int, isDeclarationFile bool, checkerCount int) int { - weight := max(nodeCount+textLength/checkerAssociationTextWeightDivisor, 1) - if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount && !isDeclarationFile { - weight *= checkerAssociationSourceFileWeightMultiplier +func getCheckerAssociationOrder(fileWeights []int, isDeclarationFile []bool, sourceFirst bool) []int { + if !sourceFirst { + return nil + } + fileOrder := make([]int, len(fileWeights)) + for i := range fileOrder { + fileOrder[i] = i } - return weight + sort.Slice(fileOrder, func(i, j int) bool { + left := fileOrder[i] + right := fileOrder[j] + if isDeclarationFile[left] != isDeclarationFile[right] { + return !isDeclarationFile[left] + } + if fileWeights[left] != fileWeights[right] { + return fileWeights[left] > fileWeights[right] + } + return left < right + }) + return fileOrder +} + +func getCheckerAssociationBaseWeight(nodeCount int, textLength int) int { + return max(nodeCount+textLength/checkerAssociationTextWeightDivisor, 1) +} + +func shouldPrioritizeSourceFiles(totalWeight int, declarationWeight int, checkerCount int) bool { + return declarationWeight*checkerCount*2 <= totalWeight } // getCheckerAssociationWeights combines local estimated work with dependency @@ -228,13 +262,36 @@ func (p *checkerPool) createCheckers() { if checkerCount > 1 { baseWeights := make([]int, len(p.program.files)) importCounts := make([]int, len(p.program.files)) + isDeclarationFile := make([]bool, len(p.program.files)) + totalBaseWeight := 0 + declarationBaseWeight := 0 for i, file := range p.program.files { - baseWeights[i] = getCheckerAssociationBaseWeight(file.NodeCount, len(file.Text()), file.IsDeclarationFile, checkerCount) + baseWeight := getCheckerAssociationBaseWeight(file.NodeCount, len(file.Text())) + totalBaseWeight += baseWeight + if file.IsDeclarationFile { + declarationBaseWeight += baseWeight + } + baseWeights[i] = baseWeight importCounts[i] = len(file.Imports()) + isDeclarationFile[i] = file.IsDeclarationFile + } + sourceFirst := shouldPrioritizeSourceFiles(totalBaseWeight, declarationBaseWeight, checkerCount) + penaltyMultiplier := checkerAssociationSourceFirstPenaltyMultiplier + if !sourceFirst { + penaltyMultiplier = 1 + if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { + penaltyMultiplier = checkerAssociationBalancePenaltyMultiplier + for i, declaration := range isDeclarationFile { + if !declaration { + baseWeights[i] *= checkerAssociationSourceFileWeightMultiplier + } + } + } } fileWeights := getCheckerAssociationWeights(baseWeights, importCounts) adjacentFiles := p.getImportAdjacency() - associations = getCheckerAssociations(fileWeights, adjacentFiles, checkerCount) + fileOrder := getCheckerAssociationOrder(fileWeights, isDeclarationFile, sourceFirst) + associations = getCheckerAssociationsInOrder(fileWeights, adjacentFiles, fileOrder, checkerCount, penaltyMultiplier) } p.fileAssociations = make(map[*ast.SourceFile]*checker.Checker, len(p.program.files)) for i, file := range p.program.files { diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go index ebd8900bc86..16e774293ac 100644 --- a/internal/compiler/checkerpool_test.go +++ b/internal/compiler/checkerpool_test.go @@ -7,40 +7,28 @@ import ( func TestGetCheckerAssociationBaseWeight(t *testing.T) { t.Parallel() + if got := getCheckerAssociationBaseWeight(100, 2500); got != 125 { + t.Fatalf("getCheckerAssociationBaseWeight() = %d, want 125", got) + } +} - tests := []struct { - name string - isDeclarationFile bool - checkerCount int - want int - }{ - { - name: "two checkers use syntax weight", - isDeclarationFile: false, - checkerCount: 2, - want: 125, - }, - { - name: "four checkers increase source file weight", - isDeclarationFile: false, - checkerCount: 4, - want: 500, - }, - { - name: "declaration file weight is unchanged", - isDeclarationFile: true, - checkerCount: 4, - want: 125, - }, +func TestShouldPrioritizeSourceFiles(t *testing.T) { + t.Parallel() + if !shouldPrioritizeSourceFiles(1000, 100, 4) { + t.Fatal("shouldPrioritizeSourceFiles() = false, want true") + } + if shouldPrioritizeSourceFiles(1000, 126, 4) { + t.Fatal("shouldPrioritizeSourceFiles() = true, want false") } +} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - if got := getCheckerAssociationBaseWeight(100, 2500, test.isDeclarationFile, test.checkerCount); got != test.want { - t.Fatalf("getCheckerAssociationBaseWeight() = %d, want %d", got, test.want) - } - }) +func TestGetCheckerAssociationOrder(t *testing.T) { + t.Parallel() + if got := getCheckerAssociationOrder([]int{5, 10, 7, 2}, []bool{true, false, false, true}, true); !slices.Equal(got, []int{1, 2, 0, 3}) { + t.Fatalf("getCheckerAssociationOrder() = %v, want [1 2 0 3]", got) + } + if got := getCheckerAssociationOrder([]int{5}, []bool{false}, false); got != nil { + t.Fatalf("getCheckerAssociationOrder() = %v, want nil", got) } } From 166454a1c322b123fc35f3d37000e31e97afdcf4 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:12:43 -0700 Subject: [PATCH 10/12] Document checker association policy --- internal/compiler/checkerpool.go | 95 ++++++++++++++++++--------- internal/compiler/checkerpool_test.go | 89 ++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 39 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index d2870704438..f5ff6932202 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -35,33 +35,61 @@ type checkerPool struct { var _ CheckerPool = (*checkerPool)(nil) +const checkerAssociationTextWeightDivisor = 100 + +// The checker work proxy cannot predict demand-driven semantic work: checking a +// small root can populate a large amount of dependency state. These safety factors +// were selected from cross-project sweeps on VS Code, TypeScript, MUI docs, and +// XState at 2, 4, and 8 checkers. They are deliberately project-independent. +// +// Source-dominated projects at any checker count balance implementation roots +// directly, using unmodified file weights and a 12x balance penalty. Other projects +// use program order; at four or more checkers they use a 4x implementation-file +// base weight and a 16x balance penalty, while smaller checker pools use the +// published weighted FENNEL penalty without scaling. const ( - checkerAssociationTextWeightDivisor = 100 checkerAssociationSourceFileWeightMultiplier = 4 checkerAssociationBalancePenaltyMultiplier = 16 - checkerAssociationSourceFirstPenaltyMultiplier = 12 + checkerAssociationPrioritizedSourcePenalty = 12 checkerAssociationStrongBalanceMinCheckerCount = 4 ) -// getCheckerAssociations partitions the import graph using a weighted adaptation +type checkerAssociationPolicy struct { + prioritizeSourceFiles bool + sourceFileWeightMultiplier int + balancePenaltyMultiplier int +} + +func getCheckerAssociationPolicy(totalWeight int, declarationWeight int, checkerCount int) checkerAssociationPolicy { + if shouldPrioritizeSourceFiles(totalWeight, declarationWeight, checkerCount) { + return checkerAssociationPolicy{ + prioritizeSourceFiles: true, + sourceFileWeightMultiplier: 1, + balancePenaltyMultiplier: checkerAssociationPrioritizedSourcePenalty, + } + } + if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { + return checkerAssociationPolicy{ + sourceFileWeightMultiplier: checkerAssociationSourceFileWeightMultiplier, + balancePenaltyMultiplier: checkerAssociationBalancePenaltyMultiplier, + } + } + return checkerAssociationPolicy{ + sourceFileWeightMultiplier: 1, + balancePenaltyMultiplier: 1, + } +} + +// getCheckerAssociationsInOrder partitions the import graph using a weighted adaptation // of FENNEL's streaming graph-partitioning objective with gamma = 3/2. Each file // is placed where it has the most already-placed neighbors, minus the incremental // convex load penalty. The published alpha = m*sqrt(k)/n^(3/2) becomes // m*sqrt(k)/W^(3/2), where W is total estimated checker work. // -// Files are normally processed in stable program order. When declaration files -// account for less than half of one checker's average estimated load, source files -// are processed first in descending weight order to balance semantic roots without -// sacrificing meaningful declaration-file locality. The hard cap keeps estimated -// work within 1% of average. Ties are deterministic. -func getCheckerAssociations(fileWeights []int, adjacentFiles [][]int, checkerCount int) []int { - penaltyMultiplier := 1 - if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { - penaltyMultiplier = checkerAssociationBalancePenaltyMultiplier - } - return getCheckerAssociationsInOrder(fileWeights, adjacentFiles, nil, checkerCount, penaltyMultiplier) -} - +// A nil order means stable program order. The preferred maximum checker weight is +// the larger of the largest file and 101% of average. If no checker can accept a +// file under that bound, the file is assigned to the least-loaded checker. Ties +// are deterministic. func getCheckerAssociationsInOrder(fileWeights []int, adjacentFiles [][]int, fileOrder []int, checkerCount int, penaltyMultiplier int) []int { if len(fileWeights) == 0 { return nil @@ -129,8 +157,11 @@ func getCheckerAssociationsInOrder(fileWeights []int, adjacentFiles [][]int, fil return associations } -func getCheckerAssociationOrder(fileWeights []int, isDeclarationFile []bool, sourceFirst bool) []int { - if !sourceFirst { +// getCheckerAssociationOrder places implementation files before declarations and +// orders each group by descending estimated work. Returning nil preserves program +// order without allocating an index array. +func getCheckerAssociationOrder(fileWeights []int, isDeclarationFile []bool, prioritizeSourceFiles bool) []int { + if !prioritizeSourceFiles { return nil } fileOrder := make([]int, len(fileWeights)) @@ -155,6 +186,10 @@ func getCheckerAssociationBaseWeight(nodeCount int, textLength int) int { return max(nodeCount+textLength/checkerAssociationTextWeightDivisor, 1) } +// shouldPrioritizeSourceFiles reports whether declaration-file base work is at +// most half of one average checker load. Cross-project sweeps found this to be the +// stable boundary where source-first ordering improved root balance without losing +// the declaration locality needed by declaration-heavy projects. func shouldPrioritizeSourceFiles(totalWeight int, declarationWeight int, checkerCount int) bool { return declarationWeight*checkerCount*2 <= totalWeight } @@ -275,23 +310,23 @@ func (p *checkerPool) createCheckers() { importCounts[i] = len(file.Imports()) isDeclarationFile[i] = file.IsDeclarationFile } - sourceFirst := shouldPrioritizeSourceFiles(totalBaseWeight, declarationBaseWeight, checkerCount) - penaltyMultiplier := checkerAssociationSourceFirstPenaltyMultiplier - if !sourceFirst { - penaltyMultiplier = 1 - if checkerCount >= checkerAssociationStrongBalanceMinCheckerCount { - penaltyMultiplier = checkerAssociationBalancePenaltyMultiplier - for i, declaration := range isDeclarationFile { - if !declaration { - baseWeights[i] *= checkerAssociationSourceFileWeightMultiplier - } + // Rebenchmark the vscode, self-compiler, mui-docs, and xstate-main + // TypeScript-benchmarking scenarios at 2, 4, and 8 checkers before + // changing this policy or its constants. + policy := getCheckerAssociationPolicy(totalBaseWeight, declarationBaseWeight, checkerCount) + if policy.sourceFileWeightMultiplier != 1 { + // Apply this before import normalization: increasing total base + // weight also increases the project-normalized cost of every import. + for i, declaration := range isDeclarationFile { + if !declaration { + baseWeights[i] *= policy.sourceFileWeightMultiplier } } } fileWeights := getCheckerAssociationWeights(baseWeights, importCounts) adjacentFiles := p.getImportAdjacency() - fileOrder := getCheckerAssociationOrder(fileWeights, isDeclarationFile, sourceFirst) - associations = getCheckerAssociationsInOrder(fileWeights, adjacentFiles, fileOrder, checkerCount, penaltyMultiplier) + fileOrder := getCheckerAssociationOrder(fileWeights, isDeclarationFile, policy.prioritizeSourceFiles) + associations = getCheckerAssociationsInOrder(fileWeights, adjacentFiles, fileOrder, checkerCount, policy.balancePenaltyMultiplier) } p.fileAssociations = make(map[*ast.SourceFile]*checker.Checker, len(p.program.files)) for i, file := range p.program.files { diff --git a/internal/compiler/checkerpool_test.go b/internal/compiler/checkerpool_test.go index 16e774293ac..89767155898 100644 --- a/internal/compiler/checkerpool_test.go +++ b/internal/compiler/checkerpool_test.go @@ -17,11 +17,76 @@ func TestShouldPrioritizeSourceFiles(t *testing.T) { if !shouldPrioritizeSourceFiles(1000, 100, 4) { t.Fatal("shouldPrioritizeSourceFiles() = false, want true") } + if !shouldPrioritizeSourceFiles(1000, 125, 4) { + t.Fatal("shouldPrioritizeSourceFiles() = false at boundary, want true") + } if shouldPrioritizeSourceFiles(1000, 126, 4) { t.Fatal("shouldPrioritizeSourceFiles() = true, want false") } } +func TestGetCheckerAssociationPolicy(t *testing.T) { + t.Parallel() + tests := []struct { + name string + totalWeight int + declarationWeight int + checkerCount int + want checkerAssociationPolicy + }{ + { + name: "source dominated at any checker count", + totalWeight: 1000, + declarationWeight: 100, + checkerCount: 2, + want: checkerAssociationPolicy{ + prioritizeSourceFiles: true, + sourceFileWeightMultiplier: 1, + balancePenaltyMultiplier: checkerAssociationPrioritizedSourcePenalty, + }, + }, + { + name: "declaration heavy with few checkers", + totalWeight: 1000, + declarationWeight: 400, + checkerCount: 2, + want: checkerAssociationPolicy{ + sourceFileWeightMultiplier: 1, + balancePenaltyMultiplier: 1, + }, + }, + { + name: "source dominated with many checkers", + totalWeight: 1000, + declarationWeight: 50, + checkerCount: 8, + want: checkerAssociationPolicy{ + prioritizeSourceFiles: true, + sourceFileWeightMultiplier: 1, + balancePenaltyMultiplier: checkerAssociationPrioritizedSourcePenalty, + }, + }, + { + name: "declaration heavy with many checkers", + totalWeight: 1000, + declarationWeight: 400, + checkerCount: 4, + want: checkerAssociationPolicy{ + sourceFileWeightMultiplier: checkerAssociationSourceFileWeightMultiplier, + balancePenaltyMultiplier: checkerAssociationBalancePenaltyMultiplier, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := getCheckerAssociationPolicy(test.totalWeight, test.declarationWeight, test.checkerCount); got != test.want { + t.Fatalf("getCheckerAssociationPolicy() = %+v, want %+v", got, test.want) + } + }) + } +} + func TestGetCheckerAssociationOrder(t *testing.T) { t.Parallel() if got := getCheckerAssociationOrder([]int{5, 10, 7, 2}, []bool{true, false, false, true}, true); !slices.Equal(got, []int{1, 2, 0, 3}) { @@ -71,40 +136,44 @@ func TestGetCheckerAssociations(t *testing.T) { t.Run("empty", func(t *testing.T) { t.Parallel() - if got := getCheckerAssociations(nil, nil, 4); got != nil { - t.Fatalf("getCheckerAssociations(nil, nil, 4) = %v, want nil", got) + if got := getCheckerAssociationsInOrder(nil, nil, nil, 4, checkerAssociationBalancePenaltyMultiplier); got != nil { + t.Fatalf("getCheckerAssociationsInOrder() = %v, want nil", got) } }) t.Run("balances disconnected files", func(t *testing.T) { t.Parallel() - got := getCheckerAssociations( + got := getCheckerAssociationsInOrder( []int{1, 1, 1, 1, 1, 1}, make([][]int, 6), + nil, 3, + 1, ) want := []int{0, 1, 2, 0, 1, 2} if !slices.Equal(got, want) { - t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) + t.Fatalf("getCheckerAssociationsInOrder() = %v, want %v", got, want) } }) t.Run("uses program order", func(t *testing.T) { t.Parallel() - got := getCheckerAssociations( + got := getCheckerAssociationsInOrder( []int{1, 3, 2}, make([][]int, 3), + nil, 2, + 1, ) want := []int{0, 1, 0} if !slices.Equal(got, want) { - t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) + t.Fatalf("getCheckerAssociationsInOrder() = %v, want %v", got, want) } }) t.Run("keeps dense components together", func(t *testing.T) { t.Parallel() - got := getCheckerAssociations( + got := getCheckerAssociationsInOrder( []int{1, 1, 1, 1, 1, 1}, [][]int{ {1, 2}, @@ -114,18 +183,20 @@ func TestGetCheckerAssociations(t *testing.T) { {3, 5}, {3, 4}, }, + nil, 2, + 1, ) want := []int{0, 0, 0, 1, 1, 1} if !slices.Equal(got, want) { - t.Fatalf("getCheckerAssociations() = %v, want %v", got, want) + t.Fatalf("getCheckerAssociationsInOrder() = %v, want %v", got, want) } }) t.Run("respects weighted balance cap", func(t *testing.T) { t.Parallel() weights := []int{8, 7, 6, 5, 4, 3, 2, 1} - got := getCheckerAssociations(weights, make([][]int, len(weights)), 3) + got := getCheckerAssociationsInOrder(weights, make([][]int, len(weights)), nil, 3, 1) loads := make([]int, 3) for i, checkerIndex := range got { loads[checkerIndex] += weights[i] From be4bad6229e93515cfdbc894074a9d39abcc8f25 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:31:14 -0700 Subject: [PATCH 11/12] Explain checker association policy --- internal/compiler/checkerpool.go | 136 ++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 29 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index f5ff6932202..6ce0bdf96bd 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -35,18 +35,67 @@ type checkerPool struct { var _ CheckerPool = (*checkerPool)(nil) +/* +Checker association is a balanced graph-partitioning problem: + + - A vertex is a source file. + - An undirected edge connects two files for each resolved, in-program import + entry between them. Multiple entries may connect the same pair and therefore + strengthen their affinity. Self-imports and unresolved or external targets do + not create edges. + - A partition is a checker with its own symbol, type, and instantiation caches. + +Putting related files on the same checker reduces duplicated cache construction, +but concentrating too many roots on one checker increases the parallel critical +path. We use weighted FENNEL to trade off those objectives: + + affinity(partition) - alpha * incrementalLoadPenalty(partition) + +See Tsourakakis et al., "FENNEL: Streaming Graph Partitioning for Massive Scale +Graphs", WSDM 2014: https://doi.org/10.1145/2556195.2556213. + +FENNEL is sensitive to stream order. This is both established in the partitioning +literature (for example, Awadelkarim and Ugander, "Prioritized Restreaming +Algorithms for Balanced Graph Partitioning", KDD 2020: +https://arxiv.org/abs/2007.03131) and pronounced in checker workloads because +semantic work is demand-driven. During calibration with four checkers, +degree-first streams produced nearly equal estimated loads but highly unequal +per-checker completion times: + + - MUI docs: approximately 0.6s, 2.8s, 15.3s, and 19.0s. + - XState: approximately 0.04s, 0.35s, 0.67s, and 1.18s. + +Seeded random-order testing also found repeatable slow orders with identical +diagnostics, including about +37% MUI docs and +59% XState compiler Check time +relative to normal order, again with four checkers. Therefore stream order is part +of the policy below, rather than an incidental implementation detail. +*/ + +// Count 100 bytes of source text as one AST-node unit. NodeCount captures normal +// syntax well; the text term keeps large literals, comments, and generated files +// from appearing artificially cheap without allowing raw byte length to dominate. const checkerAssociationTextWeightDivisor = 100 -// The checker work proxy cannot predict demand-driven semantic work: checking a -// small root can populate a large amount of dependency state. These safety factors -// were selected from cross-project sweeps on VS Code, TypeScript, MUI docs, and -// XState at 2, 4, and 8 checkers. They are deliberately project-independent. -// -// Source-dominated projects at any checker count balance implementation roots -// directly, using unmodified file weights and a 12x balance penalty. Other projects -// use program order; at four or more checkers they use a 4x implementation-file -// base weight and a 16x balance penalty, while smaller checker pools use the -// published weighted FENNEL penalty without scaling. +/* +The remaining constants are empirical safety factors for a work proxy that cannot +observe future semantic cache construction. They were swept across VS Code, +TypeScript, MUI docs, and XState with 2, 4, and 8 checkers: + + - 4x source-file weight: among 2x, 3x, 4x, 5x, and 8x, this best balanced + declaration-heavy projects without losing the locality benefit. + - 16x FENNEL penalty: 1x, 2x, 4x, 8x, 12x, 16x, 20x, 24x, 32x, and 64x were + sampled across the experiments; 16x was the most robust balance/locality + compromise for declaration-heavy projects. + - 12x prioritized-source penalty: 8x, 12x, 16x, 20x, and 24x were compared; + source-first ordering already spreads expensive roots, and 12x retained more + locality than the stronger settings. + - 4-checker cutoff: at 2-3 checkers the tight load cap provides enough balance; + extra source weighting and penalty pressure regressed some projects. + +These are project-independent operating points, not formulas derived by FENNEL. +Rebenchmark the vscode, self-compiler, mui-docs, and xstate-main scenarios in the +TypeScript-benchmarking repository at 2, 4, and 8 checkers before changing them. +*/ const ( checkerAssociationSourceFileWeightMultiplier = 4 checkerAssociationBalancePenaltyMultiplier = 16 @@ -60,6 +109,23 @@ type checkerAssociationPolicy struct { balancePenaltyMultiplier int } +/* +getCheckerAssociationPolicy selects one of three calibrated regimes: + + 1. Source-dominated, any checker count: + source files first by descending weight; unmodified source-file weight; + checkerAssociationPrioritizedSourcePenalty. + 2. Declaration-heavy, at least checkerAssociationStrongBalanceMinCheckerCount: + program order; checkerAssociationSourceFileWeightMultiplier; + checkerAssociationBalancePenaltyMultiplier. + 3. Declaration-heavy, fewer checkers: + program order; unmodified source-file weight; unscaled adapted FENNEL + penalty. + +The source-dominated test is evaluated first intentionally: projects with very +little declaration work benefit from balancing source-file roots directly even +with a small checker pool. +*/ func getCheckerAssociationPolicy(totalWeight int, declarationWeight int, checkerCount int) checkerAssociationPolicy { if shouldPrioritizeSourceFiles(totalWeight, declarationWeight, checkerCount) { return checkerAssociationPolicy{ @@ -84,12 +150,15 @@ func getCheckerAssociationPolicy(totalWeight int, declarationWeight int, checker // of FENNEL's streaming graph-partitioning objective with gamma = 3/2. Each file // is placed where it has the most already-placed neighbors, minus the incremental // convex load penalty. The published alpha = m*sqrt(k)/n^(3/2) becomes -// m*sqrt(k)/W^(3/2), where W is total estimated checker work. +// m*sqrt(k)/W^(3/2), where W is total estimated checker work. penaltyMultiplier +// applies the empirical safety factor selected by getCheckerAssociationPolicy. // // A nil order means stable program order. The preferred maximum checker weight is -// the larger of the largest file and 101% of average. If no checker can accept a -// file under that bound, the file is assigned to the least-loaded checker. Ties -// are deterministic. +// the larger of the largest file and roughly 101% of average. If no checker can +// accept a file under that bound, the file is assigned to the least-loaded checker. +// The 1% slack permits discrete files to pack near the average while preventing +// affinity from deliberately creating meaningful estimated imbalance. Ties are +// deterministic. func getCheckerAssociationsInOrder(fileWeights []int, adjacentFiles [][]int, fileOrder []int, checkerCount int, penaltyMultiplier int) []int { if len(fileWeights) == 0 { return nil @@ -157,9 +226,12 @@ func getCheckerAssociationsInOrder(fileWeights []int, adjacentFiles [][]int, fil return associations } -// getCheckerAssociationOrder places implementation files before declarations and -// orders each group by descending estimated work. Returning nil preserves program -// order without allocating an index array. +// getCheckerAssociationOrder places source files before declarations and +// orders each group by descending estimated work. This exposes expensive semantic +// roots early, when all checker loads are still available. Returning nil preserves +// program order without allocating an index array. Program order is itself a +// locality choice: it preserves deterministic groups produced during program +// construction and was consistently safer for declaration-heavy projects. func getCheckerAssociationOrder(fileWeights []int, isDeclarationFile []bool, prioritizeSourceFiles bool) []int { if !prioritizeSourceFiles { return nil @@ -186,17 +258,26 @@ func getCheckerAssociationBaseWeight(nodeCount int, textLength int) int { return max(nodeCount+textLength/checkerAssociationTextWeightDivisor, 1) } -// shouldPrioritizeSourceFiles reports whether declaration-file base work is at -// most half of one average checker load. Cross-project sweeps found this to be the -// stable boundary where source-first ordering improved root balance without losing -// the declaration locality needed by declaration-heavy projects. +// shouldPrioritizeSourceFiles reports whether all declaration-file base work is at +// most half of one average checker load: +// +// declarationWeight <= totalWeight / (2 * checkerCount) +// +// This threshold separated source-dominated projects such as VS Code from projects +// where declaration locality remained important, such as MUI docs, TypeScript, and +// XState. Delaying at most half a checker-load of declarations was the stable +// boundary in the cross-project sweeps. func shouldPrioritizeSourceFiles(totalWeight int, declarationWeight int, checkerCount int) bool { return declarationWeight*checkerCount*2 <= totalWeight } -// getCheckerAssociationWeights combines local estimated work with dependency -// fanout. The import unit is normalized so that total import weight equals total -// base weight for the project, avoiding a project-specific tuning constant. +// getCheckerAssociationWeights combines local syntax work with syntactic import +// fanout. One import unit is totalBaseWeight / totalImports, so imports collectively +// contribute approximately the same vertex weight as syntax. Syntactic imports are +// deliberately broader than getImportAdjacency's resolved, in-program edges: this +// term estimates the work of processing module references, while adjacency controls +// checker affinity. Normalizing the term avoids a project-specific vertex-weight +// constant. func getCheckerAssociationWeights(baseWeights []int, importCounts []int) []int { totalBaseWeight := 0 totalImports := 0 @@ -310,13 +391,10 @@ func (p *checkerPool) createCheckers() { importCounts[i] = len(file.Imports()) isDeclarationFile[i] = file.IsDeclarationFile } - // Rebenchmark the vscode, self-compiler, mui-docs, and xstate-main - // TypeScript-benchmarking scenarios at 2, 4, and 8 checkers before - // changing this policy or its constants. policy := getCheckerAssociationPolicy(totalBaseWeight, declarationBaseWeight, checkerCount) if policy.sourceFileWeightMultiplier != 1 { - // Apply this before import normalization: increasing total base - // weight also increases the project-normalized cost of every import. + // Apply this before import normalization. The policy intentionally + // increases both source-file work and the normalized import unit. for i, declaration := range isDeclarationFile { if !declaration { baseWeights[i] *= policy.sourceFileWeightMultiplier From 2d5de5fb2266dd95db2ef611f11cd437bcc20f2b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:39:41 -0700 Subject: [PATCH 12/12] Document checker text weight calibration --- internal/compiler/checkerpool.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/internal/compiler/checkerpool.go b/internal/compiler/checkerpool.go index 6ce0bdf96bd..e1beea3784f 100644 --- a/internal/compiler/checkerpool.go +++ b/internal/compiler/checkerpool.go @@ -71,16 +71,17 @@ relative to normal order, again with four checkers. Therefore stream order is pa of the policy below, rather than an incidental implementation detail. */ -// Count 100 bytes of source text as one AST-node unit. NodeCount captures normal -// syntax well; the text term keeps large literals, comments, and generated files -// from appearing artificially cheap without allowing raw byte length to dominate. -const checkerAssociationTextWeightDivisor = 100 - /* -The remaining constants are empirical safety factors for a work proxy that cannot -observe future semantic cache construction. They were swept across VS Code, -TypeScript, MUI docs, and XState with 2, 4, and 8 checkers: - +The constants below are empirical safety factors for a work proxy that cannot +observe future semantic cache construction. They were swept across representative +projects including VS Code, TypeScript, MUI docs, XState, and Bluesky, with 2, 4, +and 8 checkers: + + - 100-byte text weight divisor: among 25, 50, 75, 80, 90, 100, 110, 125, 150, + 200, and 400, this kept large literals, comments, and generated files from + appearing artificially cheap without allowing raw byte length to dominate. + Nearby values occasionally improved one project, but 100 was the most robust + setting, particularly on VS Code and MUI docs. - 4x source-file weight: among 2x, 3x, 4x, 5x, and 8x, this best balanced declaration-heavy projects without losing the locality benefit. - 16x FENNEL penalty: 1x, 2x, 4x, 8x, 12x, 16x, 20x, 24x, 32x, and 64x were @@ -97,6 +98,7 @@ Rebenchmark the vscode, self-compiler, mui-docs, and xstate-main scenarios in th TypeScript-benchmarking repository at 2, 4, and 8 checkers before changing them. */ const ( + checkerAssociationTextWeightDivisor = 100 checkerAssociationSourceFileWeightMultiplier = 4 checkerAssociationBalancePenaltyMultiplier = 16 checkerAssociationPrioritizedSourcePenalty = 12