diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 4bb04010c1926..420beb33c4d6b 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -347,6 +347,7 @@ namespace ts { let totalInstantiationCount = 0; let instantiationCount = 0; let instantiationDepth = 0; + let nestedElementCacheContribution = 0; let inlineLevel = 0; let currentNode: Node | undefined; let varianceTypeParameter: TypeParameter | undefined; @@ -1042,12 +1043,52 @@ namespace ts { let _jsxNamespace: __String; let _jsxFactoryEntity: EntityName | undefined; - const subtypeRelation = new Map(); - const strictSubtypeRelation = new Map(); - const assignableRelation = new Map(); - const comparableRelation = new Map(); - const identityRelation = new Map(); - const enumRelation = new Map(); + class ExpandableRelationshipCache { + private next?: ExpandableRelationshipCache; + private inner: ESMap; + constructor() { + this.inner = new Map(); + } + get(key: K): V | undefined { + return this.inner.has(key) ? this.inner.get(key) : this.next?.get(key); + } + set(key: K, value: V): this { + if (this.inner.size > ((2 ** 24) - 1) && !this.inner.has(key)) { + this.next ||= new ExpandableRelationshipCache(); + this.next.set(key, value); + } + else { + this.inner.set(key, value); + } + return this; + } + has(key: K): boolean { + return this.inner.has(key) || !!this.next?.has(key); + } + clear(): void { + this.inner.clear(); + this.next?.clear(); + this.next = undefined; + } + delete(key: K): boolean { + return this.inner.delete(key) || !!this.next?.delete(key); + } + forEach(callbackfn: (value: V, key: K, map: ExpandableRelationshipCache) => void): void { + this.inner.forEach((v, k) => callbackfn(v, k, this)); + this.next?.forEach((v, k) => callbackfn(v, k, this)); + } + get size(): number { + return this.inner.size + (this.next?.size || 0); + } + } + + type RelationCache = ExpandableRelationshipCache; + const subtypeRelation = new ExpandableRelationshipCache(); + const strictSubtypeRelation = new ExpandableRelationshipCache(); + const assignableRelation = new ExpandableRelationshipCache(); + const comparableRelation = new ExpandableRelationshipCache(); + const identityRelation = new ExpandableRelationshipCache(); + const enumRelation = new ExpandableRelationshipCache(); const builtinGlobals = createSymbolTable(); builtinGlobals.set(undefinedSymbol.escapedName, undefinedSymbol); @@ -17583,7 +17624,7 @@ namespace ts { function checkTypeRelatedToAndOptionallyElaborate( source: Type, target: Type, - relation: ESMap, + relation: RelationCache, errorNode: Node | undefined, expr: Expression | undefined, headMessage: DiagnosticMessage | undefined, @@ -17605,7 +17646,7 @@ namespace ts { node: Expression | undefined, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, headMessage: DiagnosticMessage | undefined, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined @@ -17642,7 +17683,7 @@ namespace ts { node: Expression, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, headMessage: DiagnosticMessage | undefined, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined @@ -17671,7 +17712,7 @@ namespace ts { node: ArrowFunction, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined ): boolean { @@ -17758,7 +17799,7 @@ namespace ts { iterator: ElaborationIterator, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined ) { @@ -17876,7 +17917,7 @@ namespace ts { node: JsxAttributes, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined ) { @@ -17977,7 +18018,7 @@ namespace ts { node: ArrayLiteralExpression, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined ) { @@ -18030,7 +18071,7 @@ namespace ts { node: ObjectLiteralExpression, source: Type, target: Type, - relation: ESMap, + relation: RelationCache, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, errorOutputContainer: { errors?: Diagnostic[], skipLogging?: boolean } | undefined ) { @@ -18334,7 +18375,7 @@ namespace ts { return true; } - function isSimpleTypeRelatedTo(source: Type, target: Type, relation: ESMap, errorReporter?: ErrorReporter) { + function isSimpleTypeRelatedTo(source: Type, target: Type, relation: RelationCache, errorReporter?: ErrorReporter) { const s = source.flags; const t = target.flags; if (t & TypeFlags.AnyOrUnknown || s & TypeFlags.Never || source === wildcardType) return true; @@ -18375,7 +18416,7 @@ namespace ts { return false; } - function isTypeRelatedTo(source: Type, target: Type, relation: ESMap) { + function isTypeRelatedTo(source: Type, target: Type, relation: RelationCache) { if (isFreshLiteralType(source)) { source = (source as FreshableType).regularType; } @@ -18452,7 +18493,7 @@ namespace ts { function checkTypeRelatedTo( source: Type, target: Type, - relation: ESMap, + relation: RelationCache, errorNode: Node | undefined, headMessage?: DiagnosticMessage, containingMessageChain?: () => DiagnosticMessageChain | undefined, @@ -20991,7 +21032,7 @@ namespace ts { * To improve caching, the relation key for two generic types uses the target's id plus ids of the type parameters. * For other cases, the types ids are used. */ - function getRelationKey(source: Type, target: Type, intersectionState: IntersectionState, relation: ESMap, ignoreConstraints: boolean) { + function getRelationKey(source: Type, target: Type, intersectionState: IntersectionState, relation: RelationCache, ignoreConstraints: boolean) { if (relation === identityRelation && source.id > target.id) { const temp = source; source = target; @@ -30384,7 +30425,7 @@ namespace ts { function checkApplicableSignatureForJsxOpeningLikeElement( node: JsxOpeningLikeElement, signature: Signature, - relation: ESMap, + relation: RelationCache, checkMode: CheckMode, reportErrors: boolean, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, @@ -30487,7 +30528,7 @@ namespace ts { node: CallLikeExpression, args: readonly Expression[], signature: Signature, - relation: ESMap, + relation: RelationCache, checkMode: CheckMode, reportErrors: boolean, containingMessageChain: (() => DiagnosticMessageChain | undefined) | undefined, @@ -31051,7 +31092,7 @@ namespace ts { candidateForTypeArgumentError = oldCandidateForTypeArgumentError; } - function chooseOverload(candidates: Signature[], relation: ESMap, isSingleNonGenericCandidate: boolean, signatureHelpTrailingComma = false) { + function chooseOverload(candidates: Signature[], relation: RelationCache, isSingleNonGenericCandidate: boolean, signatureHelpTrailingComma = false) { candidatesForArgumentError = undefined; candidateForArgumentArityError = undefined; candidateForTypeArgumentError = undefined; @@ -41683,8 +41724,27 @@ namespace ts { const saveCurrentNode = currentNode; currentNode = node; instantiationCount = 0; + const saveNestedElementCacheContribution = nestedElementCacheContribution; + nestedElementCacheContribution = 0; + const startCacheSize = assignableRelation.size; checkSourceElementWorker(node); + const rawAssignabilityCacheContribution = assignableRelation.size - startCacheSize; + const assignabilityCacheContribution = rawAssignabilityCacheContribution - nestedElementCacheContribution; + nestedElementCacheContribution = saveNestedElementCacheContribution + assignabilityCacheContribution; currentNode = saveCurrentNode; + + // If a single source element triggers pulling in 1 million comparions, editor perf is likely very bad. + // Surprisingly, the choice of 1 million is not arbitrary - it's just under 2^20, the number of comparisons + // required to compare two 2^10-element unions naively in the worst case. That is juuust large enough to take + // a few seconds to check on a laptop, and thus for things to not *obviously* be wrong without an error. + // Two 2^12 unions takes about a minute. Larger powers of two aren't worth waiting for, and cause an obvious hang. + // The error is sometimes safe to `//@ts-ignore` if the perf is still OK, but does indicate a location + // that is likely triggering a performance problem. The types may be able to be restructured to + // get better perf, or the constructs in use may be a good candidate for specialized optimizations + // in the compiler itself to reduce the amount of work required. + if (assignabilityCacheContribution > 1_000_000) { + error(node, Diagnostics.This_source_element_is_ultimately_responsible_for_0_million_type_comparisons_It_is_likely_very_slow_and_may_impact_editor_performance_Simplify_the_types_in_use, Math.floor(assignabilityCacheContribution / 1_000_000)); + } } } diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 45792ea5096bd..f8738e29b96e4 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -6239,6 +6239,10 @@ "category": "Error", "code": 7061 }, + "This source element is ultimately responsible for {0} million type comparisons. It is likely very slow, and may impact editor performance. Simplify the types in use.": { + "category": "Error", + "code": 7062 + }, "You cannot rename this element.": { "category": "Error", diff --git a/tests/baselines/reference/comparisonCountLimits.errors.txt b/tests/baselines/reference/comparisonCountLimits.errors.txt new file mode 100644 index 0000000000000..6e5cbcea2c310 --- /dev/null +++ b/tests/baselines/reference/comparisonCountLimits.errors.txt @@ -0,0 +1,34 @@ +tests/cases/compiler/comparisonCountLimits.ts(9,17): error TS2590: Expression produces a union type that is too complex to represent. +tests/cases/compiler/comparisonCountLimits.ts(25,1): error TS7062: This source element is ultimately responsible for 1 million type comparisons. It is likely very slow, and may impact editor performance. Simplify the types in use. + + +==== tests/cases/compiler/comparisonCountLimits.ts (2 errors) ==== + function get10BitsOf() { + type Bit = T | U; // 2^1 + type HalfNibble = `${Bit}${Bit}`; // 2^2 + type Nibble = `${HalfNibble}${HalfNibble}`; // 2^4 + type Byte = `${Nibble}${Nibble}`; // 2^8 + type TenBits = `${Byte}${HalfNibble}`; // 2^10 (approx. 1 million comparisons if compared naively) + + type HalfWord = `${Byte}${Byte}`; // 2^16 // allowed, but test takes way too long if used + type Word = `${HalfWord}${HalfWord}`; // 2^32 (throws, too large) + ~~~~~~~~~~~~~~~~~~~~~~~~ +!!! error TS2590: Expression produces a union type that is too complex to represent. + + // Literal type relations are uncached (lol), so everything has to be wrapped in an object to affect cache sizes + // (A distributive conditional is the easiest way to do the mapping, but others are possible, eg, mapped types, + // or explicit construction) + type Box = T extends unknown ? {item: T} : never; + + // By manufacturing the fallback in here, we guarantee it has a higher typeid than the bit strings, + // and thus is sorted to the end of the union, guaranteeing relationship checking passes with a maximal + // number of comparisons when a naive comparison is done (guaranteeing this test is slow) + return null as any as Box; // return type is a union + } + + let a = get10BitsOf<"0", "1", "a" | "b">(); + const b = get10BitsOf<"a", "b", never>(); + + a = b; + ~~~~~~ +!!! error TS7062: This source element is ultimately responsible for 1 million type comparisons. It is likely very slow, and may impact editor performance. Simplify the types in use. \ No newline at end of file diff --git a/tests/baselines/reference/comparisonCountLimits.js b/tests/baselines/reference/comparisonCountLimits.js new file mode 100644 index 0000000000000..2f64d7c12f17f --- /dev/null +++ b/tests/baselines/reference/comparisonCountLimits.js @@ -0,0 +1,37 @@ +//// [comparisonCountLimits.ts] +function get10BitsOf() { + type Bit = T | U; // 2^1 + type HalfNibble = `${Bit}${Bit}`; // 2^2 + type Nibble = `${HalfNibble}${HalfNibble}`; // 2^4 + type Byte = `${Nibble}${Nibble}`; // 2^8 + type TenBits = `${Byte}${HalfNibble}`; // 2^10 (approx. 1 million comparisons if compared naively) + + type HalfWord = `${Byte}${Byte}`; // 2^16 // allowed, but test takes way too long if used + type Word = `${HalfWord}${HalfWord}`; // 2^32 (throws, too large) + + // Literal type relations are uncached (lol), so everything has to be wrapped in an object to affect cache sizes + // (A distributive conditional is the easiest way to do the mapping, but others are possible, eg, mapped types, + // or explicit construction) + type Box = T extends unknown ? {item: T} : never; + + // By manufacturing the fallback in here, we guarantee it has a higher typeid than the bit strings, + // and thus is sorted to the end of the union, guaranteeing relationship checking passes with a maximal + // number of comparisons when a naive comparison is done (guaranteeing this test is slow) + return null as any as Box; // return type is a union +} + +let a = get10BitsOf<"0", "1", "a" | "b">(); +const b = get10BitsOf<"a", "b", never>(); + +a = b; + +//// [comparisonCountLimits.js] +function get10BitsOf() { + // By manufacturing the fallback in here, we guarantee it has a higher typeid than the bit strings, + // and thus is sorted to the end of the union, guaranteeing relationship checking passes with a maximal + // number of comparisons when a naive comparison is done (guaranteeing this test is slow) + return null; // return type is a union +} +var a = get10BitsOf(); +var b = get10BitsOf(); +a = b; diff --git a/tests/cases/compiler/comparisonCountLimits.ts b/tests/cases/compiler/comparisonCountLimits.ts new file mode 100644 index 0000000000000..c61646d3a5eea --- /dev/null +++ b/tests/cases/compiler/comparisonCountLimits.ts @@ -0,0 +1,27 @@ +// @noTypesAndSymbols: true + +function get10BitsOf() { + type Bit = T | U; // 2^1 + type HalfNibble = `${Bit}${Bit}`; // 2^2 + type Nibble = `${HalfNibble}${HalfNibble}`; // 2^4 + type Byte = `${Nibble}${Nibble}`; // 2^8 + type TenBits = `${Byte}${HalfNibble}`; // 2^10 (approx. 1 million comparisons if compared naively) + + type HalfWord = `${Byte}${Byte}`; // 2^16 // allowed, but test takes way too long if used + type Word = `${HalfWord}${HalfWord}`; // 2^32 (throws, too large) + + // Literal type relations are uncached (lol), so everything has to be wrapped in an object to affect cache sizes + // (A distributive conditional is the easiest way to do the mapping, but others are possible, eg, mapped types, + // or explicit construction) + type Box = T extends unknown ? {item: T} : never; + + // By manufacturing the fallback in here, we guarantee it has a higher typeid than the bit strings, + // and thus is sorted to the end of the union, guaranteeing relationship checking passes with a maximal + // number of comparisons when a naive comparison is done (guaranteeing this test is slow) + return null as any as Box; // return type is a union +} + +let a = get10BitsOf<"0", "1", "a" | "b">(); +const b = get10BitsOf<"a", "b", never>(); + +a = b; \ No newline at end of file