Skip to content

Tree shaking fixes - #2315

Merged
lukastaegert merged 13 commits into
masterfrom
tree-shaking-fixes
Jul 17, 2018
Merged

Tree shaking fixes#2315
lukastaegert merged 13 commits into
masterfrom
tree-shaking-fixes

Conversation

@lukastaegert

@lukastaegert lukastaegert commented Jul 2, 2018

Copy link
Copy Markdown
Member

Resolves #2306.
Resolves #2316.
Resolves #2303.
Resolves #2302.
Resolves #2326.

This resolves quite a few issues of the tree-shaking algorithm. Some were caused by the new return expression handling logic while others were actually older. It also comes with 14(!) new functional tests, most of which were red. Here is a rundown of the most important issues that were fixed:

There is also one improvement that was previously overlooked: Literal values of return expressions used to be handled as "unknown". This is fixed, this: https://rollupjs.org/repl?version=0.62.0&shareable=JTdCJTIybW9kdWxlcyUyMiUzQSU1QiU3QiUyMm5hbWUlMjIlM0ElMjJtYWluLmpzJTIyJTJDJTIyY29kZSUyMiUzQSUyMmZ1bmN0aW9uJTIwcmV0dXJuVHJ1ZSgpJTIwJTdCJTVDbiU1Q3RyZXR1cm4lMjB0cnVlJTNCJTVDbiU3RCU1Q24lNUNuZnVuY3Rpb24lMjByZXR1cm5GYWxzZSgpJTIwJTdCJTVDbiU1Q3RyZXR1cm4lMjBmYWxzZSUzQiU1Q24lN0QlNUNuJTVDbmlmJTIwKHJldHVyblRydWUoKSklMjAlN0IlNUNuJTVDdGNvbnNvbGUubG9nKCdyZXRhaW5lZCcpJTNCJTVDbiU3RCU1Q24lNUNuaWYlMjAocmV0dXJuRmFsc2UoKSklMjAlN0IlNUNuJTVDdGNvbnNvbGUubG9nKCdyZW1vdmVkJyklM0IlNUNuJTdEJTVDbiUyMiU3RCU1RCUyQyUyMm9wdGlvbnMlMjIlM0ElN0IlMjJmb3JtYXQlMjIlM0ElMjJlcyUyMiUyQyUyMm5hbWUlMjIlM0ElMjJteUJ1bmRsZSUyMiUyQyUyMmdsb2JhbHMlMjIlM0ElN0IlN0QlMkMlMjJhbWQlMjIlM0ElN0IlMjJpZCUyMiUzQSUyMiUyMiU3RCU3RCUyQyUyMmV4YW1wbGUlMjIlM0FudWxsJTdE now simplifies to

{
	console.log('retained');
}

At the core, those were the main reasons for these issues + their fixes:

  • If we "lost track" of a variable, we "reassigned all properties" of the variable while we should also have reassigned its return expression if there was one. This is now handled in that a reassignment of an unknown key also reassigns the return expression if applicable. This is a little bit of a hack but otherwise we would have needed a whole lot more deoptimization logic here.
  • Certain expressions like conditionals and logical expressions could not be sure which branch applied during the bind phase which lead to issues with reassignments and return expressions. Also, test values and return expressions could not be reliably cached. This is inverted now by a new "cache and deoptimize" logic which applies to the test values of logical expressions, conditional expressions and if-statements, computed property keys, and the return values of call expressions and getter properties. It works like this:
    1. In their bind method or in either getLiteralValueAtPath, getReturnExpressionWhenCalledAtPath or reassignPath (whichever is called first; all of these can be called during the bind phase), those nodes retrieve their relevant literal values or return expressions and cache these.
    2. When retrieving those, they supply themselves as an additional parameter for later "deoptimization".
    3. If the retrieved value is no longer reliable due to a reassignment, the entity where the reassignment happens calls the new deoptimize method of the original node.
    4. Deoptimizations can be caused by:
    • Reassigned local variables
    • Reassigned object expression properties
    • Deoptimized intermediate expressions like conditionals

With the new logic, performance seems to be about on par with the old logic. One great advantage of these changes is that we are now independent of the execution order of reassignments which again would enable us to move binding of entities into the tree-shaking phase (e.g. we could bind all sub-scopes each time a function is included).

As an aside, I reworked the logic for object expressions to build a map of all available properties once to avoid reiterating over its properties on every access.

Update 1

I also added a simple fix for literal values of variables declared in for-loops.

Update 2

I added a simple fix for untracked reassignments across object rest and spread operators in objects expressions and object patterns. Also updated some definitions.

@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.

First comments after trying this out today... still wrapping my head around it, will continue tomorrow morning to hopefully get the full review done then.

Looks very intuitive from first glance.

} else if (path[0] === UNKNOWN_KEY) {
this.isPrototypeReassigned = true;
this.scope.getReturnExpression().reassignPath(UNKNOWN_PATH);
}

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.

As far as I can tell this is for the scenario:

function x () {
}
x.asdf = 'asdf';

in which case surely the return expressions of the function don't need to be reassigned? If you're after x().asdf = 'asdf' then that would only apply to the call expression surely?

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 the given example, path[0] would be 'asdf'. This scenario corresponds to something like

function x () {}
x[thisIsPrototype]= {the: 'prototype'};

as well as

function x () {}
globalFunction(x);

Why the latter? In this situation, we lose track of x. For simplicity, this is tracked as reassigning [UNKNOWN_KEY] (as this goes hand in hand in most situations). I added comments at the corresponding places of FunctionNode and ArrowFunctionExpression explaining this as well as further tests as commenting out these lines did not turn any test red.

Comment thread src/ast/nodes/SpreadElement.ts Outdated
bind() {
super.bind();
// Only properties of properties of the argument could become subject to reassignment
this.argument.reassignPath([UNKNOWN_KEY, UNKNOWN_KEY]);

Copy link
Copy Markdown
Contributor

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 like the above issue may be that yield isn't handled at all.

Here's another version of that using a custom iterator protocol, which also fails:

https://rollupjs.org/repl?version=0.62.0&shareable=JTdCJTIybW9kdWxlcyUyMiUzQSU1QiU3QiUyMm5hbWUlMjIlM0ElMjJtYWluLmpzJTIyJTJDJTIyY29kZSUyMiUzQSUyMnZhciUyMG9iaiUyMCUzRCUyMCU3QiU1Q24lNUN0eCUzQSUyMGZhbHNlJTVDbiU3RCUzQiU1Q24lNUNudmFyJTIwcCUyMCUzRCUyMCU3QiU1Q24lNUN0JTVCU3ltYm9sLml0ZXJhdG9yJTVEJTIwKCklMjAlN0IlNUNuJTVDdCU1Q3R2YXIlMjBmaXJzdCUyMCUzRCUyMHRydWUlM0IlNUNuJTVDdCU1Q3RyZXR1cm4lMjAlN0IlNUNuJTVDdCU1Q3QlNUN0bmV4dCUyMCgpJTIwJTdCJTVDbiU1Q3QlNUN0JTVDdCU1Q3RpZiUyMChmaXJzdCklMjAlN0IlNUNuJTVDdCU1Q3QlNUN0JTVDdCU1Q3RmaXJzdCUyMCUzRCUyMGZhbHNlJTNCJTVDbiU1Q3QlNUN0JTVDdCU1Q3QlMjAlMjByZXR1cm4lMjAlN0IlNUNuJTVDdCU1Q3QlNUN0JTVDdCU1Q3QlMjAlMjBkb25lJTNBJTIwZmFsc2UlMkMlNUNuJTVDdCU1Q3QlNUN0JTVDdCU1Q3QlMjAlMjB2YWx1ZSUzQSUyMG9iaiU1Q24lNUN0JTVDdCU1Q3QlNUN0JTIwJTIwJTdEJTNCJTVDbiU1Q3QlNUN0JTVDdCU1Q3QlN0QlNUNuJTVDdCU1Q3QlNUN0JTVDdHJldHVybiUyMCU3QiUyMGRvbmUlM0ElMjB0cnVlJTJDJTIwdmFsdWUlM0ElMjBudWxsJTIwJTdEJTNCJTVDbiU1Q3QlNUN0JTVDdCU3RCU1Q24lNUN0JTVDdCU3RCUzQiU1Q24lNUN0JTdEJTVDbiU3RCUzQiU1Q24lNUNuJTVCLi4ucCU1RCU1QjAlNUQueCUyMCUzRCUyMHRydWUlM0IlNUNuJTVDbmlmJTIwKG9iai54KSUyMCU3QiU1Q24lNUN0Y29uc29sZS5sb2coJ2l0JTIwaXMlMjB0cnVlJyklM0IlNUNuJTdEJTIyJTdEJTVEJTJDJTIyb3B0aW9ucyUyMiUzQSU3QiUyMmZvcm1hdCUyMiUzQSUyMmNqcyUyMiUyQyUyMm5hbWUlMjIlM0ElMjJteUJ1bmRsZSUyMiUyQyUyMmdsb2JhbHMlMjIlM0ElN0IlN0QlMkMlMjJhbWQlMjIlM0ElN0IlMjJpZCUyMiUzQSUyMiUyMiU3RCU3RCUyQyUyMmV4YW1wbGUlMjIlM0FudWxsJTdE

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.

(These are edge cases I know, but good to get them ironed out!)

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.

Definitely, thanks a lot! Will see what solutions I can find.

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.

The second example was already handle explicitly by the new spread element reassignment logic. Added a comment here + added handling for yield expressions.

* Fix an issue with losing track of reassigned missing keys
* Add comments clarifying the special handling of reassigning an unknown
  path of length 1
@guybedford

Copy link
Copy Markdown
Contributor

Sorry, the case I just mentioned isn't valid...

@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 great. Thanks for your patience on this one and keep up the great work.

}

reassignPath(path: ObjectPath) {
// A reassignment of [UNKNOWN_PATH] is considered equivalent to having lost track

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.

Did you mean A reassignment of UNKNOWN_PATH here, since UNKNOWN_PATH === [UNKNOWN_KEY]?

// A reassignment of [UNKNOWN_PATH] is considered equivalent to having lost track
// which means the return expression needs to be reassigned as well
this.scope.getReturnExpression().reassignPath(UNKNOWN_PATH);
}

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, that makes sense.

So the meaning of reassignPath is more like:

a) The path is being assigned somewhere we are no longer tracking
b) The path is being provided as an argument to a function we are no longer tracking

Is that all the cases?

It does seem somewhat circumstantial to the current analysis that the name here is reassignPath, as if we did happen to track some basic value propagation, then it would surely need another name.

Perhaps a clearer name other than reassignPath might help make this clear? Like taintPath, exposePath, deoptimizePath or spillPath or something?

A clear comment / documentation providing a comprehensive list of the above could help supplement the understanding here, I may well take this sort of work on at some point as an exercise in my own understanding, may well be beneficial both to increase my own contributions here as well as engaging other core analysis maintainers.

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 are more situations where a reassignment happens. In all situations where the path is NOT UNKNOWN_PATH, there is an actual assignment taking place. More situations where we reassign UNKNOWN_PATH:

  • there is more than one return expression
  • a previously optimized conditional is deoptimized
  • something is an export of the bundle
  • something is an array element
  • a variable has more than one declaration
  • in any assignment, not only is the EMPTY_PATH of the left side reassigned, we also reassign UNKNOWN_PATH of the right side as we are no longer tracking mutations here (this one is REALLY important)
  • this also goes for assignment patterns
  • something is yielded (this one is new)

But you are right, this double meaning of reassignPath(UNKNOWN_PATH) should warrant a new name. I will go for deoptimizePath and probably rename the existing deoptimize methods to deoptimizeCache to better distinguish between the two.

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 cases where reassignPath really does mean reassignPath could we retain the meaning, or is that a big refactor? I just think it seems like there should be a semantic distinction between reassignPath which refers to reassigning a binding (which might be possible to track in future), and deoptimizePath?

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.

Nice changing to deoptimizePath though that seems much clearer to me!

Or do you think that reassignPath for actual reassignments is always about deoptimization, and that if we were to have better tracking here, we'd do it another way?

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.

Also deoptimizeCache sounds great, nice to be clear on what exactly is the deoptimization!

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.

The hope is that ultimately with SSA, all remaining reassignments will be "real" deoptimizations where an entity is out of sight of the algorithm. But also in its current form, deoptimize is probably a better name because we never reassign to anything but mark it as evil and unreliable ;)

Comment thread src/ast/nodes/CallExpression.ts Outdated
);
}
this.returnExpression.reassignPath(path);
if (this.returnExpression !== 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.

This conditional isn't necessary? It isn't made in other places for the UNKNOWN style call noops, so just wondering why it's used here.

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.

Just to make it a little cheaper and save us possibly a call but yes, this is a little over-optimization.

) {
return UNKNOWN_EXPRESSION;
}
this.expressionsToBeDeoptimized.push(origin);

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, could we be adding duplicates to this? Should we not use a Set() implementation?

(that might also handle the UNKNOWN_EXPRESSION case more simply, if wanted anywhere)

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.

I initially had a Set here but as a matter of fact, this should not be possible as each return expression is only ever retrieved once. The same goes for literal values in other places. I also checked this in some places by throwing an error when adding a duplicate and there were none I could find.

// Caching and deoptimization:
// We collect deoptimization information if returnExpression !== UNKNOWN_EXPRESSION
private returnExpression: ExpressionEntity | null;
private expressionsToBeDeoptimized: DeoptimizableEntity[];

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.

Not sure if this is valid or not, but would it be possible to use this as a recursion tracker itself?

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.

I did not check but I would not assume it to work as well as the ImmutableEntityPathTracker in that it would deoptimize more literal values than necessary e.g. in situations where one origin needs more than one lookup to get its value. At least these were the reason why the immutable tracker was used here.

);
if (this.propertyKey === null) this.updatePropertyKey();
this.expressionsToBeDeoptimized.push(origin);
return this.object.getLiteralValueAtPath([this.propertyKey, ...path], recursionTracker, origin);

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.

Comment thread src/ast/nodes/ObjectExpression.ts Outdated

// We could also track this per-property but this would quickly become much more complex
deoptimize() {
if (!this.hasUnknownReassignedProperty) this.reassignAllProperties();

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 does seem a little unfortunate that the following:

var obj = {
  x: 'x',
  y: 'y'
};
var test = false;

function asdf () {
  return obj;
}

if (asdf().x === 'x')
  console.log('yes');

if (obj.x === 'x')
  console.log('true');

function updateTest () {
  test = true;
}

loses all the analysis benfits if the return statement is just changed to test ? obj : obj.

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.

I might give it a try to improve a little here

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.

No, it seems with the return expression handling, fixing this would be a little more involved. This will probably need to wait for SSA.

key = property.key.name;
} else {
key = String((<Literal>property.key).value);
}

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.

Perhaps cache the property here -

let propertyMapProperty = this.propertyMap[key];

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.

good idea

Comment thread src/ast/nodes/Property.ts
deoptimize() {
// As getter properties directly receive their values from function expressions that always
// have a fixed return value, there is no known situation where a getter is deoptimized.
throw new Error('Unexpected deoptimization');

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.

+1 to having internal assertions... will be useful to catch more bugs.

import { LiteralValueOrUnknown, ObjectPath, UNKNOWN_VALUE } from '../../values';
import { ExpressionEntity } from './Expression';

export class MultiExpression implements ExpressionEntity {

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 big step. Intermediate expressions!

Could function returns and generator yields not be treated as MultiExpression of all their return values?

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.

The problem with function returns turned out to be a problem of convergence order for some code-bases (say bundling the TypeScript compiler for instance). If there are many functions with many returns, then with only a few steps, a single hasEffects check could suddenly trigger a million secondary checks as all returnExpressions are checked. This should definitely be revisited, though. One might consider e.g. limiting the number of tracked return expressions to two or similar things.

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.

Definitely, setting a limit on this would be a good starting approach!

@lukastaegert

Copy link
Copy Markdown
Member Author

Implemented the suggested changes, thanks for the detailed review!

@lukastaegert
lukastaegert merged commit b403e6f into master Jul 17, 2018
@lukastaegert
lukastaegert deleted the tree-shaking-fixes branch July 20, 2018 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants