Skip to content

Fix reassignment tracking performance issues - #2218

Merged
lukastaegert merged 6 commits into
masterfrom
better-reassignment-tracking
May 28, 2018
Merged

Fix reassignment tracking performance issues#2218
lukastaegert merged 6 commits into
masterfrom
better-reassignment-tracking

Conversation

@lukastaegert

Copy link
Copy Markdown
Member

Resolves #2212
Resolves #2210 (not checked explicitly, please confirm!)

As outlined here: #2212 (comment), this PR fixes a potential source of performance degradation when there are functions with many return statements by treating them as returning an "unknown" value.

There is also a second change included: Previously, both variable and object path reassignments were tracked in the LocalVariable objects. Tracking the latter, however, was rather complicated and did not reflect the real situation where path reassignments are mutations of the original literal. To that end, I moved the path tracking to the actual object expressions. This will help us a lot when we start implementing object path tree-shaking.

@lukastaegert lukastaegert changed the title Improve reassignment tracking Fix reassignment tracking performance issues May 25, 2018
@lukastaegert

Copy link
Copy Markdown
Member Author

Performance of this branch seems to be about equal to the performance of 0.58.2 with slightly slower graph analysis and slightly faster tree-shaking.

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tried my best to follow this one. Mostly just a bunch of issues was the result as opposed to useful feedback.

Still not following it all quite yet, but hope the feedback helps.

) => (node: ExpressionEntity) => void;
options: ExecutionPathOptions,
node: ExpressionEntity
) => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Much nicer!

addImplicitReturnExpressionToScope() {
const lastStatement = this.body[this.body.length - 1];
if (!lastStatement || lastStatement.type !== NodeType.ReturnStatement) {
this.scope.addReturnExpression(UNKNOWN_EXPRESSION);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can this not be UNDEFINED_EXPRESSION?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There originally was an UNDEFINED_EXPRESSION but it turned out to be equivalent to UNKNOWN_EXPRESSION so I dropped it. With the new literal value tracking, re-introducing it would actually make sense. Since this is intended to be a bug-fix, I would postpone this for a PR explicitly addressing #2223.

return path.length > 2;
}
return true;
return path.length > 2 || path[0] !== 'prototype' || this.isPrototypeReassigned;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is the prototype is treated as a special case in the following:

function x () {}
x.prototype = unknown;
x.thing = unknown;

x.prototype.a.b;
x.thing.a.b;

when surely any deep reassignment on a function or object should apply effects?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is about the not-so-deep reassignments. More specifically, this very important situation that is often part of initialization code is not a side-effect:

function X(){};
X.prototype.myMethod = function() { console.log('hello'); }

while this is:

function X(){};
X.doesNotExist.myMethod = function() { console.log('hello'); }

Note that if X is included for whatever reason, all prototype assignments will be included as well. I think as long as we do not have proper flow tracking, a simple flag to take care of the prototype should be enough to handle situations such as this:

function X(){}
X.prototype.myMethod = function() {}

function Y(){}
Y.prototype = X.prototype; // this will mark the prototype of `Y` as reassigned
Y.prototype.myMethod = function() { console.log('hello'); } //  this mutation needs to be retained

const x = new X();
x.myMethod();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the clear explanation that makes complete sense. To continue to help aid my understanding I added a further question about more generic path reassignment checking below, which seems like it is related here.

for (const param of this.params) {
param.declare('parameter', null);
}
this.body.addImplicitReturnExpressionToScope();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this mean that a function whose return expression is not explicitly the last statement will then be treated as having unknown return?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems here's an example case that is invalided by this change:

function x () {}
function y () {}
function f () {
  if (Math.random() > 0.5)
    return y;
  return x;
}
f()();

that would otherwise be tree-shaken.

It doesn't seem a huge loss to me though as it is really hypothetical.

I wonder though if we could not treat the whole function itself as having a "single type" (set from the first return), and only opt-out only if we find that a return value doesn't follow that type rule? That might stop the deep loops while still retaining the basic functionality here? Just a thought.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess you wouldn't know that x and y are the same without the full deep analysis though... so yeah. But perhaps having a type like f: PURE_FUNCTION or f: STRING_LITERAL would be useful?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It might be but it would also mean creating a whole new analysis step and another layer of types. I would rather wait for us to have some basic SSA going and use this as a base for much better analysis. Otherwise I would not take action unless there are some situations in important libraries for which this change would provide important gains.

As related to your first comment, rollup also does not yet detect unreachable code after return, break or throw statements. So this would be treated as "unknown" as well:

function x() {
  return false;
  const unimportant = false;
}

if (x()) console.log('not removed');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sure, as I say these certainly seem suitable compromises to me.

}
currentProperty.isReassigned = true;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very nice to get rid of this.

options: ExecutionPathOptions
) {
if (
!this.isReassigned &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a very impressive simplification. I can't say I've followed exactly, but I've tested the cases (as you've seen...) and it seems to definitely work well with what I've tried.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Basically the logic has moved to object expression. Which makes much more sense IMO as

  • this goes much better with the notion of mutations of object references
  • different types react different to mutations. E.g. a number will throw an error when properties are mutated (in strict mode) and itself not be mutated.

callOptions,
callback,
options
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In theory hasCertainHit should have caught this case:

var x = {
	a () {
	},
	[unknown] () {
  }
};
x.a();

but it seems it wasn't applying to such a thing anyway so that removing this is fine.

That said, if we extend this type of logic to deal with progressive property sets within reduced control flows like:

var exports = {};

exports.a = function () {
}

exports.a();

then we may want to be detecting this again.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

in theory hasCertainHit should have caught this case

Not 100% sure what you mean by "caught" as I do not see any side-effects to be detected. In general one thing this algorithm tries to capture is that when two properties with the same name appear in a literal, the second overwrites the first. Thus when we access .a and an unknown property appears after a, both need to be considered while any unknown properties before a can be safely ignored. When there are only unknown properties, calling a property is always a side-effect: REPL

That said, if we extend this type of logic to deal with progressive property sets

Yes, these cases should certainly still be considered. I think there should already be some tests for this. I have thought a little about how an SSA form could be extended to handle property reassignments. One concept that comes to my mind is considering object expressions to be like "scopes" where the properties are the "variables" that are duplicated:

// code
const x = {a: false};
const y = x;
x.a = true;
if (y.a) console.log('properly tracked');

// "SSA form"
// the object expression OE on the right side keeps track of its properties
const x1 = {a1: false};
const y1 = x1;

// we define version 2 of property a; the actual representation will probably be different
// note that this does NOT define a new x2 as x still refers to the same object reference OE
x1.a2 = true;

// as we know that y1 refers to OE, we know we need to use version 2 here
if (y1.a2) console.log('properly tracked');

// tree-shaken result
console.log('properly tracked');

You can already see that something like this needs very sophisticated analysis and for the first attempt at SSA I would not include property assignments but focus on actual variable reassignments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In theory, unknown could be equal to 'a', which would then take precedence meaning it is not a certain hit even though there is an explicit 'a' defined. certainHit logic has precedence logic in it (loop in reverse order), yet doesn't quite go all the way in taking these concepts to their complete conclusions.

We lose the ability to go back on this here, but yes it's a super edge case. I'm mentioning because we are so close to handling the edge case, and now moving away from it. Projects that get massive traction need to be watertight - that is a big difference between a useful software project, and a massively supported and used project.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very interesting object interpretation of SSA.

Somewhat unrelated, but I've long wanted to have the available analysis to form the following optimization:

var x = 'asdf';
var z = 'g';

export function y () {
  console.log(z);
}

export function p() {
  console.log(x);
}

If one module uses just y and another module uses just p, we don't need to keep the definitions of y and p in the same chunk necessarily and can actually split the above module into two separate modules:

var x = 'asdf';
export function p() {
  console.log(x);
}
var z = 'g';
export function y () {
  console.log(z);
}

This is a super complex long-term code splitting optimization I've wanted to implement for a while, and likely won't be able to implement for a while - but mentioning here as it would usefully piggy-back off similar analysis.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In theory, unknown could be equal to 'a', which would then take precedence meaning it is not a certain hit even though there is an explicit 'a' defined. certainHit logic has precedence logic in it (loop in reverse order), yet doesn't quite go all the way in taking these concepts to their complete conclusions

Actually as far as I know, it does go all the way 😜 Let me elaborate:
hasCertainHit means "we are certain there is a property of the given name". It does not mean we are certain which one it is! And in fact it is only used in the negative: When we are not certain there is a property of the given name, calling it must always be a side-effect.

If we are certain on the other hand, we check a whole array of properties for side-effects. Cf. this example: REPL

Here we do have a certain hit; the algorithm, however, does not only check the last method
with name "a" for side-effects but also all computed properties after it.

I have added this example + some variations as a function test to make sure this works even after the update and keeps working in the future.

Somewhat unrelated, but I've long wanted to have the available analysis to form the following optimization

Doing this in an arbitrary way might be difficult if the right hand side of the declaration itself depends on other variables. Something simpler that might be able to solve this problem in some situations and that I would consider quite doable would be inlining variables that are only used in one place (if they are used in more places, you would loose the reference identity). If done right i.e. in a way that "a variable can be inlined if it is only used once or only depends on other variables that can be inlined", then this would inline .e.g z into y and move both into the chunk where they are used

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What I mean by being inconsistent is that the example is tree-shaken - https://rollupjs.org/repl?version=0.59.2&shareable=JTdCJTIybW9kdWxlcyUyMiUzQSU1QiU3QiUyMm5hbWUlMjIlM0ElMjJtYWluLmpzJTIyJTJDJTIyY29kZSUyMiUzQSUyMnZhciUyMHglMjAlM0QlMjAlN0IlNUNuJTVDdGElMjAoKSUyMCU3QiU1Q24lNUN0JTdEJTJDJTVDbiU1Q3QlNUJ1bmtub3duJTVEJTIwKCklMjAlN0IlNUNuJTIwJTIwJTdEJTVDbiU3RCUzQiU1Q254LmEoKSUzQiUyMiU3RCU1RCUyQyUyMm9wdGlvbnMlMjIlM0ElN0IlMjJmb3JtYXQlMjIlM0ElMjJjanMlMjIlMkMlMjJuYW1lJTIyJTNBJTIybXlCdW5kbGUlMjIlMkMlMjJnbG9iYWxzJTIyJTNBJTdCJTdEJTJDJTIyYW1kJTIyJTNBJTdCJTIyaWQlMjIlM0ElMjIlMjIlN0QlN0QlMkMlMjJleGFtcGxlJTIyJTNBbnVsbCU3RA== when in theory it shouldn't necessarily be.

Doing this in an arbitrary way might be difficult if the right hand side of the declaration itself depends on other variables. Something simpler that might be able to solve this problem in some situations and that I would consider quite doable would be inlining variables that are only used in one place (if they are used in more places, you would loose the reference identity). If done right i.e. in a way that "a variable can be inlined if it is only used once or only depends on other variables that can be inlined", then this would inline .e.g z into y and move both into the chunk where they are used

I feel quite strongly on this one that "gathering related branches" is an important part of the module splitting process.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

when in theory it shouldn't necessarily be

In which theory? Add a side-effect and it will not be removed. Do you want the algorithm to be dumber in this place? Why?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In which theory? Add a side-effect and it will not be removed. Do you want the algorithm to be dumber in this place? Why?

Oh wow, you are right, and this works perfectly with precedence with the change. Sorry to doubt :)

Comment thread src/ast/nodes/ObjectExpression.ts Outdated
return true;

if (path.length === 1 && typeof key === 'string' && objectMembers[key]) {
return predicateFunction(options, objectMembers[key].returns);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wasn't able to come up with any test that checks this behaviour. Any ideas? Would be nice to capture as much of this as possible through coverage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are very right. I added some tests around these lines and also discovered a bug while doing so...

@lukastaegert

Copy link
Copy Markdown
Member Author

Thanks for the thorough review, I have added some more tests + fixed some ordering issue in object expression.

@guybedford guybedford left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, thanks for taking the time to explain this stuff.

if (path.length === 1 && path[0] === 'prototype') {
this.isPrototypeReassigned = true;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To follow-on the comment about the prototype, ideally would it not be the job of reassignPath to track all reassignments to fully distinguish SSA-analyzable paths from non-SSA-analyzable paths?

Further, ideally reassignPath should be the same here for both objects and functions surely, apart from some core opt-outs like length which would throw on reassignment?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes and no. At the moment, all paths of a function except the prototype are treated as "unknown". So tracking reassignments here would just mean an "unknown" gets replaced by another "unknown" so there is no use doing more analysis.

I thought about adding more logic here but I think it only makes sense if there are at least a few more function properties that are known to rollup. Until then, a single flag for the prototype seemed like enough logic.

return path.length > 2;
}
return true;
return path.length > 2 || path[0] !== 'prototype' || this.isPrototypeReassigned;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the clear explanation that makes complete sense. To continue to help aid my understanding I added a further question about more generic path reassignment checking below, which seems like it is related here.

for (const param of this.params) {
param.declare('parameter', null);
}
this.body.addImplicitReturnExpressionToScope();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sure, as I say these certainly seem suitable compromises to me.

callOptions,
callback,
options
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In theory, unknown could be equal to 'a', which would then take precedence meaning it is not a certain hit even though there is an explicit 'a' defined. certainHit logic has precedence logic in it (loop in reverse order), yet doesn't quite go all the way in taking these concepts to their complete conclusions.

We lose the ability to go back on this here, but yes it's a super edge case. I'm mentioning because we are so close to handling the edge case, and now moving away from it. Projects that get massive traction need to be watertight - that is a big difference between a useful software project, and a massively supported and used project.

callOptions,
callback,
options
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Very interesting object interpretation of SSA.

Somewhat unrelated, but I've long wanted to have the available analysis to form the following optimization:

var x = 'asdf';
var z = 'g';

export function y () {
  console.log(z);
}

export function p() {
  console.log(x);
}

If one module uses just y and another module uses just p, we don't need to keep the definitions of y and p in the same chunk necessarily and can actually split the above module into two separate modules:

var x = 'asdf';
export function p() {
  console.log(x);
}
var z = 'g';
export function y () {
  console.log(z);
}

This is a super complex long-term code splitting optimization I've wanted to implement for a while, and likely won't be able to implement for a while - but mentioning here as it would usefully piggy-back off similar analysis.

@lukastaegert
lukastaegert merged commit fba6877 into master May 28, 2018
calebeby referenced this pull request in Pigmice2733/scouting-frontend May 30, 2018
This Pull Request updates dependency [rollup](https://github.com/rollup/rollup) from `v0.59.0` to `v0.59.4`



<details>
<summary>Release Notes</summary>

### [`v0.59.4`](https://github.com/rollup/rollup/blob/master/CHANGELOG.md#&#8203;0594)
[Compare Source](rollup/rollup@v0.59.3...v0.59.4)
*2018-05-28*
* Fix performance regression when many return statements are used ([#&#8203;2218](`https://github.com/rollup/rollup/pull/2218`))

---

### [`v0.59.3`](https://github.com/rollup/rollup/blob/master/CHANGELOG.md#&#8203;0593)
[Compare Source](rollup/rollup@v0.59.2...v0.59.3)
*2018-05-24*
* Fix reassignment tracking for constructor parameters ([#&#8203;2214](`https://github.com/rollup/rollup/pull/2214`))

---

### [`v0.59.2`](https://github.com/rollup/rollup/blob/master/CHANGELOG.md#&#8203;0592)
[Compare Source](rollup/rollup@v0.59.1...v0.59.2)
*2018-05-21*
* Fix reassignment tracking in for-in loops ([#&#8203;2205](`https://github.com/rollup/rollup/pull/2205`))

---

### [`v0.59.1`](https://github.com/rollup/rollup/blob/master/CHANGELOG.md#&#8203;0591)
[Compare Source](rollup/rollup@v0.59.0...v0.59.1)
*2018-05-16*
* Fix infinite recursion when determining literal values of circular structures ([#&#8203;2193](`https://github.com/rollup/rollup/pull/2193`))
* Fix invalid code when simplifying expressions without spaces ([#&#8203;2194](`https://github.com/rollup/rollup/pull/2194`))

---

</details>




---

This PR has been generated by [Renovate Bot](https://renovatebot.com).
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.

Rollup didn't finish over 30 minutes. LocalVariable.prototype.reassignPath run into a endless loop with rxjs.

2 participants