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

perf: geting lazy?, doing less work? generate more (plus some recommendations) #1564

Merged
merged 12 commits into from
May 13, 2024

Conversation

chorobin
Copy link
Contributor

@chorobin chorobin commented May 6, 2024

Getting lazy? Doing less work? and generating more (plus some recommendations)

This PR focuses more on complex routes and to some degree many routes. When I say a complex route, I mean routes which have search, params, loaders and external dependencies such as zod or other heavy TS related dependencies.

A bigger issue I've noticed in TSR is in order to get suggestions, the route tree needs to be evaluated. If your routes contain complex things that TS needs to infer like validation schemas then this can slow down TS evaluating the route tree because its hard at work on the types of the validation libraries. But for most suggestions, we only need the fullPath, we don't need to work out the search schema or the params in order to get suggestions for Link for example until search is used. The ideal scenario is we do as little as possible in order to get the suggestions we need

Show me the results!

I've updated our large filed based example to include:

  • 100 routes for absolute pathing
  • 100 routes for relative pathing
  • 100 routes for params with loader
  • 100 routes for search with loader

These include external dependencies which also do TS inference. Note the results may vary depending on what dependencies you use and how the types are written

On main:

> tsc --extendedDiagnostics

Files:                         636
Lines of Library:            38118
Lines of Definitions:        80134
Lines of TypeScript:         18481
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                132113
Symbols:                    345367
Types:                      119980
Instantiations:            2720561
Memory used:               345796K
Assignability cache size:    56379
Identity cache size:          8114
Subtype cache size:           2022
Strict subtype cache size:       0
I/O Read time:               0.09s
Parse time:                  0.67s
ResolveModule time:          0.15s
ResolveTypeReference time:   0.00s
ResolveLibrary time:         0.02s
Program time:                1.03s
Bind time:                   0.29s
Check time:                  7.25s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  8.57s

On current branch:

> tsc --extendedDiagnostics

Files:                         636
Lines of Library:            38118
Lines of Definitions:        80143
Lines of TypeScript:         19712
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                133346
Symbols:                    322837
Types:                      111429
Instantiations:             773068
Memory used:               327186K
Assignability cache size:    45204
Identity cache size:          8109
Subtype cache size:           2022
Strict subtype cache size:       0
I/O Read time:               0.08s
Parse time:                  0.59s
ResolveModule time:          0.12s
ResolveTypeReference time:   0.00s
ResolveLibrary time:         0.02s
Program time:                0.92s
Bind time:                   0.28s
Check time:                  5.51s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  6.71s

Getting lazy? Doing less work?

In TSR one of the largest performance bottlenecks is the route tree building. This node is important to reduce because it is directly linked to TS server suggestion times. What is contributing to this node being so slow is firstly, a lot of work upfront to evaluate search, loader, context and the others, which are possibly inferred from external libraries.

The first interesting change this PR introduces is not a breaking one but introducing a new signature to addChildren. addChildren accepts a tuple of routes like the following:

route.addChildren([childRoute1, childRoute2])

addChildren also in addition can now accept an object route.addChildren({ childRoute1, childRoute2 }).

Why this change? Well, it looks like tuples seem to be forcing TS to be doing something in each route which is quite slow and it gets worse with different external dependencies, where objects seem to be more cheap or perhaps more lazy in nature, maybe there's also caching involved? Depending on the libraries used in for example validateSearch, you may notice a difference in performance improvements.

For example, using valibot instead of zod in the above example with addChildren that accept a tuple

> tsc --extendedDiagnostics

Files:                          638
Lines of Library:             38680
Lines of Definitions:         86278
Lines of TypeScript:          19809
Lines of JavaScript:              0
Lines of JSON:                    0
Lines of Other:                   0
Identifiers:                 141166
Symbols:                     270865
Types:                        90876
Instantiations:            22514251
Memory used:                310778K
Assignability cache size:     33466
Identity cache size:           7787
Subtype cache size:            2830
Strict subtype cache size:        1
I/O Read time:                0.09s
Parse time:                   0.63s
ResolveModule time:           0.14s
ResolveTypeReference time:    0.01s
ResolveLibrary time:          0.02s
Program time:                 1.03s
Bind time:                    0.30s
Check time:                  14.66s
printTime time:               0.00s
Emit time:                    0.00s
Total time:                  15.99s

Now with addChildren that accepts an object

> tsc --extendedDiagnostics

Files:                         638
Lines of Library:            38680
Lines of Definitions:        86278
Lines of TypeScript:         19813
Lines of JavaScript:             0
Lines of JSON:                   0
Lines of Other:                  0
Identifiers:                141170
Symbols:                    269855
Types:                       90640
Instantiations:            1093144
Memory used:               316056K
Assignability cache size:    33455
Identity cache size:          7787
Subtype cache size:           2830
Strict subtype cache size:       1
I/O Read time:               0.09s
Parse time:                  0.68s
ResolveModule time:          0.14s
ResolveTypeReference time:   0.01s
ResolveLibrary time:         0.02s
Program time:                1.09s
Bind time:                   0.35s
Check time:                  5.35s
printTime time:              0.00s
Emit time:                   0.00s
Total time:                  6.79s

That's a big difference between tuples and objects. I'm still not totally sure why tuples behave this way or if I'm even correct about my assumptions but to me it looks like tuples are forcing some kind of extra work the TS compiler is doing and its related to validateSearch and how the types of the external library are likely written.

Profile

The first link that is checked by TS has to evaluate the route tree and it looks like this with addChildren accepting a tuple

image

The same node with addChildren accepting an object looks like this

image

But thats not the end of the story. Each individual Link is also faster with children as an object. Each Link is checked with the following time when addChildren accepts a tuple

image

And each Link is checked with the following time with addChildren accepts an object

image

As mentioned before. 30ms might not seem like much. But if you have 30ms for every Link in your code base, it quickly accumulates. 4ms is a huge difference in these cases

In this PR I have switched file based routing over to using objects automatically for a default performance boost. Code based can opt into the signature.

Other stuff

Generating more

File based routing previously has done a lot of string interpretation on the type level in order to calculate things like fullPath, path, id etc. Template literal types are expensive for the compiler and generally in file based routing, these strings can be generated directly by the generator without hurting inference. This speeds up creating routes

Improve params parsing

A more direct implementation of path params parsing without any additional splitting or mapped types

What can blow up your TS Performance

With TSR you want to narrow to the most relevant routes. This means using from and to on Link or other API's.

For example, <Link search={{ page: 0 }} /> will increase the type checking time on this link because TSR will resolve search to all possible routes (a union of all search params) and will therefore spend longer type checking it. This depends on the size of your route tree and how many search params you have but on our large example, I noticed a single Link would type check in roughly 100ms.

It is better to narrow the route as much as you can <Link from={Route.fullPath} search={{ page: 0 }} /> with from or to. In this case the time type checking Link went down to 9ms.

This same logic also applies to LinkOptions. If you're using types from TSR which are by default not narrowed to a route then this can also blow up TS performance when merging this props with Link. Just remember that when from or to is not specified then things can start slowing down, so definitely try to do it as little as possible.

Copy link

nx-cloud bot commented May 6, 2024

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 236d63c. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this CI Pipeline Execution


✅ Successfully ran 2 targets

Sent with 💌 from NxCloud.

@chorobin
Copy link
Contributor Author

chorobin commented May 6, 2024

Still some work needs to be done so not ready just yet

addChildren = <const TNewChildren extends ReadonlyArray<AnyRoute>>(
addChildren = <
const TNewChildren extends
| Record<string, AnyRoute>
Copy link
Contributor

Choose a reason for hiding this comment

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

should this new object syntax be documented?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added to the performance recommendations

@schiller-manuel
Copy link
Contributor

we should add that What can blow up your TS Performance section into the documentation

@chorobin
Copy link
Contributor Author

chorobin commented May 6, 2024

we should add that What can blow up your TS Performance section into the documentation

Definitely. Think we need to write a section on it so they can go to it and be able to resolve common problems

TSearchSchemaUsed = TSearchSchemaInput extends SearchSchemaInput
? Omit<TSearchSchemaInput, keyof SearchSchemaInput>
: TSearchSchema,
TSearchSchemaInput = {},
Copy link
Contributor Author

@chorobin chorobin May 8, 2024

Choose a reason for hiding this comment

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

I realized these constraints were not necessary because they were just constrained to {} which is already extremely loose

@@ -138,78 +144,142 @@ const JestedLayoutB3LayoutC2BarRoute = JestedLayoutB3LayoutC2BarImport.update({
declare module '@tanstack/react-router' {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@schiller-manuel I've added conditions for the tests in the generator for fullPath, id, path. Are there any situations missing here that I should add?

)

return `'${filePathId}': {
id: '${id}'
Copy link
Contributor

Choose a reason for hiding this comment

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

do we need both id and path?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought id was slightly different?

Copy link
Member

@SeanCassiere SeanCassiere May 11, 2024

Choose a reason for hiding this comment

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

Correct me if I'm wrong, but you are explicitly setting these values on the declaration so you don't need to resolve the inference again via the TS-resolve types, yes?
Edit: Ignore this comment, I didn't properly read the PR description.

Copy link
Member

@SeanCassiere SeanCassiere May 11, 2024

Choose a reason for hiding this comment

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

Also, just want to confirm that we don't end up in a scenario that a created route configuration (not route type declaration) doesn't ever have both an id and a path.

Like:

const fooRoute = fooImport.update({
  id: 'foo',
  path: '/foo'
})

This only affects the type that's printed for module declaration.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if I get your point. I think file based routing always had id and path in the types?

Copy link
Member

Choose a reason for hiding this comment

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

Check more into this, it looks good.

Just wanted to make sure we weren't modifying anything in route configuration, and only touching stuff on the type declaration.

@chorobin chorobin marked this pull request as ready for review May 10, 2024 13:15
TRouteContext
>,
TRouterContext extends RouteConstraints['TRouterContext'] = AnyContext,
TAllParams = ResolveAllParamsFromParent<TParentRoute, TParams>,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a bit faster to name all complex types

@chorobin
Copy link
Contributor Author

Don't merge yet. Need to double check some things. Ideas 💡

@schiller-manuel
Copy link
Contributor

@SeanCassiere in light of #1584, can you please review this?

Copy link
Member

@SeanCassiere SeanCassiere left a comment

Choose a reason for hiding this comment

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

I don't have the best understanding of all the ts-stuff we do with the router, but trying it out on my machine with out examples, the functionality works identical to what it was like before.

Left some comments, but nothing too crazy, all-in-all amazing, amazing, amazing, work @chorobin 🙌🏼!

packages/router-generator/src/generator.ts Show resolved Hide resolved
)

return `'${filePathId}': {
id: '${id}'
Copy link
Member

@SeanCassiere SeanCassiere May 11, 2024

Choose a reason for hiding this comment

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

Correct me if I'm wrong, but you are explicitly setting these values on the declaration so you don't need to resolve the inference again via the TS-resolve types, yes?
Edit: Ignore this comment, I didn't properly read the PR description.

)

return `'${filePathId}': {
id: '${id}'
Copy link
Member

@SeanCassiere SeanCassiere May 11, 2024

Choose a reason for hiding this comment

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

Also, just want to confirm that we don't end up in a scenario that a created route configuration (not route type declaration) doesn't ever have both an id and a path.

Like:

const fooRoute = fooImport.update({
  id: 'foo',
  path: '/foo'
})

This only affects the type that's printed for module declaration.

packages/router-generator/src/generator.ts Show resolved Hide resolved
export type ParsePathParams<T extends string> = keyof {
[K in Trim<Split<T>[number], '_'> as K extends `$${infer L}`
? L extends ''
export type ParsePathParams<
Copy link
Member

Choose a reason for hiding this comment

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

This comment may not really belong here at this line, rather it's something I noticed about the link component when testing out the PR.

In the "kitchen-sink-file-based" example, using a <Link> component it accepts the following to values.

  1. /route-group
  2. route-group

1 works as expected, but 2 then says it requires params.

Property 'params' is missing in type '{ to: "route-group"; }' but required in type 'MakeRequiredPathParams<Router<Route<any, "/", "/", string, "__root__", RootSearchSchema, RootSearchSchema, RootSearchSchema, RootSearchSchema, ... 10 more ..., { ...; }>, TrailingSlashOption, Record<...>, Record<...>>, string, "route-group">'.ts(2741)

Also, something else, I noticed is that the from property doesn't accept just route-group, only allows /route-group unlike the to property shown above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, its a separate issue I'm going to be fixing in a separate PR. Its a bug on main as well.

@@ -73,38 +73,65 @@ const LayoutLayout2LayoutARoute = LayoutLayout2LayoutAImport.update({
declare module '@tanstack/react-router' {
Copy link
Member

Choose a reason for hiding this comment

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

We'll need to generate the route-trees on all the file-based examples as well and start migrating all the code-based examples over to the object-syntax.

Copy link
Contributor

Choose a reason for hiding this comment

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

while I agree we should re-generate the route-trees of file based examples, I don't think we MUST migrate the code based ones.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's slow down a bit. I'm exploring another idea that I really hope means we can not introduce a new signature. But we will see! I'm not sure if it'll work yet

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok. Turns out the idea is again limited by the children being tuples, it likely forcing unnecessary instantiations...

We need to keep the signature to allow objects for children

<Link from={from} to='..' />
```

### Consider using the object syntax of `createChildren`
Copy link
Member

Choose a reason for hiding this comment

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

I believe this should be addChildren

@SeanCassiere
Copy link
Member

Also, we probably want to using the feat: or refactor: prefix on the commit name. Not sure if we are using conventional commits or angular commits, but if I've not mistaken on one of them using perf: triggers a major version bump.

@SeanCassiere
Copy link
Member

SeanCassiere commented May 11, 2024

@SeanCassiere in light of #1584, can you please review this?

Trying out the changes from my previous PR on this branch yielded no collisions or regressions that I could observe on that specific layout route behaviour.

That being said, @chorobin once the merge conflict has been resolved, you'd want to rebase and confirm the tests I introduced in that PR still work.


## Performance Recommendations

As your application scales, typescript check times will naturally increase. There are a few things to keep in mind when your application scales to keep your TS check times down.
Copy link
Contributor

Choose a reason for hiding this comment

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

typescript => TypeScript

<Link search={{ page: 0 }} />
```

**These examples are bad for TS performance**. Thats because `search` resolves to a union of all `search` params for all routes and TS has to check whatever you pass to the `search` prop against this potentially big union. As your application grows, this check time will increase linearly to number of routes and search params. We have done our best to optimise for this case (typescript will typically do this work once and cache it) but the initial check against this large union is expensive. This also applies to `params` and other API's such as `useSearch`, `useParams`, `useNavigate` etc
Copy link
Contributor

Choose a reason for hiding this comment

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

Thats => That's

@@ -71,6 +64,7 @@ export {
type UseLinkPropsOptions,
type ActiveLinkOptions,
type LinkProps,
type LinkComponentProps,
Copy link
Contributor

Choose a reason for hiding this comment

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

do we really want to export this type? what would this be used for?

@chorobin
Copy link
Contributor Author

Also, we probably want to using the feat: or refactor: prefix on the commit name. Not sure if we are using conventional commits or angular commits, but if I've not mistaken on one of them using perf: triggers a major version bump.

I've never heard of this. In conventional commits I thought you had to explicitly mention ! or BREAKING CHANGE to bump major version

@schiller-manuel
Copy link
Contributor

schiller-manuel commented May 12, 2024

this can be seen here: https://github.com/TanStack/config/blob/main/src/publish/index.js#L144

perf will trigger a patch release.

> = ToSubOptions<TRouter, TFrom, TTo> & {
> = ToSubOptions<TRouter, TFrom, TTo> & MaskOptions<TRouter, TMaskFrom, TMaskTo>

export interface MaskOptions<
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naming all of these types helps TS cache stuff when passing LinkProps to a Link. Slightly more optimised

@chorobin chorobin force-pushed the get-lazy-gen branch 3 times, most recently from c772e8b to cd8d216 Compare May 12, 2024 22:22
@chorobin chorobin merged commit 0898f2e into main May 13, 2024
7 checks passed
@chorobin chorobin deleted the get-lazy-gen branch May 13, 2024 14:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants