From ae113ed4b7ee28b58fbf789b4f8a458598ff213d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:16:42 -0700 Subject: [PATCH] Memoize repeated inference walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some inference walks are long and are made again and again with the same inputs. With Effect Schema, every call such as Schema.optional(x) inside a Struct({ ... }) infers from the contextual Constraint type to the combinator's return type, optional, whose members span the large schema interfaces. Each call repeats that walk from fresh inference infos and always ends in the same candidates. Effect-heavy code like t3code spends a large share of its check time this way. When every inference info is still in its initial state, the outcome of a walk is now remembered under its source, target, priority, contravariance and type parameters, as long as the walk computed nothing new and observed no transient state, so walking it again would take the same path. Transient state is any read of an in-progress resolution, signature resolution, variance computation, flow loop or flow type cache, which are now counted. Measured with --checkers 4 (n=8): │ before.txt │ after.txt │ │ sec/op │ sec/op vs base │ Tsc/bluesky 3.069 ± 4% 3.024 ± 1% -1.47% (p=0.038 n=8) Tsc/sentry 12.74 ± 2% 12.62 ± 1% ~ (p=0.382 n=8) Tsc/t3code-server 16.81 ± 3% 16.12 ± 4% -4.12% (p=0.028 n=8) geomean 8.694 8.505 -2.17% │ before.txt │ after.txt │ │ rss-B │ rss-B vs base │ Tsc/bluesky 1.422Gi ± 2% 1.413Gi ± 1% ~ (p=0.959 n=8) Tsc/sentry 4.571Gi ± 8% 4.617Gi ± 4% ~ (p=0.721 n=8) Tsc/t3code-server 4.378Gi ± 2% 4.650Gi ± 6% +6.21% (p=0.038 n=8) geomean 3.053Gi 3.119Gi +2.16% │ before.txt │ after.txt │ │ heap-B │ heap-B vs base │ Tsc/bluesky 1.152Gi ± 0% 1.153Gi ± 0% ~ (p=0.195 n=8) Tsc/sentry 3.620Gi ± 0% 3.622Gi ± 0% ~ (p=0.195 n=8) Tsc/t3code-server 3.761Gi ± 0% 3.763Gi ± 0% +0.04% (p=0.000 n=8) geomean 2.504Gi 2.504Gi +0.03% --- tsc/internal/checker/checker.go | 16 ++++ tsc/internal/checker/flow.go | 8 +- tsc/internal/checker/inference.go | 3 +- tsc/internal/checker/infermemo.go | 133 ++++++++++++++++++++++++++++++ tsc/internal/checker/relater.go | 1 + 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tsc/internal/checker/infermemo.go diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 4c57dd37910f2..d690aee14d66e 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -599,6 +599,10 @@ type Checker struct { TotalInstantiationCount uint32 instantiationCount uint32 memberLayoutCount uint32 + instantiationCountResets uint32 + diagnosticAdds uint32 + transientReads uint32 + inferMemo inferMemo instantiationStack []*Type conditionalConstraintDepth uint32 inlineLevel int @@ -2316,6 +2320,7 @@ func (c *Checker) checkSourceElement(node *ast.Node) bool { saveWithinUnreachableCode := c.withinUnreachableCode c.currentNode = node c.instantiationCount = 0 + c.instantiationCountResets++ c.checkSourceElementWorker(node) c.currentNode = saveCurrentNode c.withinUnreachableCode = saveWithinUnreachableCode @@ -2579,6 +2584,7 @@ func (c *Checker) checkDeferredNode(node *ast.Node) { saveCurrentNode := c.currentNode c.currentNode = node c.instantiationCount = 0 + c.instantiationCountResets++ switch node.Kind { case ast.KindCallExpression, ast.KindNewExpression, ast.KindTaggedTemplateExpression, ast.KindDecorator, ast.KindJsxOpeningElement: // These node kinds are deferred checked when overload resolution fails. To save on work, @@ -7545,6 +7551,7 @@ func (c *Checker) getTypeOfExpression(node *ast.Node) *Type { } // If a type has been cached for the node, return it. if cachedType := c.flowTypeCache[node]; cachedType != nil { + c.noteTransientRead() return cachedType } startInvocationCount := c.flowInvocationCount @@ -7764,6 +7771,7 @@ func (c *Checker) checkExpressionEx(node *ast.Node, checkMode CheckMode) *Type { saveCurrentNode := c.currentNode c.currentNode = node c.instantiationCount = 0 + c.instantiationCountResets++ uninstantiatedType := c.checkExpressionWorker(node, checkMode) t := c.instantiateTypeWithSingleGenericCallSignature(node, uninstantiatedType, checkMode) if isConstEnumObjectType(t) { @@ -8623,6 +8631,9 @@ func (c *Checker) getResolvedSignature(node *ast.Node, candidatesOutArray *[]*Si if cached != nil && cached != c.resolvingSignature && candidatesOutArray == nil { return cached } + if cached == c.resolvingSignature { + c.noteTransientRead() + } saveResolutionStart := c.resolutionStart if cached == nil { // If we haven't already done so, temporarily reset the resolution stack. This allows us to @@ -14246,6 +14257,7 @@ func (c *Checker) GetGlobalDiagnostics() []*ast.Diagnostic { } func (c *Checker) addDeferredDiagnostic(callback func()) { + c.diagnosticAdds++ c.deferredDiagnosticCallbacks = append(c.deferredDiagnosticCallbacks, callback) } @@ -14258,6 +14270,7 @@ func (c *Checker) produceDeferredDiagnostics() { func (c *Checker) addDiagnostic(diagnostic *ast.Diagnostic) *ast.Diagnostic { // Discard diagnostics created while at the maximum number of recursive TypeToString invocations. + c.diagnosticAdds++ if c.serializationLevel < maxSerializationLevel { return c.diagnostics.Add(diagnostic) } @@ -14266,6 +14279,7 @@ func (c *Checker) addDiagnostic(diagnostic *ast.Diagnostic) *ast.Diagnostic { func (c *Checker) addSuggestionDiagnostic(diagnostic *ast.Diagnostic) *ast.Diagnostic { // Discard diagnostics created while at the maximum number of recursive TypeToString invocations. + c.diagnosticAdds++ if c.serializationLevel < maxSerializationLevel { return c.suggestionDiagnostics.Add(diagnostic) } @@ -19228,6 +19242,7 @@ func (c *Checker) findResolutionCycleStartIndex(target TypeSystemEntity, propert return -1 } if resolution.target == target && resolution.propertyName == propertyName { + c.noteTransientRead() return i } } @@ -30554,6 +30569,7 @@ func (c *Checker) getContextualTypeForArgumentAtIndex(callTarget *ast.Node, argI // that could cause infinite recursion. Instead, return anySignature. var signature *Signature if c.signatureLinks.Get(callTarget).resolvedSignature == c.resolvingSignature { + c.noteTransientRead() signature = c.resolvingSignature } else { signature = c.getResolvedSignature(callTarget, nil, CheckModeNormal) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index d2831231772fe..e228e48849cf2 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -80,6 +80,7 @@ func (c *Checker) getFlowTypeOfReference(reference *ast.Node, declaredType *Type func (c *Checker) getFlowTypeOfReferenceEx(reference *ast.Node, declaredType *Type, initialType *Type, flowContainer *ast.Node, flowNode *ast.FlowNode) *Type { if c.flowAnalysisDisabled { + c.noteTransientRead() return c.errorType } if flowNode == nil { @@ -122,6 +123,7 @@ func (c *Checker) getTypeAtFlowNode(f *FlowState, flow *ast.FlowNode) FlowType { tr.Instant(tracing.PhaseCheckTypes, "getTypeAtFlowNode_DepthLimit", map[string]any{"depth": f.depth}) } c.flowAnalysisDisabled = true + c.noteTransientRead() c.reportFlowControlError(f.reference) return FlowType{t: c.errorType} } @@ -383,7 +385,10 @@ func (c *Checker) narrowType(f *FlowState, t *Type, expr *ast.Node, assumeTrue b case ast.KindIdentifier: // When narrowing a reference to a const variable, non-assigned parameter, or readonly property, we inline // up to five levels of aliased conditional expressions that are themselves declared as const variables. - if !c.isMatchingReference(f.reference, expr) && c.inlineLevel < 5 { + if c.inlineLevel >= 5 { + // Whether this inlines depends on how deep the enclosing inlining is. + c.noteTransientRead() + } else if !c.isMatchingReference(f.reference, expr) { symbol := c.getResolvedSymbol(expr) if c.isConstantVariable(symbol) { declaration := symbol.ValueDeclaration() @@ -1346,6 +1351,7 @@ func (c *Checker) getTypeAtFlowLoopLabel(f *FlowState, flow *ast.FlowNode) FlowT // path that leads to the top. for _, loopInfo := range c.flowLoopStack { if loopInfo.key == key && len(loopInfo.types) != 0 { + c.noteTransientRead() return c.newFlowType(c.getUnionOrEvolvingArrayType(f, loopInfo.types, UnionReductionLiteral), true /*incomplete*/) } } diff --git a/tsc/internal/checker/inference.go b/tsc/internal/checker/inference.go index 02c7d009b9ec5..ed69d9c3c1c50 100644 --- a/tsc/internal/checker/inference.go +++ b/tsc/internal/checker/inference.go @@ -61,11 +61,12 @@ func (c *Checker) inferTypes(inferences []*InferenceInfo, originalSource *Type, n.priority = priority n.inferencePriority = InferencePriorityMaxValue n.contravariant = contravariant - c.inferFromTypes(n, originalSource, originalTarget) + c.inferFromTypesMemo(n, originalSource, originalTarget) c.putInferenceState(n) } func (c *Checker) inferFromTypes(n *InferenceState, source *Type, target *Type) { + c.inferMemo.steps++ if !c.couldContainTypeVariables(target) || c.isNoInferType(target) { return } diff --git a/tsc/internal/checker/infermemo.go b/tsc/internal/checker/infermemo.go new file mode 100644 index 0000000000000..4f0d3cfd40f7c --- /dev/null +++ b/tsc/internal/checker/infermemo.go @@ -0,0 +1,133 @@ +package checker + +import ( + "encoding/binary" + "slices" + + "github.com/microsoft/TypeScript/tsc/internal/core" +) + +// The inference walk memo. +// +// Some walks at the top of inferTypes are long and are repeated with the same inputs, for example +// inferring from a schema constraint type to the return type of a schema combinator, once per +// call to that combinator. When every inference info is still in its initial state, a walk's +// inputs are just the source, the target, the priority, the contravariance and the type +// parameters, and its outcome is the candidates, priority and topLevel it writes to each info. +// +// A walk's outcome is stored only when the walk computed nothing new and read no transient +// state: it created no type, symbol or signature, instantiated nothing, added no diagnostic, +// observed no in-progress resolution, signature resolution, flow loop or flow type cache entry, +// and did not start inside an instantiation or variance computation. Such a walk read only +// finished values besides its inputs, so the same inputs take the same path again and write +// the same outcome. Walks shorter than inferMemoMinSteps are cheaper to repeat than to store. + +const inferMemoMinSteps = 16 + +type inferMemoOutcome struct { + candidates []*Type + contraCandidates []*Type + priority InferencePriority + topLevel bool +} + +type inferMemo struct { + entries map[string][]inferMemoOutcome + key []byte + steps uint32 +} + +// The checker state a walk must leave alone to be stored. +type inferMemoEffects struct { + created uint32 + instantiationCount uint32 + totalInstantiationCount uint32 + diagnostics uint32 + transientReads uint32 + instantiationCountReset uint32 +} + +func (c *Checker) inferMemoEffects() inferMemoEffects { + return inferMemoEffects{ + created: c.TypeCount + c.SymbolCount + c.SignatureCount, + instantiationCount: c.instantiationCount, + totalInstantiationCount: c.TotalInstantiationCount, + diagnostics: c.diagnosticAdds, + transientReads: c.transientReads, + instantiationCountReset: c.instantiationCountResets, + } +} + +// Records that the current computation observed state that is only valid for the moment, such +// as an in-progress resolution. Results computed with such reads must not be memoized. +func (c *Checker) noteTransientRead() { + c.transientReads++ +} + +func (c *Checker) inferFromTypesMemo(n *InferenceState, source *Type, target *Type) { + m := &c.inferMemo + // Relations made inside a variance computation report reliability to it. + if len(c.varianceStack) != 0 || c.skipDirectInferenceNodes.Len() != 0 || + !core.Every(n.inferences, isInitialInferenceInfo) { + c.inferFromTypes(n, source, target) + return + } + m.key = binary.LittleEndian.AppendUint32(m.key[:0], uint32(source.id)) + m.key = binary.LittleEndian.AppendUint32(m.key, uint32(target.id)) + m.key = binary.LittleEndian.AppendUint32(m.key, uint32(n.priority)) + if n.contravariant { + m.key = append(m.key, 1) + } else { + m.key = append(m.key, 0) + } + for _, info := range n.inferences { + m.key = binary.LittleEndian.AppendUint32(m.key, uint32(info.typeParameter.id)) + } + if outcomes, ok := m.entries[string(m.key)]; ok { + changed := false + for i, info := range n.inferences { + outcome := &outcomes[i] + if outcome.candidates != nil || outcome.contraCandidates != nil || + outcome.priority != info.priority || outcome.topLevel != info.topLevel { + changed = true + } + info.candidates = outcome.candidates + info.contraCandidates = outcome.contraCandidates + info.priority = outcome.priority + info.topLevel = outcome.topLevel + } + if changed { + clearCachedInferences(n.inferences) + } + return + } + // The walk may infer into nested calls that reuse the key buffer. + key := string(m.key) + // Inside an instantiation the walk may take cached instantiations without counting them, + // while the same walk elsewhere would make and count them. + inInstantiation := len(c.activeMappers) != 0 + before := c.inferMemoEffects() + stepsBefore := m.steps + c.inferFromTypes(n, source, target) + if m.steps-stepsBefore < inferMemoMinSteps || inInstantiation || c.inferMemoEffects() != before { + return + } + outcomes := make([]inferMemoOutcome, len(n.inferences)) + for i, info := range n.inferences { + outcomes[i] = inferMemoOutcome{ + candidates: slices.Clip(slices.Clone(info.candidates)), + contraCandidates: slices.Clip(slices.Clone(info.contraCandidates)), + priority: info.priority, + topLevel: info.topLevel, + } + } + if m.entries == nil { + m.entries = make(map[string][]inferMemoOutcome) + } + m.entries[key] = outcomes +} + +func isInitialInferenceInfo(info *InferenceInfo) bool { + return info.typeParameter != nil && info.candidates == nil && info.contraCandidates == nil && !info.isFixed && + info.priority == InferencePriorityMaxValue && info.topLevel && info.impliedArity == -1 +} diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index 007fba2511a13..d8c595c725a7e 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1410,6 +1410,7 @@ func (c *Checker) getVariancesWorker(symbol *ast.Symbol, typeParameters []*Type) c.resolutionStart = saveResolutionStart } } else { + c.noteTransientRead() // We've detected a circularity. Since we may compute different variances depending on where // we enter a circularity, we find the generic type with the "smallest" symbol in the circular // region of the variance stack and restart the computation from there if necessary. This