Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache mapper instantiations #61505

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

Andarist
Copy link
Contributor

@Andarist Andarist commented Mar 30, 2025

@ssalbdivad found out that a chain of somewhat trivial operations can easily end up with dreaded "Type instantiation is excessively deep and possibly infinite".

Two different reproductions were created:

  1. standalone signature receiving its previous output as input in a chain: TS playground
  2. a dotted chain where a method returns a new instance of its own enclosing type alias: TS playground

We were able to bisect both to two different past PRs - one of which is quite old:

  1. the first one bisects to this PR and we can see it performing better in TS 5.4 here
  2. the other one bisects to this PR and we can see it performing better in TS 4.1 here

⚠️ that said - the "chain setup" avoids the error better but the property type lookup can still lead to the error. So, in some sense, the error just shifted place. this has been solved

I dug into this and what I found out is, those types refer to the same "base" types - they are chained from them and instantiateType repeats the work on them a lot. At least part of the problem is that getObjectTypeInstantiation calls instantiateTypes(type.aliasTypeArguments, mapper) to create part of its cache key. So it can't even check if the result is cached before it instantiates those. This problem compounds heavily in longer chains like this.

So I toyed with possible solutions and I realized that caching results on mappers could help here... to some extent at least. I don't think it's the best solution but I also don't yet understand this problem to its core to propose anything better.

It looks like this has very positive impact on instantiation counts across the test suite. It surely trades some memory for it though. It would be interesting to see perf results for this and the extended test suite run (even if only to get new data points for further investigation).

EDIT:// @ssalbdivad has tested this in some trpc-based repositories and the check time got halved for them. The current version is also able to typecheck the 50-item deep "chains" as it can be seen in the added long* tests. Those previously were only instantiable to a certain depth (smth between 15-25, depending on the test case) and the last items in the chain were being instantiated really slow. Now those longer ones are handled pretty fast.

@github-project-automation github-project-automation bot moved this to Not started in PR Backlog Mar 30, 2025
@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Mar 30, 2025
@typescript-bot
Copy link
Collaborator

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

@@ -19960,7 +19961,9 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
}

function makeFunctionTypeMapper(func: (t: Type) => Type, debugInfo: () => string): TypeMapper {
return Debug.attachDebugPrototypeIfDebug({ kind: TypeMapKind.Function, func, debugInfo: Debug.isDebugging ? debugInfo : undefined });
const mapper = Debug.attachDebugPrototypeIfDebug({ kind: TypeMapKind.Function, func, debugInfo: Debug.isDebugging ? debugInfo : undefined });
mapper.instantiations = voidMap;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those mappers are "global" and this is just a quick hack to prevent this caching mechanism to kick in on them

Comment on lines 25589 to 25591
if (context) {
context.nonFixingMapper.instantiations = undefined;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonFixingMapper isn't idempotent so whenever inferences are cleared it's instantiation cache has to be cleared too

@jakebailey
Copy link
Member

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 30, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started
user test this ✅ Started
run dt ✅ Started
perf test this faster ✅ Started

@jakebailey
Copy link
Member

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 31, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started 👀 Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @jakebailey, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@typescript-bot
Copy link
Collaborator

@jakebailey Here are the results of running the user tests with tsc comparing main and refs/pull/61505/merge:

Something interesting changed - please have a look.

Details

effect

packages/effect/benchmark/tsconfig.json

tsconfig.json

tsconfig.build.json

tsconfig.base.json

packages/typeclass/dtslint/tsconfig.json

packages/platform/dtslint/tsconfig.json

packages/effect/dtslint/tsconfig.json

@typescript-bot
Copy link
Collaborator

@jakebailey
The results of the perf run you requested are in!

Here they are:

tsc

Comparison Report - baseline..pr
Metric baseline pr Delta Best Worst p-value
Compiler-Unions - node (v18.15.0, x64)
Errors 34 34 ~ ~ ~ p=1.000 n=6
Symbols 62,390 62,390 ~ ~ ~ p=1.000 n=6
Types 50,395 50,395 ~ ~ ~ p=1.000 n=6
Memory used 194,709k (± 1.01%) 196,843k (± 0.98%) ~ 194,935k 198,710k p=0.173 n=6
Parse Time 1.31s (± 0.31%) 1.31s (± 0.57%) ~ 1.30s 1.32s p=1.000 n=6
Bind Time 0.73s 0.73s ~ ~ ~ p=1.000 n=6
Check Time 9.73s (± 0.49%) 9.77s (± 0.35%) ~ 9.71s 9.81s p=0.228 n=6
Emit Time 2.73s (± 0.54%) 2.73s (± 0.65%) ~ 2.71s 2.75s p=0.935 n=6
Total Time 14.51s (± 0.37%) 14.54s (± 0.16%) ~ 14.50s 14.56s p=0.253 n=6
angular-1 - node (v18.15.0, x64)
Errors 56 56 ~ ~ ~ p=1.000 n=6
Symbols 948,670 948,670 ~ ~ ~ p=1.000 n=6
Types 410,947 410,947 ~ ~ ~ p=1.000 n=6
Memory used 1,224,296k (± 0.00%) 1,248,572k (± 0.00%) +24,276k (+ 1.98%) 1,248,514k 1,248,618k p=0.005 n=6
Parse Time 6.63s (± 1.21%) 6.63s (± 0.67%) ~ 6.56s 6.70s p=1.000 n=6
Bind Time 1.88s (± 0.67%) 1.88s (± 0.73%) ~ 1.87s 1.90s p=0.557 n=6
Check Time 31.93s (± 0.37%) 32.31s (± 0.31%) +0.39s (+ 1.21%) 32.18s 32.47s p=0.005 n=6
Emit Time 15.23s (± 0.22%) 15.26s (± 0.24%) ~ 15.21s 15.32s p=0.147 n=6
Total Time 55.67s (± 0.25%) 56.09s (± 0.21%) +0.42s (+ 0.75%) 55.98s 56.31s p=0.005 n=6
mui-docs - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 2,461,267 2,461,267 ~ ~ ~ p=1.000 n=6
Types 893,340 893,340 ~ ~ ~ p=1.000 n=6
Memory used 2,310,804k (± 0.00%) 2,342,249k (± 0.00%) +31,445k (+ 1.36%) 2,342,222k 2,342,302k p=0.005 n=6
Parse Time 9.04s (± 0.22%) 9.04s (± 0.17%) ~ 9.02s 9.06s p=1.000 n=6
Bind Time 2.27s (± 0.36%) 2.26s (± 0.53%) ~ 2.25s 2.28s p=0.652 n=6
Check Time 75.67s (± 0.38%) 75.95s (± 0.42%) ~ 75.43s 76.42s p=0.173 n=6
Emit Time 0.29s (± 1.92%) 0.29s (± 1.80%) ~ 0.28s 0.29s p=0.640 n=6
Total Time 87.26s (± 0.34%) 87.55s (± 0.35%) ~ 87.03s 87.99s p=0.173 n=6
self-build-src - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,226,713 1,226,807 +94 (+ 0.01%) ~ ~ p=0.001 n=6
Types 266,991 267,000 +9 (+ 0.00%) ~ ~ p=0.001 n=6
Memory used 2,357,616k (± 0.03%) 2,376,351k (± 0.02%) +18,735k (+ 0.79%) 2,375,702k 2,377,087k p=0.005 n=6
Parse Time 5.20s (± 1.17%) 5.17s (± 0.50%) ~ 5.14s 5.21s p=0.297 n=6
Bind Time 1.79s (± 1.91%) 1.78s (± 0.68%) ~ 1.77s 1.80s p=0.744 n=6
Check Time 35.40s (± 0.56%) 35.58s (± 0.33%) ~ 35.41s 35.75s p=0.066 n=6
Emit Time 2.99s (± 2.22%) 2.99s (± 1.20%) ~ 2.96s 3.06s p=0.630 n=6
Total Time 45.40s (± 0.72%) 45.53s (± 0.17%) ~ 45.45s 45.62s p=0.066 n=6
self-build-src-public-api - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,226,713 1,226,807 +94 (+ 0.01%) ~ ~ p=0.001 n=6
Types 266,991 267,000 +9 (+ 0.00%) ~ ~ p=0.001 n=6
Memory used 2,911,961k (±12.87%) 3,067,098k (± 9.90%) 🔻+155,137k (+ 5.33%) 2,446,535k 3,191,944k p=0.031 n=6
Parse Time 6.94s (± 1.45%) 6.94s (± 1.80%) ~ 6.70s 7.04s p=1.000 n=6
Bind Time 2.14s (± 2.28%) 2.12s (± 1.06%) ~ 2.08s 2.14s p=0.810 n=6
Check Time 42.65s (± 0.57%) 42.85s (± 0.37%) ~ 42.57s 43.01s p=0.128 n=6
Emit Time 3.45s (± 2.05%) 3.53s (± 0.82%) +0.07s (+ 2.17%) 3.49s 3.58s p=0.044 n=6
Total Time 55.20s (± 0.59%) 55.45s (± 0.48%) ~ 54.92s 55.69s p=0.128 n=6
self-compiler - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 262,674 262,691 +17 (+ 0.01%) ~ ~ p=0.001 n=6
Types 106,849 106,858 +9 (+ 0.01%) ~ ~ p=0.001 n=6
Memory used 440,486k (± 0.01%) 449,610k (± 0.02%) +9,124k (+ 2.07%) 449,509k 449,723k p=0.005 n=6
Parse Time 3.54s (± 0.42%) 3.51s (± 0.91%) ~ 3.48s 3.57s p=0.052 n=6
Bind Time 1.31s (± 0.39%) 1.32s (± 0.80%) ~ 1.30s 1.33s p=0.794 n=6
Check Time 19.05s (± 0.31%) 19.04s (± 0.36%) ~ 18.93s 19.13s p=0.810 n=6
Emit Time 1.52s (± 0.65%) 1.53s (± 0.79%) ~ 1.51s 1.54s p=0.246 n=6
Total Time 25.43s (± 0.27%) 25.39s (± 0.32%) ~ 25.29s 25.53s p=0.423 n=6
ts-pre-modules - node (v18.15.0, x64)
Errors 71 71 ~ ~ ~ p=1.000 n=6
Symbols 225,981 225,981 ~ ~ ~ p=1.000 n=6
Types 94,356 94,356 ~ ~ ~ p=1.000 n=6
Memory used 371,214k (± 0.01%) 379,734k (± 0.01%) +8,519k (+ 2.29%) 379,686k 379,811k p=0.005 n=6
Parse Time 2.89s (± 0.60%) 2.91s (± 0.96%) ~ 2.86s 2.93s p=0.192 n=6
Bind Time 1.61s (± 1.69%) 1.60s (± 1.46%) ~ 1.57s 1.63s p=0.333 n=6
Check Time 16.51s (± 0.33%) 16.54s (± 0.26%) ~ 16.49s 16.61s p=0.574 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 21.02s (± 0.26%) 21.04s (± 0.25%) ~ 20.99s 21.12s p=0.628 n=6
vscode - node (v18.15.0, x64)
Errors 3 3 ~ ~ ~ p=1.000 n=6
Symbols 3,336,059 3,336,059 ~ ~ ~ p=1.000 n=6
Types 1,130,507 1,130,507 ~ ~ ~ p=1.000 n=6
Memory used 3,394,122k (± 0.01%) 3,466,746k (± 0.01%) +72,624k (+ 2.14%) 3,466,227k 3,466,964k p=0.005 n=6
Parse Time 14.70s (± 0.34%) 14.60s (± 0.53%) -0.10s (- 0.71%) 14.51s 14.69s p=0.030 n=6
Bind Time 4.67s (± 0.62%) 4.69s (± 0.61%) ~ 4.66s 4.74s p=0.514 n=6
Check Time 93.33s (± 2.44%) 93.32s (± 3.35%) ~ 91.13s 98.75s p=0.936 n=6
Emit Time 29.88s (± 2.66%) 30.10s (± 2.92%) ~ 29.44s 31.36s p=0.575 n=6
Total Time 142.58s (± 2.06%) 142.71s (± 2.81%) ~ 139.84s 149.43s p=1.000 n=6
webpack - node (v18.15.0, x64)
Errors 2 2 ~ ~ ~ p=1.000 n=6
Symbols 310,441 310,441 ~ ~ ~ p=1.000 n=6
Types 136,030 136,030 ~ ~ ~ p=1.000 n=6
Memory used 465,164k (± 0.02%) 475,453k (± 0.01%) +10,290k (+ 2.21%) 475,362k 475,517k p=0.005 n=6
Parse Time 5.15s (± 0.56%) 5.16s (± 0.94%) ~ 5.13s 5.26s p=0.418 n=6
Bind Time 2.26s (± 0.99%) 2.26s (± 1.23%) ~ 2.24s 2.31s p=0.934 n=6
Check Time 25.48s (± 0.42%) 25.67s (± 0.18%) +0.19s (+ 0.73%) 25.62s 25.74s p=0.005 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 32.89s (± 0.34%) 33.09s (± 0.27%) +0.21s (+ 0.63%) 33.00s 33.25s p=0.013 n=6
xstate-main - node (v18.15.0, x64)
Errors 5 5 ~ ~ ~ p=1.000 n=6
Symbols 567,041 567,041 ~ ~ ~ p=1.000 n=6
Types 189,889 189,889 ~ ~ ~ p=1.000 n=6
Memory used 497,780k (± 0.02%) 519,637k (± 0.02%) 🔻+21,857k (+ 4.39%) 519,555k 519,800k p=0.005 n=6
Parse Time 3.36s (± 0.81%) 3.37s (± 0.62%) ~ 3.34s 3.40s p=0.570 n=6
Bind Time 1.21s (± 1.10%) 1.20s (± 1.14%) ~ 1.19s 1.23s p=0.323 n=6
Check Time 19.99s (± 0.10%) 19.81s (± 0.38%) -0.18s (- 0.91%) 19.67s 19.88s p=0.004 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 24.56s (± 0.12%) 24.38s (± 0.42%) -0.18s (- 0.75%) 24.21s 24.51s p=0.006 n=6
System info unknown
Hosts
  • node (v18.15.0, x64)
Scenarios
  • Compiler-Unions - node (v18.15.0, x64)
  • angular-1 - node (v18.15.0, x64)
  • mui-docs - node (v18.15.0, x64)
  • self-build-src - node (v18.15.0, x64)
  • self-build-src-public-api - node (v18.15.0, x64)
  • self-compiler - node (v18.15.0, x64)
  • ts-pre-modules - node (v18.15.0, x64)
  • vscode - node (v18.15.0, x64)
  • webpack - node (v18.15.0, x64)
  • xstate-main - node (v18.15.0, x64)
Benchmark Name Iterations
Current pr 6
Baseline baseline 6

Developer Information:

Download Benchmarks

@jakebailey
Copy link
Member

@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Mar 31, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
pack this ✅ Started ❌ Results

@typescript-bot
Copy link
Collaborator

Hey @jakebailey, something went wrong when looking for the build artifact. (You can check the log here).

@typescript-bot
Copy link
Collaborator

@jakebailey Here are the results of running the top 400 repos with tsc comparing main and refs/pull/61505/merge:

Everything looks good!

@ssalbdivad
Copy link

This is a set of instantiation benchmarks with results from 5.8.2 for related scenarios.

These can be run against the installed TS version by executing this with node or similar, or could just be used as a reference when constructing benchmarks for TS directly:

import { bench } from "@ark/attest"

type merge<base, props> = Omit<base, keyof props & keyof base> & props

declare const merge: <l, r>(l: l, r: r) => merge<l, r>

bench("functional(10)", () => {
	const a = merge({ a: 1 }, { b: 2 })
	const b = merge(a, { c: 3 })
	const c = merge(b, { d: 4 })
	const d = merge(c, { e: 5 })
	const e = merge(d, { f: 6 })
	const f = merge(e, { g: 7 })
	const g = merge(f, { h: 8 })
	const h = merge(g, { i: 9 })
	const i = merge(h, { j: 10 })
}).types([59386, "instantiations"])

bench("functional(11)", () => {
	const a = merge({ a: 1 }, { b: 2 })
	const b = merge(a, { c: 3 })
	const c = merge(b, { d: 4 })
	const d = merge(c, { e: 5 })
	const e = merge(d, { f: 6 })
	const f = merge(e, { g: 7 })
	const g = merge(f, { h: 8 })
	const h = merge(g, { i: 9 })
	const i = merge(h, { j: 10 })
	const j = merge(i, { k: 11 })
}).types([177537, "instantiations"])

bench("functional(12)", () => {
	const a = merge({ a: 1 }, { b: 2 })
	const b = merge(a, { c: 3 })
	const c = merge(b, { d: 4 })
	const d = merge(c, { e: 5 })
	const e = merge(d, { f: 6 })
	const f = merge(e, { g: 7 })
	const g = merge(f, { h: 8 })
	const h = merge(g, { i: 9 })
	const i = merge(h, { j: 10 })
	const j = merge(i, { k: 11 })
	const k = merge(j, { l: 12 })
	// should be linear relative to functional(10) and functional(11)
}).types([531887, "instantiations"])

bench("functional(12) with spread", () => {
	const a = merge({ a: 1 }, { b: 2 })
	const b = merge(a, { c: 3 })
	const c = merge(b, { d: 4 })
	const d = merge(c, { e: 5 })
	const e = merge(d, { f: 6 })
	const f = merge(e, { g: 7 })
	const g = merge(f, { h: 8 })
	const h = merge(g, { i: 9 })
	const i = merge(h, { j: 10 })
	const j = merge(i, { k: 11 })
	const k = merge(j, { l: 12 })

	return {
		...i,
		...j,
		...k
	}
	// should be similar to functional(12)
}).types([797849, "instantiations"])

type Type<t> = {
	merge: <r>(r: r) => Type<merge<t, r>>
	unwrapped: t
}

declare const a: Type<{ a: 1 }>

bench("chained(10)", () => {
	const b = a.merge({ b: 2 })
	const c = b.merge({ c: 3 })
	const d = c.merge({ d: 4 })
	const e = d.merge({ e: 5 })
	const f = e.merge({ f: 6 })
	const g = f.merge({ g: 7 })
	const h = g.merge({ h: 8 })
	const i = h.merge({ i: 9 })
	const j = i.merge({ j: 10 })
}).types([178394, "instantiations"])

bench("chained(11)", () => {
	const b = a.merge({ b: 2 })
	const c = b.merge({ c: 3 })
	const d = c.merge({ d: 4 })
	const e = d.merge({ e: 5 })
	const f = e.merge({ f: 6 })
	const g = f.merge({ g: 7 })
	const h = g.merge({ h: 8 })
	const i = h.merge({ i: 9 })
	const j = i.merge({ j: 10 })
	const k = j.merge({ k: 11 })
}).types([532898, "instantiations"])

bench("chained(12)", () => {
	const b = a.merge({ b: 2 })
	const c = b.merge({ c: 3 })
	const d = c.merge({ d: 4 })
	const e = d.merge({ e: 5 })
	const f = e.merge({ f: 6 })
	const g = f.merge({ g: 7 })
	const h = g.merge({ h: 8 })
	const i = h.merge({ i: 9 })
	const j = i.merge({ j: 10 })
	const k = j.merge({ k: 11 })
	const l = k.merge({ l: 12 })
	// should be linear relative to chained(10) and chained(11)
}).types([1596004, "instantiations"])

bench("chained(12) with spread", () => {
	const b = a.merge({ b: 2 })
	const c = b.merge({ c: 3 })
	const d = c.merge({ d: 4 })
	const e = d.merge({ e: 5 })
	const f = e.merge({ f: 6 })
	const g = f.merge({ g: 7 })
	const h = g.merge({ h: 8 })
	const i = h.merge({ i: 9 })
	const j = i.merge({ j: 10 })
	const k = j.merge({ k: 11 })
	const l = k.merge({ l: 12 })

	return {
		...i.unwrapped,
		...j.unwrapped,
		...k.unwrapped
	}
	// should be similar to chained(12)
}).types([1684778, "instantiations"])

I would expect the instantiation scaling to not be purely linear for this sort of operation as the number of props is increasing, but it should be relatively close. None of these scenarios should result in the exponential scaling we see currently.

I'm also including the original end-to-end type benchmarks that identified this issue here. Once the minimal repo scaling has been addressed, we should sanity check that the scaling here is also fixed:

import { bench } from '@ark/attest';
import { initTRPC } from '@trpc/server';
import { type } from 'arktype';
import { z } from 'zod';

const t = initTRPC.create();

// avoid pollution from one-time library setup
bench.baseline(() => {
  const router = t.router({
    baseline: t.procedure
      .input(
        z.object({
          baseline: z.string(),
        }),
      )
      .query(({ input }) => `hello ${input.baseline}`),
    arkBaseline: t.procedure
      .input(
        type({
          baseline: 'string',
        }),
      )
      .query(({ input }) => `hello ${input.baseline}`),
  });
});

const base = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});

bench('non-sequential Zod type', async () => {
  const nonSequentialRouter = t.router({
    query1: t.procedure.input(base).query(({ input }) => `hello ${input.a}`),
    mutation1: t.procedure
      .input(base)
      .mutation(({ input }) => `hello ${input.a}`),
  });

  // this is relatively cheap
}).types([1659, 'instantiations']);

// even though sequential is totally equivalent to nonSequential, its
// Zod representation is not reduced and still includes intermediate operations
const sequential = base
  .partial()
  .merge(base)
  .pick({ a: true, b: true, c: true })
  .omit({})
  .merge(base);

const base2 = z.object({
  d: z.string(),
  e: z.string(),
  f: z.string(),
});

bench('isolated sequential zod', () => {
  const sequential = base2
    .partial()
    .merge(base2)
    .pick({ d: true, e: true, f: true })
    .omit({})
    .merge(base2);
  // this is expensive
}).types([11420, 'instantiations']);

bench('sequential Zod type', async () => {
  const sequentialRouter = t.router({
    query1: t.procedure
      .input(sequential)
      .query(({ input }) => `hello ${input.a}`),
    mutation1: t.procedure
      .input(sequential)
      .mutation(({ input }) => `hello ${input.a}`),
  });

  // but it's in combination with trpc that these sequentially evaluated
  // Zod types get out of control. instead of incurring a 1-time evaluation
  // cost, it seems it can't be cached and the extra inference cost
  // is incurred multiple times (even worse with deepPartial)
}).types([49173, 'instantiations']);

const arkBase = type({
  a: 'string',
  b: 'string',
  c: 'string',
});

bench('non-sequential arktype', async () => {
  const sequentialRouter = t.router({
    query1: t.procedure.input(arkBase).query(({ input }) => `hello ${input.a}`),
    mutation1: t.procedure
      .input(arkBase)
      .mutation(({ input }) => `hello ${input.a}`),
  });

  // realtively cheap
}).types([2961, 'instantiations']);

const arkBase2 = type({
  d: 'string',
  e: 'string',
  f: 'string',
});

bench('isolated sequential arktype', () => {
  arkBase2.partial().merge(arkBase2).pick('d', 'e', 'f').omit().merge(arkBase2);
  // these kind of operations are much cheaper in ArkType than Zod
}).types([2336, 'instantiations']);

const arkSequential = arkBase
  .partial()
  .merge(arkBase)
  .pick('a', 'b', 'c')
  .omit()
  .merge(arkBase);

bench('sequential arktype', async () => {
  const sequentialRouter = t.router({
    query1: t.procedure
      .input(arkSequential)
      .query(({ input }) => `hello ${input.a}`),
    mutation1: t.procedure
      .input(arkSequential)
      .mutation(({ input }) => `hello ${input.a}`),
  });

  // even though hovering arkSequential is identical to hovering arkBase,
  // TS still seems to do a lot of repeated work inferring it somehow (though less than Zod)
}).types([17906, 'instantiations']);

@Andarist Andarist force-pushed the cache-mapper-instaniations branch from 688f5d5 to b0f5c70 Compare April 1, 2025 08:00
@Andarist
Copy link
Contributor Author

Andarist commented Apr 1, 2025

@ssalbdivad tested this in a trpc-based repo.

TS 5.8:

Types:                       139629
Instantiations:            12133763
Memory used:                798162K
Check time:                  13.04s

This branch (commit b0f5c70):

Types:                      139629
Instantiations:             817105
Memory used:               819990K
Check time:                  6.00s

@jakebailey
Copy link
Member

@typescript-bot test it
@typescript-bot pack this

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 1, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
pack this ✅ Started ✅ Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@jakebailey
Copy link
Member

The new state seems less hacky for sure, though Map equality is unlikely to be portable to Go, so that's sort of a problem.

@typescript-bot test it

@typescript-bot
Copy link
Collaborator

typescript-bot commented Apr 4, 2025

Starting jobs; this comment will be updated as builds start and complete.

Command Status Results
test top400 ✅ Started ✅ Results
user test this ✅ Started ✅ Results
run dt ✅ Started ✅ Results
perf test this faster ✅ Started 👀 Results

@typescript-bot
Copy link
Collaborator

Hey @jakebailey, the results of running the DT tests are ready.

Everything looks the same!

You can check the log here.

@typescript-bot
Copy link
Collaborator

@jakebailey Here are the results of running the user tests with tsc comparing main and refs/pull/61505/merge:

Everything looks good!

@Andarist
Copy link
Contributor Author

Andarist commented Apr 4, 2025

@jakebailey I don't think the portability should be a concern now. I can tweak this further or just handle it differently in the Go version. First, it would be great to get some eyes on this if this makes sense at all. Cause if it doesn't - what's even the point of code golfing this? ;p

@typescript-bot
Copy link
Collaborator

@jakebailey
The results of the perf run you requested are in!

Here they are:

tsc

Comparison Report - baseline..pr
Metric baseline pr Delta Best Worst p-value
Compiler-Unions - node (v18.15.0, x64)
Errors 34 34 ~ ~ ~ p=1.000 n=6
Symbols 62,390 62,390 ~ ~ ~ p=1.000 n=6
Types 50,395 50,395 ~ ~ ~ p=1.000 n=6
Memory used 194,172k (± 0.94%) 197,362k (± 0.96%) +3,191k (+ 1.64%) 194,893k 198,733k p=0.031 n=6
Parse Time 1.31s (± 0.48%) 1.31s (± 0.31%) ~ 1.30s 1.31s p=0.673 n=6
Bind Time 0.73s 0.73s ~ ~ ~ p=1.000 n=6
Check Time 9.72s (± 0.42%) 9.77s (± 0.21%) ~ 9.75s 9.80s p=0.073 n=6
Emit Time 2.73s (± 0.89%) 2.74s (± 0.84%) ~ 2.71s 2.77s p=0.287 n=6
Total Time 14.48s (± 0.22%) 14.54s (± 0.15%) +0.06s (+ 0.40%) 14.50s 14.56s p=0.007 n=6
angular-1 - node (v18.15.0, x64)
Errors 56 56 ~ ~ ~ p=1.000 n=6
Symbols 948,670 948,670 ~ ~ ~ p=1.000 n=6
Types 410,947 410,947 ~ ~ ~ p=1.000 n=6
Memory used 1,224,301k (± 0.00%) 1,248,672k (± 0.00%) +24,371k (+ 1.99%) 1,248,631k 1,248,704k p=0.005 n=6
Parse Time 6.67s (± 0.22%) 6.67s (± 0.22%) ~ 6.65s 6.69s p=0.744 n=6
Bind Time 1.89s (± 0.78%) 1.89s (± 0.58%) ~ 1.88s 1.90s p=0.866 n=6
Check Time 31.97s (± 0.34%) 32.32s (± 0.39%) +0.35s (+ 1.11%) 32.23s 32.52s p=0.005 n=6
Emit Time 15.27s (± 0.28%) 15.31s (± 0.49%) ~ 15.17s 15.37s p=0.109 n=6
Total Time 55.79s (± 0.21%) 56.19s (± 0.30%) +0.40s (+ 0.72%) 55.95s 56.39s p=0.008 n=6
mui-docs - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 2,460,247 2,460,247 ~ ~ ~ p=1.000 n=6
Types 892,978 892,978 ~ ~ ~ p=1.000 n=6
Memory used 2,308,103k (± 0.00%) 2,339,958k (± 0.01%) +31,855k (+ 1.38%) 2,339,764k 2,340,072k p=0.005 n=6
Parse Time 9.01s (± 0.22%) 8.99s (± 0.23%) ~ 8.97s 9.02s p=0.168 n=6
Bind Time 2.27s (± 0.37%) 2.25s (± 0.44%) -0.01s (- 0.59%) 2.24s 2.26s p=0.037 n=6
Check Time 75.53s (± 0.60%) 76.20s (± 1.21%) ~ 75.54s 77.97s p=0.066 n=6
Emit Time 0.29s (± 1.40%) 0.29s (± 1.92%) ~ 0.28s 0.29s p=0.054 n=6
Total Time 87.10s (± 0.52%) 87.72s (± 1.04%) ~ 87.07s 89.47s p=0.149 n=6
self-build-src - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,226,719 1,226,859 +140 (+ 0.01%) ~ ~ p=0.001 n=6
Types 266,996 267,020 +24 (+ 0.01%) ~ ~ p=0.001 n=6
Memory used 2,357,564k (± 0.01%) 2,376,098k (± 0.02%) +18,534k (+ 0.79%) 2,375,215k 2,376,604k p=0.005 n=6
Parse Time 5.18s (± 1.20%) 5.19s (± 0.72%) ~ 5.14s 5.24s p=0.297 n=6
Bind Time 1.79s (± 0.77%) 1.79s (± 1.35%) ~ 1.75s 1.82s p=0.255 n=6
Check Time 35.40s (± 0.39%) 35.55s (± 0.16%) ~ 35.47s 35.64s p=0.109 n=6
Emit Time 3.02s (± 1.00%) 3.00s (± 0.98%) ~ 2.95s 3.03s p=0.377 n=6
Total Time 45.36s (± 0.34%) 45.54s (± 0.14%) ~ 45.46s 45.62s p=0.066 n=6
self-build-src-public-api - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 1,226,719 1,226,859 +140 (+ 0.01%) ~ ~ p=0.001 n=6
Types 266,996 267,020 +24 (+ 0.01%) ~ ~ p=0.001 n=6
Memory used 3,153,075k (± 0.03%) 2,942,758k (±13.06%) ~ 2,445,100k 3,191,305k p=0.378 n=6
Parse Time 7.02s (± 0.99%) 6.93s (± 1.75%) ~ 6.76s 7.05s p=0.336 n=6
Bind Time 2.13s (± 1.41%) 2.14s (± 2.31%) ~ 2.08s 2.22s p=0.572 n=6
Check Time 43.00s (± 0.18%) 43.13s (± 0.58%) ~ 42.67s 43.36s p=0.173 n=6
Emit Time 3.56s (± 0.63%) 3.57s (± 4.15%) ~ 3.38s 3.80s p=0.689 n=6
Total Time 55.72s (± 0.15%) 55.77s (± 0.54%) ~ 55.46s 56.23s p=1.000 n=6
self-compiler - node (v18.15.0, x64)
Errors 0 0 ~ ~ ~ p=1.000 n=6
Symbols 262,674 262,679 +5 (+ 0.00%) ~ ~ p=0.001 n=6
Types 106,849 106,846 -3 (- 0.00%) ~ ~ p=0.001 n=6
Memory used 440,482k (± 0.02%) 449,591k (± 0.01%) +9,109k (+ 2.07%) 449,518k 449,707k p=0.005 n=6
Parse Time 3.56s (± 0.70%) 3.54s (± 0.68%) ~ 3.51s 3.58s p=0.332 n=6
Bind Time 1.33s (± 0.91%) 1.32s (± 1.04%) ~ 1.30s 1.34s p=0.215 n=6
Check Time 18.91s (± 0.50%) 19.07s (± 0.43%) +0.16s (+ 0.85%) 18.99s 19.21s p=0.025 n=6
Emit Time 1.53s (± 1.53%) 1.53s (± 1.01%) ~ 1.51s 1.55s p=1.000 n=6
Total Time 25.32s (± 0.41%) 25.46s (± 0.40%) ~ 25.35s 25.62s p=0.066 n=6
ts-pre-modules - node (v18.15.0, x64)
Errors 71 71 ~ ~ ~ p=1.000 n=6
Symbols 225,981 225,981 ~ ~ ~ p=1.000 n=6
Types 94,356 94,356 ~ ~ ~ p=1.000 n=6
Memory used 371,232k (± 0.02%) 379,783k (± 0.02%) +8,552k (+ 2.30%) 379,716k 379,921k p=0.005 n=6
Parse Time 2.90s (± 0.84%) 2.89s (± 0.93%) ~ 2.86s 2.92s p=0.746 n=6
Bind Time 1.59s (± 1.47%) 1.59s (± 1.56%) ~ 1.56s 1.63s p=0.871 n=6
Check Time 16.55s (± 0.30%) 16.57s (± 0.13%) ~ 16.53s 16.59s p=0.627 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 21.05s (± 0.25%) 21.05s (± 0.26%) ~ 20.97s 21.13s p=0.872 n=6
vscode - node (v18.15.0, x64)
Errors 4 4 ~ ~ ~ p=1.000 n=6
Symbols 3,339,505 3,339,505 ~ ~ ~ p=1.000 n=6
Types 1,131,627 1,131,627 ~ ~ ~ p=1.000 n=6
Memory used 3,397,616k (± 0.00%) 3,470,855k (± 0.00%) +73,239k (+ 2.16%) 3,470,728k 3,470,914k p=0.005 n=6
Parse Time 14.65s (± 0.64%) 14.65s (± 0.73%) ~ 14.52s 14.80s p=0.936 n=6
Bind Time 4.64s (± 0.27%) 4.67s (± 1.36%) ~ 4.61s 4.79s p=0.413 n=6
Check Time 90.59s (± 1.30%) 91.67s (± 1.66%) ~ 90.47s 94.70s p=0.066 n=6
Emit Time 29.31s (± 1.40%) 29.57s (± 2.11%) ~ 29.18s 30.83s p=0.173 n=6
Total Time 139.19s (± 0.82%) 140.56s (± 1.46%) ~ 139.17s 144.69s p=0.173 n=6
webpack - node (v18.15.0, x64)
Errors 2 2 ~ ~ ~ p=1.000 n=6
Symbols 311,557 311,557 ~ ~ ~ p=1.000 n=6
Types 137,100 137,100 ~ ~ ~ p=1.000 n=6
Memory used 466,366k (± 0.04%) 476,819k (± 0.02%) +10,453k (+ 2.24%) 476,705k 476,945k p=0.005 n=6
Parse Time 5.11s (± 0.61%) 5.14s (± 1.27%) ~ 5.03s 5.22s p=0.244 n=6
Bind Time 2.25s (± 1.44%) 2.26s (± 1.15%) ~ 2.23s 2.29s p=0.571 n=6
Check Time 25.39s (± 0.74%) 25.57s (± 0.17%) ~ 25.52s 25.61s p=0.053 n=6
Emit Time 0.00s 0.00s (±244.70%) ~ 0.00s 0.01s p=0.405 n=6
Total Time 32.76s (± 0.65%) 32.97s (± 0.28%) ~ 32.84s 33.05s p=0.065 n=6
xstate-main - node (v18.15.0, x64)
Errors 5 5 ~ ~ ~ p=1.000 n=6
Symbols 567,397 567,397 ~ ~ ~ p=1.000 n=6
Types 190,073 190,073 ~ ~ ~ p=1.000 n=6
Memory used 497,974k (± 0.01%) 520,814k (± 0.02%) 🔻+22,841k (+ 4.59%) 520,719k 521,002k p=0.005 n=6
Parse Time 3.36s (± 0.70%) 3.36s (± 1.28%) ~ 3.31s 3.42s p=1.000 n=6
Bind Time 1.21s (± 0.81%) 1.21s (± 0.68%) ~ 1.20s 1.22s p=0.394 n=6
Check Time 20.01s (± 0.42%) 19.66s (± 0.50%) -0.35s (- 1.76%) 19.53s 19.80s p=0.005 n=6
Emit Time 0.00s 0.00s ~ ~ ~ p=1.000 n=6
Total Time 24.58s (± 0.41%) 24.23s (± 0.45%) -0.36s (- 1.44%) 24.07s 24.33s p=0.005 n=6
System info unknown
Hosts
  • node (v18.15.0, x64)
Scenarios
  • Compiler-Unions - node (v18.15.0, x64)
  • angular-1 - node (v18.15.0, x64)
  • mui-docs - node (v18.15.0, x64)
  • self-build-src - node (v18.15.0, x64)
  • self-build-src-public-api - node (v18.15.0, x64)
  • self-compiler - node (v18.15.0, x64)
  • ts-pre-modules - node (v18.15.0, x64)
  • vscode - node (v18.15.0, x64)
  • webpack - node (v18.15.0, x64)
  • xstate-main - node (v18.15.0, x64)
Benchmark Name Iterations
Current pr 6
Baseline baseline 6

Developer Information:

Download Benchmarks

@jakebailey
Copy link
Member

jakebailey commented Apr 4, 2025

Just so we have something to test, is there a clear repo we could check this on? And not just a microbenchmark?

It's a shame that no benchmark we have currently can target this. (xstate kinda?)

@Andarist
Copy link
Contributor Author

Andarist commented Apr 4, 2025

@jakebailey from what I understand, @ssalbdivad found this when investigating a real trpc-based perf issue and he was able to narrow it down to this (TS playground):

import { initTRPC } from '@trpc/server'; // types: 11.0.2
import { z } from 'zod'; // types: 3.24.2

const t = initTRPC.create();

const base = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});

const sequential = base
  .partial()
  .merge(base)
  .pick({ a: true, b: true, c: true })
  .omit({})
  .merge(base);

// fast
const nonSequentialRouter = t.router({
  query1: t.procedure.input(base).query(({ input }) => `hello ${input.a}`),
  mutation1: t.procedure
    .input(base)
    .mutation(({ input }) => `hello ${input.a}`),
});

// equivalent, but slow
const sequentialRouter = t.router({
  query1: t.procedure
    .input(sequential)
    .query(({ input }) => `hello ${input.a}`),
  mutation1: t.procedure
    .input(sequential)
    .mutation(({ input }) => `hello ${input.a}`),
});

Of course, this is a silly artificial example but it's a slimmed-down real thing. @ssalbdivad could comment on this further and maybe share more examples.

@typescript-bot
Copy link
Collaborator

@jakebailey Here are the results of running the top 400 repos with tsc comparing main and refs/pull/61505/merge:

Everything looks good!

@Andarist Andarist changed the title [experiment] Cache mapper instantiations Cache mapper instantiations Apr 4, 2025
@ssalbdivad
Copy link

ssalbdivad commented Apr 4, 2025

@Andarist's overview was good.

The broader context is that these kind of sequentially derived Zod schemas are very common in the wild, especially in the repos I investigate due to type performance issues.

What originally led me to this was two innocuous looking useMutation calls (here and here) that each took ~2 seconds to check.

The Zod types themselves are not very efficient, but its only due to this exponential scaling issue that they are more than a blip.

Given how easy this was to minimally reproduce and the fact that these chaining patterns are prevalent in some of the libraries most often responsible for significant type perf issues, I suspect it will drastically improve check timer for many other APIs in the ecosystem I may not be aware of.

RE: the lack of existing benchmarks here, I think it points to a broader mismatch between the packages the team looks at most often for performance. It seems the focus is primarily large projects with many types but few generics like vscode and libraries like x-state which might define lots of generics but don't necessarily instantiate them a lot.

Meanwhile, the most significant type performance issues almost always arise in large, end-user monorepos that instantiate an order of magnitude or more of these complex library types than libraries do themselves assuming their unit tests are included at all.

I'm not sure what the best way to identify good candidates for these sort of benchmarks is as these projects are notoriously messy and volatile. Potentially just choosing the largest open-source applications to cover the most popular libraries including complex types like Zod, tRPC, Drizzle and Prisma would be a start?

I just wrapped up some consulting for cal.com. They're probably a very good candidate for this sort of thing.

@jakebailey
Copy link
Member

I would like to be able to add a repo like you're describing to the standard benchmark suite, it's just a matter of knowing which one we can choose that will demonstrate this issue. If there's a specific tsconfig out in the wild, we can add it. (cal.com might itself be too large of a single project to add, but maybe there's something else?)

@ahejlsberg
Copy link
Member

Skimming, it seems like this sort of caching could be just done as some sort of Map on the checker, rather than using some sort of hacked up voided Map per mapper. Updating this would certainly make it simpler to port to the new compiler when the time comes.

I agree. If adding a cache on a mapper helps performance, it is an indication that something isn't being cached somewhere else. Mappers were never intended to be cache locations, but rather just type-to-type mapping functions.

I'm in the middle of JSX porting work in the ts-go code base, but once I come up for air I'll try to take a look at this.

@ahejlsberg
Copy link
Member

ahejlsberg commented Apr 5, 2025

Also, I should add, instantiation count isn't the most accurate account of computational work because it sits before the instantiation caches. IOW, we count instantiations coming out of a cache the same as instantiations that actually create new types. Type count is a much better indicator.

@Andarist
Copy link
Contributor Author

Andarist commented Apr 5, 2025

Mappers were never intended to be cache locations, but rather just type-to-type mapping functions.

I understand that - adding this cache, in the first place, felt quite dirty to me but it was the best shot I had at this.

If adding a cache on a mapper helps performance, it is an indication that something isn't being cached somewhere else.

I've examined this quite a bit, both before and after opening this PR, and I just couldn't find any other way to introduce some new caching elsewhere or to fix existing caches.

I only found out that maybe getObjectTypeInstantiation could skip instantiating type.aliasTypeArguments when being within "outer" typeArguments mapping in getObjectTypeInstantiation (when, to the best of my knowledge, that wouldn't exactly use those inner instantiated alias type arguments as it would only use its own instantiated type alias arguments).

That helped a little bit for type instantiations but at the expense of more types being created. That surprised me but I have not followed up on that because the idea has not proved to be sufficient to fix the original perf issues reported~ here.

but once I come up for air I'll try to take a look at this.

I'm very eager to see what you can come up with here. As I've mentioned, at the beginning I treated this PR as a cheap shot at this problem. But after looking more and more into this - I just failed to recognize any other caching possibilities. Of course, I have much worse understanding of all the internal intricacies. I'm hopeful that some alternative for this can be found - it's just that I can't find them right now 😅

In the meantime, I've opened an alternative version to this PR here: #61535 . It still helps the original case but not as much. It might perform better when it comes to the consumed memory though.

Also, I should add, instantiation count isn't the most accurate account of computational work because it sits before the instantiation caches. IOW, we count instantiations coming out of a cache the same as instantiations that actually create new types. Type count is a much better indicator.

Right, I only found out that it still takes a lot of work (and thus time) to get to those caches for computations that already happened in the past. In the presented long* test cases, it adds up a lot.

@ssalbdivad
Copy link

@jakebailey You could add the AnswersOverflow repo I mentioned in my last response where I originally noticed the issue.

@ahejlsberg To be clear, defining e.g. the same ArkType schema twice does not result in extra instantiations. I've tested both metrics fairly extensively in ArkType and across other real world projects and while neither types nor instantiations are linearly correlated with check time, both are granular, deterministic heuristics.

Minimizing instantiations is almost always valuable, although some like those in this scenario are cheaper than others. It's also particularly good at identifying the scaling characteristics of a type. For example, the perfect match in the original case for O(3^N) made it very clear why the type gets so slow so fast as you add more operands.

@jakebailey
Copy link
Member

I think I was hoping for a repro via a repo I had heard of previously; AnswerOverflow itself uses some tooling that would make it hard to use as a direct repro, unfortunately.

@ssalbdivad
Copy link

@jakebailey Unfortunately the end-user monorepos where these type performance issues are most likely to occur tend to be messier and also less likely to be public in general than the libraries that are typically tested. I do think it would still be highly beneficial to try and find some that are relatively stable/reliable to give TS some unique real-world perf insights.

That said, I actually have some benchmarks and tests for these scenarios in ArkType (associated issue).

For now, I have some of the longer chain/piping tests commented out because tsc chokes, but once this issue is addressed, I will uncomment them and you would see any regressions when typechecking ArkType.

@fxdave
Copy link

fxdave commented Apr 11, 2025

I'm working on the next version of my tRPC alternative Cuple. I can easily run into this type instantiation limit. I happily donate my repo for benchmarking https://github.com/fxdave/ts-deep-type-instantiation-example . It uses zod and express, but those are not requried, you can just copy-paste middlewares until it happens, or you can keep them and it happens right now in the example.ts.
It's the third version in which I'm trying to address the issue, I hope it gets solved by this caching.

@jakebailey
Copy link
Member

Our benchmark resources are limited, so we usually go for "large established/continuing projects which exhibit a lot of behavior" rather than specific microbenchmarks; that's mainly why I was asking if there were any real-world projects out there that showed this, e.g. we use mui-docs, angular, xstate, as "somewhat large but interesting" cases.

@gavbaa
Copy link

gavbaa commented Apr 11, 2025

Wouldn't a popular repository that discovered this behavior as part of development, get rid of it in development/not allow that feature to merge as opposed to keeping it in there and making their userbase suffer it? This seems a bit of a self-fulfilling prophecy that bad performing behavior won't be accepted, so no one will use the bad performing behavior.

@jakebailey
Copy link
Member

You're not wrong, though that "AnswerOverflow" example does imply that there's likely some out there.

I'm not trying to be difficult, it's just that every benchmark we add adds a bunch of CI time on a limited set of machines, so we try and pick good benchmarks. I certainly want to add something for this ASAP.

@ahejlsberg
Copy link
Member

The comment here is a clear indication that caches are ultimately hit to produce the types (since the type counts are the same), but also that the path to those caches is expensive and possibly merits an additional fronting cache. Are we dealing with instantiations of intersection types of increasing complexity here?

@Andarist
Copy link
Contributor Author

Andarist commented Apr 11, 2025

Are we dealing with instantiations of intersection types of increasing complexity here?

Yes, the involved types are origin-less intersections

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Status: Not started
Development

Successfully merging this pull request may close these issues.

7 participants