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: memoize values of cmp props when used multiple times in template #2300

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

jodarove
Copy link
Contributor

@jodarove jodarove commented Apr 21, 2021

Details

Fixes: #243

This PR memoize repeated property access to the component instance from the template.

Example:

<template>
     <p>{author.name}, {author.lastmane}</p>
</template>

now compiles to:

  function tmpl($api, $cmp, $slotset, $ctx) {
    const {
      d: api_dynamic,
      t: api_text,
      h: api_element
    } = $api;
    const { author: $cv0 } = $cmp;
    return [api_element("p", {
      key: 0
    }, [api_dynamic($cv0.name), api_text(", "), api_dynamic($cv0.lastmane)])];
  }

Implementation details

In LWC, a property does not becomes reactive until is accessed. In the following example, bar does not become reactive until foo === true:

<template>
	<template if:true={foo}>
              <p>{bar.name}, {bar.lastname}</p> <!-- bar does not become reactive until foo === true -->
        </template>
</template>

Because of this, we can't access the used properties from the component once at the root scope (it will become reactive) and reuse its value in the following usages.

This PR changes:

  1. (b9258ee) The template compilation to create one scope (function) for the if and for/itetator directives in the same hierarchy as the template html.
  2. (8f4ed06) Memoize repeated access to component bound property: For each scope when serialized, any access to component bound property A ($cmp.A(.B...)) will be replaced with a variable A' (A'(.B...)), initialized like const { A: A' } = $cmp; in the current scope or some of the ancestor scopes. if and only if any of the following is true:
    a) If A' is declared in an ancestor scope.
    b) If A is used at least 2 times in the current scope.
    c) If A is used only one time in this scope, and at least another time in one of the children's scopes.

Note to Reviewers: The compiler changes are in these commits: b9258ee and 8f4ed06. The rest is updating the template compiler snapshots and adding new ones.

Todos:

  • Handle the case where a component property is accessed only within a foreach See this comment, we may want to do it as a separate PR.

Does this PR introduce breaking changes?

  • 🚨 Yes, it does introduce breaking changes.

There may be some case in wich the getter returns different values for each invocation. Ex:

<template>
    <p>Random number 1: {randomNumber}</p>
    <p>Random number 2: {randomNumber}</p>
</template>
.... {
   get randomNumber() {
      return Math.random();
   }
}

@jodarove jodarove changed the title perf: memoize values of cmp props when uses multiple times in template perf: memoize values of cmp props when used multiple times in template Apr 21, 2021
@jodarove jodarove force-pushed the jodarove/momoize-props-used-in-tpl branch from 5f2da46 to 1df9f0e Compare April 21, 2021 16:02
@caridy
Copy link
Contributor

caridy commented May 28, 2021

Let's prioritize this one since it is going to help locker. /cc @manuel-jasso

@manuel-jasso
Copy link

Let's prioritize this one since it is going to help locker. /cc @manuel-jasso

You mean from a perf standpoint @caridy? And is there an action item for Locker?

@jodarove jodarove force-pushed the jodarove/momoize-props-used-in-tpl branch 2 times, most recently from 5dd37f6 to 2d50402 Compare June 22, 2021 00:02
@caridy
Copy link
Contributor

caridy commented Jun 22, 2021

Can you add some explanation in the description about how this will work? looking at the code is very hard to understand.

@nolanlawson
Copy link
Collaborator

I tested this a bit manually, and I noticed that this will call the getter only once:

<template>
  {foo}
  {foo}
  {foo}
</template>

but this will call it multiple times:

<template>
  <template for:each="..." for:items="...">
    {foo}
  </template>
</template>

I guess maybe it's kind of an edge case, though?

@nolanlawson
Copy link
Collaborator

FWIW I was able to write a Tachometer benchmark to demonstrate the perf improvement from this PR. The benchmark renders a component with an expensive getter, and that getter is referenced 100 times in the template. (So with this PR, the getter would only be called once rather than 100 times.) It's a bit unrepresentative, but it shows a consistent improvement compared to master:

Screen Shot 2021-06-30 at 12 26 15 PM

The existing benchmarks do not seem to show an improvement or regression, but they also aren't really exercising the right code paths to show a difference.

@jodarove
Copy link
Contributor Author

I tested this a bit manually, and I noticed that this will call the getter only once:

<template>
  {foo}
  {foo}
  {foo}
</template>

but this will call it multiple times:

<template>
  <template for:each="..." for:items="...">
    {foo}
  </template>
</template>

I guess maybe it's kind of an edge case, though?

@nolanlawson this is a great catch! it's an edge case, but I do think is one that can be addressed (when foo is bound to the component).

@jodarove jodarove force-pushed the jodarove/momoize-props-used-in-tpl branch from 8f5fc58 to 998dd76 Compare July 16, 2021 18:32
@jodarove
Copy link
Contributor Author

@nolanlawson I took a look at the iteration case, and the short answer: is difficult.

Today the foreach and iterators directive generate a function that is called from the engine for each element in the collection:

    return api_iterator($cmp.collection, function (item) {
        return api_element("span", {
          key: api_key(0, item.id)
        }, [api_dynamic($cmp.foo)]);
      });

This PR changes that compilation to:

    function foreach1_0(item) {
      return api_element("span", {
        key: api_key(0, item.id)
      }, [api_dynamic($cmp.foo)]);
    }

    return api_iterator($cmp.collection, foreach1_0);

which is the same. We can't access cmp.foo until we know for sure that the collection has at least one element (otherwise it becomes reactive when it shouldn't).

@jodarove jodarove marked this pull request as ready for review July 16, 2021 19:25
@nolanlawson
Copy link
Collaborator

@jodarove Ah, that is a good point. Well, the iterator is probably an edge case anyway. (Most iterations I imagine would be repeating items from the array, not from the outer scope.)

? t.arrayExpression(node.elements.map(() => falseValue ?? t.literal(null)))
: falseValue ?? t.literal(null);

return t.conditionalExpression(leftExpression, t.callExpression(ifFn, []), falsyArray);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain this change?

Copy link
Member

@pmdartus pmdartus left a comment

Choose a reason for hiding this comment

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

IMHO, the risk of breakage and the complexity introduced in the code gen doesn't worth the potential performance gain. I would expect the potential performance gain to be quite minimal since only the top-level property lookup is cached (in the expression foo.bar.baz, only the foo lookup is cached).

Karma integration test should also be added to validate this change.

@@ -1,6 +1,7 @@
import _xFoo from "x/foo";
import { registerTemplate } from "lwc";
function tmpl($api, $cmp, $slotset, $ctx) {
const { computed: $cv0_0 } = $cmp;
Copy link
Member

Choose a reason for hiding this comment

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

This PR makes reading the compiled template code way more complex, I would recommend keeping those expressions as close as possible to the original expression.

Suggested change
const { computed: $cv0_0 } = $cmp;
const $cv0_0 = $cmp.computed;

Comment on lines +4 to +36
function foreach4_0(item) {
return api_element(
"div",
{
key: api_key(7, item.id),
},
[]
);
}
function if2_1() {
function foreach3_0(item) {
return [
api_element(
"p",
{
key: api_key(3, item.id),
},
[api_text("X1")]
),
api_element(
"p",
{
key: api_key(4, item.id),
},
[api_text("X2")]
),
];
}
return api_iterator($cv0_0, foreach3_0);
}
function foreach1_0(item) {
return api_text("X");
}
Copy link
Member

Choose a reason for hiding this comment

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

Nested if block and for each block makes it complex/impossible to understand the actual code flow.

One way to make it more intelligible would be to use a similar approach than Svelte with comments preceding blocks. Example of generated code.

@@ -122,6 +127,15 @@ function transform(codeGen: CodeGen): t.Expression {
let expr;

if (isElement(child)) {
// When the same element have if and forEach/forOf directives, we must create two scopes.
if (child.if) {
codeGen.createScope();
Copy link
Member

Choose a reason for hiding this comment

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

I would rename most of the references from scope to block. While an iteration creates a new lexical scope with the item and the index, the if block doesn't. This is why I think the term block makes more sense in this context.

@@ -94,10 +98,31 @@ export default class CodeGen {
this.scopeFragmentId = scopeFragmentId;
}

createScope() {
Copy link
Member

Choose a reason for hiding this comment

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

To make this symmetrical with the popScope method.

Suggested change
createScope() {
pushScope() {

body: t.FunctionExpression['body'],
kind: string
) {
const id = t.identifier(`${kind}${this.id}_${this.childScopes.length}`);
Copy link
Member

Choose a reason for hiding this comment

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

Why making the identifier more complex by adding the childScopes.length?

}

for (const childScope of scope.childScopes) {
body.unshift(childScope.scopeFn!);
Copy link
Member

Choose a reason for hiding this comment

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

An assertion should be added here to make sure the setFn should be invoked before serializeInto

@nolanlawson
Copy link
Collaborator

Just my two cents: I think caching getters is probably a good perf improvement on the average LWC app, since component authors end up writing lots of getters, and those getters can be 1) frequently called, and 2) expensive.

That said, PM's point about not caching deep.getters.like.this is valid. I also wonder if we could get even bigger perf improvements by caching getters very aggressively outside of templates.

For instance, I've seen a perf issue in the wild that was due to getters, but it would not be solved by this PR, because it was due to repeated re-renders, where each render was calling several expensive getters. Trivial example:

  renderedCallback() {
    console.log(
      this.foo, // expensive
      this.bar, // also expensive
      this.baz  // you get the idea
    )
  }

@AllanOricil
Copy link
Contributor

@nolanlawson it is better if you also run these benchmarks and ask the repo owner to add LWC on the list https://github.com/krausest/js-framework-benchmark

@caridy
Copy link
Contributor

caridy commented Aug 17, 2021

That said, PM's point about not caching deep.getters.like.this is valid. I also wonder if we could get even bigger perf improvements by caching getters very aggressively outside of templates.

I want to hear more about this. Keep in mind that in the first few versions of LWC (raptor at the time), we did some of this, basically, the template never hit the getter directly, but a function that calls the getter under the hood, and can make optimizations, basically, we don't give the component to the template function, but a wrapper around it, so when the template tries to access those getters, you could cache the result, the problem is when to invalidate the result.

@nolanlawson
Copy link
Collaborator

@caridy I was thinking along the lines of computed properties (#2437). If we have an explicit API for this, then we know when to cache and when to invalidate. For instance, if sum is defined as a + b, then we invalidate whenever a or b are set.

I suppose we don't always need the explicit signal for invalidation though? We are a compiler, so we could theoretically parse:

get () sum {
  return this.a + this.b
}

...and understand that we can cache the sum getter and only invalidate when a or b are set. We would need this in the JS compiler, though, and we would need to bail out if we don't have the full information:

get () sum {
  return somethingOutsideOfImmediateScope()
}

@nolanlawson
Copy link
Collaborator

@AllanOricil It is a fair point, that sometimes you might want caching even without computed properties. E.g.:

get foo () {
  return somethingSuperExpensive()
}

I guess the question is whether the framework should proactively try to cache getters (as in this PR), or if we should rely on an explicit signal from developers (such as @cached).

@caridy
Copy link
Contributor

caridy commented Aug 18, 2021

@AllanOricil @nolanlawson this can't be about new decorators, this has to be about existing code, and how to optimize it without developers intervention.

I think this PR was trying to move the caching to the compiled code, but it seems like it is too complex, and covers too little. An alternative solution is to add a layer in the engine itself which has access to the lifecycle mechanism and instance level metadata. That might be a far better option, or maybe a combination of both, saying: {foo} will need caching if it appears more than once in the template, but it is only used once, then why bothering to add more complexity?

@nolanlawson
Copy link
Collaborator

Since this is an observable change, it may only be possible to merge after API versioning is in.

@jodarove Do you still want to proceed with this PR?

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.

Memoize values used more than once during the template function execution
6 participants