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 improvements #104

Closed
wants to merge 5 commits into from

Conversation

jussisaurio
Copy link
Contributor

Hey,

Recently popped into this issue thread to discuss a fairly significant perf improvement idea, but found out @fabian-hiller had already done almost exactly the same thing!

However, I just finished a 4 hour plane ride and I spent the whole flight looking for more perf improvements and found a few!

Sizeable optimizations

Both of these generally just involved doing less whenever possible:

  1. Evaluate full issue paths lazily instead of eagerly
  2. Avoid creating PipeInfo objects when pipe.length === 0

I'd say these contributed 90% to the total performance improvement in this PR.

Micro-optimizations

  1. Make cachedEntries for the object schema a flat array instead of an array of 2-tuples -- mainly just less indirection which is more friendly to the CPU.
  2. Simpler "is object" check using input?.constructor === Object

Here are the results on my machine using typescript-runtime-type-benchmarks:

*** valibot @ current head of main, i.e. e6c53c8 ***
Running "parseSafe" suite...
Progress: 100%

  valibot:
    1 784 335 ops/s, ±1.51%   | fastest

Finished 1 case!
Running "parseStrict" suite...
Progress: 100%

  valibot:
    1 715 007 ops/s, ±0.15%   | fastest

-------------------------------------------

*** THIS PR ***
Running "parseSafe" suite...
Progress: 100%

  valibot:
    2 616 009 ops/s, ±0.21%   | fastest

Finished 1 case!
Running "parseStrict" suite...
Progress: 100%

  valibot:
    2 317 246 ops/s, ±0.25%   | fastest

This run amounts to +47% on parseSafe, +35% on parseStrict.

@netlify
Copy link

netlify bot commented Aug 15, 2023

Deploy Preview for valibot canceled.

Name Link
🔨 Latest commit 83d34f9
🔍 Latest deploy log https://app.netlify.com/sites/valibot/deploys/64de538c6a68d30008d2dd19

@fabian-hiller
Copy link
Owner

Thank you very much for your contribution! At the moment I have to focus on the text of my bachelor thesis. As soon as I have more time, I will check and merge your changes.

@fabian-hiller fabian-hiller self-assigned this Aug 15, 2023
@fabian-hiller fabian-hiller added priority This has priority performance Its performance related labels Aug 15, 2023
@milankinen
Copy link
Contributor

milankinen commented Aug 16, 2023

Just a quick notice about path if we're really trying to optimize the performance. Usually path is only needed in case of failures so that the path can be added to the issues. If this is the case, it can be allocated lazily only if the validation (or sub-validation) returns any issues instead of the current implementation where the path is allocated eagerly. This should yield some small performance improvements due to decreased number of allocations and gc overhead.

If the failure types are something like

type NestedIssue = { 
  pathItem: PathItem
  issues: Issues
} 
type LeafIssueWithErrorDetailsButNOPath = { ... }

type Issue = NestedIssue | LeafIssueWithErrorDetailsButNOPath
type Issues = Issue[]

Then the _parse function validation logic in failure cases becomes extremely simple:

// object validation pseudo
let issues: Issues | undefined
for each key in objectSchema {
  const result = parseKeySchema(object[key])
  if (result.issues) {
   (issues = issues ?? []).push({ pathItem: new PathItem(key), issues: result.issues })
  }
}
return issues ? { valid: false, issues } : { valid: true, value }

Of course the issues structure (and paths along with it) must be flattened at the top level parse before returning the issues to the library user, but that should be very straightforward task to do (similar to the current getPath).

That said, I'm not familiar with this library or its features so take this with grain of salt... 😄

EDIT: fixed the issue type, original one was incorrect

@fabian-hiller
Copy link
Owner

Yes, that is correct. Thank you very much for pointing it out. I will take it into account for the final implementation.

@jussisaurio
Copy link
Contributor Author

Yep that's a very good point, interested to see how much this speeds things up further

the objects created using getPipeInfo() are only necessary
when the parser actually has a non-empty pipe. Since it is
very often the case that the pipe is empty, this common path
can be optimized by calling getPipeInfo() only in cases where
executePipe() is called with a non-empty pipe.
refactors 'path' to use 'class LazyPath' instead of
PathItem[].

Issue paths are only needed when flattening errors, so
eagerly constructing them for the ParseInfo objects passed
to every parser results in a lot of unnecessary allocations.

Skipping most of these allocations and evaluating the paths
only when they are needed improves performance.
@fabian-hiller
Copy link
Owner

Thank you for your contribution! Due to my bachelor thesis I have a lot to do at the moment. I'll try to check the changes next week at the latest.

@jussisaurio
Copy link
Contributor Author

jussisaurio commented Aug 17, 2023

Thank you for your contribution! Due to my bachelor thesis I have a lot to do at the moment. I'll try to check the changes next week at the latest.

Sure, no pressure nor hurry, it's your library dude :) I just rebased and fixed the conflicts, nothing else.

If I have extra time I can implement @milankinen 's suggested refactor as well, but it's kind of 50/50 whether I can find the time

@jussisaurio
Copy link
Contributor Author

jussisaurio commented Aug 17, 2023

Found an hour of idle time and started shoveling, looking pretty promising... :D

Removing previous results
Executing "valibot"
Loading "valibot"
Running "parseSafe" suite...
Progress: 100%

  valibot:
    3 657 558 ops/s, ±0.11%   | fastest

Finished 1 case!
Running "parseStrict" suite...
Progress: 100%

  valibot:
    3 213 696 ops/s, ±0.10%   | fastest

@milankinen you absolute GOAT! 🐐 Apart from the performance gains I think this also makes the code a tad cleaner.

I'm not quite finished with the refactor yet - must make sure all the tests pass and none of the public API is broken.

A suggestion by @milankinen considerably improves
the happy-path performance of valibot:

Never allocate any issue paths at all until an issue
actually occurs, and let the parent (e.g. object) handle
the path, since it knows what child property (==key) the
issue propagates from.

The Issue type is refactored to contain three variants:

- LeafIssue (e.g. regular issue details but no path)
- NestedIssue (just the path and a list of child issues)
- UnionIssue (special case of NestedIssue where each union
  variant propagates its potential issues)
@fabian-hiller
Copy link
Owner

Can you explain why it is necessary to make a distinction between LeafIssue, NestedIssue and UnionIssue for the issues?

@jussisaurio
Copy link
Contributor Author

jussisaurio commented Aug 17, 2023

Can you explain why it is necessary to make a distinction between LeafIssue, NestedIssue and UnionIssue for the issues?

Sure! We can obviously iterate on the data model, but the idea in a nutshell is to accurately model the different kinds of objects according to the information that is actually relevant to each ("make illegal states unrepresentable"):

LeafIssue is an issue with the datatype itself: the input was supposed to be a string, but was a number instead, or was supposed to be an object, but was a string instead (important to distinguish this from other issues with objects -- here the specific problem is that the input is not an object at all). So for these types of issues it makes sense to include input , message etc, but it does not include other issues since the validation problem is the datatype itself. For this reason issues shouldn't be in its datatype because it can't really have them. A leaf issue also doesn't really have to be aware of path -- a string is a string, the path is its containing parent's concern.


NestedIssue is an issue with a "composite": an input may be a valid object (i.e. it is not a string), but it can have e.g. the following problem: it should have contained key a of string, but a was a number instead. It's still a valid object, but something within its composition is wrong. More precisely, one or more sub-paths within this composite contained issues. Hence, it makes sense to include path and a list of issues from each subpath. Likewise, for maps and records, origin belongs here, but nowhere else, since origin is a property of where in the "container" the leaf issue(s) occurred.

  • Looking at the current implementation of NestedIssue, it should probably still have the validation property which I neglected to add. But does it need to have reason? The reason in the above example is that one of its child properties was not a string. So I'm thinking the reason belongs to the child, not the container.

UnionIssue was something that I identified as a special case: It's basically a "pathless" NestedIssue. Not 100% sure what to do with it. The current data model for it may well not be the right one.


I'm also thinking there might potentially be better names for these different types of Issue, but I do think that a type-level distinction between them is useful. Perhaps type Issue = SelfIssue | ChildrenIssue | SomethingToHandleTheUnionCase_tbd

@milankinen
Copy link
Contributor

milankinen commented Aug 18, 2023

Wow nicely done @jussisaurio 😮 👌!

Can you explain why it is necessary to make a distinction between LeafIssue, NestedIssue and UnionIssue for the issues?

Sorry my fault, different issue types was just my initial thought because of separation of concerns. 😅 It's not absolutely necessary to have different types of issues if the issues are flattened "in-place" in schemas. E.g. something like:

const createIssue = (msg: string) => ({msg, path: []})
const appendIssues = (issues: Issue[], childIssues: Issue[], pathItem: PathItem) => {
  issues.push(...childIssues.map(issue => issue.path.unshift(pathItem) && issue))
  return issues
}

// string.ts
if (typeof value !== 'string') {
  return createIssue('not a string')
}

// object.ts
if (result.issues) {
  issues = appendIssues(issues, result.issues, key)
}

In my opinion it's a bit cleaner and more robust to keep the issue structure immutable during the validation phase and construct the path afterwards instead of mutating (because of perf) the path on each validation level. But both should work. Perfwise probably no difference.

@fabian-hiller
Copy link
Owner

I don't have time to think about it in detail at the moment. However, I want to keep the library as simple as possible, as this also reduces the bundle size, and splitting it into different issues seems a bit complicated. It is already possible to extract all important information with the current implementation, isn't it?

A path is still needed for a LeafIssue if it is in an object. Or can a LeafIssue not concern nested data? If so, the name is not optimal.

The only thing that would make sense to me would be:

  • RootIssue: Issue without path
  • NestedIssue: Issue with path
  • UnionIssue (special case): With sub-issues (stems from RootIssue or NestedIssue)

With the current implementation, the path is only constructed once an issue occurs, which is great. Does this lead to bugs with deeply nested issues? We may construct the path retroactively e.g. with path.unshift(…)? Then LazyPath would not be necessary, and the code would be simpler constructed or?

@fabian-hiller
Copy link
Owner

Tomorrow or Sunday, I will try to release a new version with all performance improvements. For time reasons (because of my bachelor thesis), I will then weigh whether I build on this issue or implement the ideas independently for the time being. I appreciate your contribution and will mention you in the release notes. 🤝

@jussisaurio
Copy link
Contributor Author

@fabian-hiller you are free to do what you like ofc :) the important point is the lazy construction of paths.

as you can see in the most recent commit i removed the LazyPath class entirely because its not needed at all. ParseInfo no longer contains path because it's only constructed when an issue actually arises. that is the key detail.

wish you the best with your thesis, those are a lot of work!

@jussisaurio
Copy link
Contributor Author

I don't have time to think about it in detail at the moment. However, I want to keep the library as simple as possible, as this also reduces the bundle size, and splitting it into different issues seems a bit complicated. It is already possible to extract all important information with the current implementation, isn't it?

A path is still needed for a LeafIssue if it is in an object. Or can a LeafIssue not concern nested data? If so, the name is not optimal.

The only thing that would make sense to me would be:

  • RootIssue: Issue without path
  • NestedIssue: Issue with path
  • UnionIssue (special case): With sub-issues (stems from RootIssue or NestedIssue)

With the current implementation, the path is only constructed once an issue occurs, which is great. Does this lead to bugs with deeply nested issues? We may construct the path retroactively e.g. with path.unshift(…)? Then LazyPath would not be necessary, and the code would be simpler constructed or?

And yes in the most recent commit the path is constructed retroactively in the flatten() function. In non flattened issues the path is only the key or the index, because nested issues have an issues array, which may have their own issues array, etc. So you can always build the full path string from those as necessary. Again, this was just my shot at the implementation, but it does avoid doing any extra work on the paths before an issue actually happens.

If you want you can play around with it in a branch to see how it works. But anyway, do whatever you think is best!

@fabian-hiller
Copy link
Owner

Thank you both for the research and ideas. Since I wanted to do a few things differently, I implemented it on my own and added you guys as co-authors. Feel free to check the result. You may see something we can do better.

The paths are now created lazy, and the pipeline is only executed when required. Also, I changed the object check to input.constructor !== Object.

However, I kept the previous implementation of cachedEntries because there was hardly any difference in performance in my test environment, and the code became more confusing with the new implementation.

The changes helped to improve performance by about 30% in my test environment. That's great! Thanks a lot!

@jussisaurio
Copy link
Contributor Author

Hey @fabian-hiller , nicely done! 🥳

I made a simpler version of the flat cachedEntries here, trying to be a bit more sensitive towards eventual bundle size and readability. It still gets a tad confusing in the objectAsync case.

Perf-wise: I'm still consistently getting a 6% perf boost using this strategy on typescript-runtime-type-benchmarks compared to current main, so feel free to throw it in at your discretion if you consider it a net win.

@fabian-hiller
Copy link
Owner

Thanks for the hint. For objectAsync I would probably leave it as is, but for object I can imagine the change. @naruaway can you check in your benchmark suite how the change affects performance?

@naruaway
Copy link
Contributor

I guess many of (similar) changes in this PR are already incorpolated in the latest main.
So I compared Make cachedEntries for the object schema a flat array instead of an array of 2-tuples -- mainly just less indirection which is more friendly to the CPU. part (I extracted out this part on top of the current main: naruaway#1) against the current main.

And I ran the benchmark suite for the following commits on my MacBook Air (M2 chip):

Benchmark results

nodejs, deep, valid
  use-flat-object-cache : 1095916.5 ops/s (fastest, 5.2% faster)
  latest-main           : 1042200.5 ops/s

nodejs, many_features, invalid
use-flat-object-cache : 294343.5 ops/s (fastest, 1.2% faster)
latest-main : 290939.5 ops/s

nodejs, many_features, valid
use-flat-object-cache : 1183446 ops/s (fastest, 3.8% faster)
latest-main : 1140184 ops/s

nodejs, optional_nullable, valid
use-flat-object-cache : 798971.5 ops/s (fastest, 5.1% faster)
latest-main : 760427.5 ops/s

nodejs, wide, invalid
use-flat-object-cache : 175466.5 ops/s (fastest, 2.2% faster)
latest-main : 171669 ops/s

nodejs, wide, valid
use-flat-object-cache : 369301 ops/s (fastest, 5.6% faster)
latest-main : 349862 ops/s

bun, deep, valid
use-flat-object-cache : 1814776 ops/s (fastest, 9.8% faster)
latest-main : 1652201.5 ops/s

bun, many_features, invalid
use-flat-object-cache : 1224463.5 ops/s (fastest, 4.9% faster)
latest-main : 1167401.5 ops/s

bun, many_features, valid
use-flat-object-cache : 1818182.5 ops/s (fastest, 9.1% faster)
latest-main : 1666586.5 ops/s

bun, optional_nullable, valid
use-flat-object-cache : 1001718.5 ops/s (fastest, 15.0% faster)
latest-main : 870855 ops/s

bun, wide, invalid
use-flat-object-cache : 494294 ops/s (fastest, 12.8% faster)
latest-main : 438072 ops/s

bun, wide, valid
use-flat-object-cache : 685653 ops/s (fastest, 18.9% faster)
latest-main : 576454.5 ops/s

Interestingly, in the above result, we see that this "flat cache" strategy consistently improves the runtime performance for both Bun and Node.js and the performance improvement is more significant for Bun (up to 18.9% for "wide, valid" case). We might want to consider merging this strategy as long as the maintainer thinks the code is readable & maintainable enough.

@fabian-hiller
Copy link
Owner

Thank you @naruaway. I will think about it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance Its performance related priority This has priority
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants