Tree shaking fixes - #2315
Conversation
9b62455 to
87955fa
Compare
87955fa to
47f88ea
Compare
guybedford
left a comment
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| bind() { | ||
| super.bind(); | ||
| // Only properties of properties of the argument could become subject to reassignment | ||
| this.argument.reassignPath([UNKNOWN_KEY, UNKNOWN_KEY]); |
There was a problem hiding this comment.
It's worth noting here that spread can call the iterator protocol which may result in side effects.
seems to fail the deoptimization process on this build.
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
(These are edge cases I know, but good to get them ironed out!)
There was a problem hiding this comment.
Definitely, thanks a lot! Will see what solutions I can find.
There was a problem hiding this comment.
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
|
Sorry, the case I just mentioned isn't valid... |
guybedford
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Also deoptimizeCache sounds great, nice to be clear on what exactly is the deoptimization!
There was a problem hiding this comment.
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 ;)
| ); | ||
| } | ||
| this.returnExpression.reassignPath(path); | ||
| if (this.returnExpression !== UNKNOWN_EXPRESSION) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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[]; |
There was a problem hiding this comment.
Not sure if this is valid or not, but would it be possible to use this as a recursion tracker itself?
There was a problem hiding this comment.
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); |
|
|
||
| // We could also track this per-property but this would quickly become much more complex | ||
| deoptimize() { | ||
| if (!this.hasUnknownReassignedProperty) this.reassignAllProperties(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I might give it a try to improve a little here
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
Perhaps cache the property here -
let propertyMapProperty = this.propertyMap[key];| 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'); |
There was a problem hiding this comment.
+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 { |
There was a problem hiding this comment.
This is a big step. Intermediate expressions!
Could function returns and generator yields not be treated as MultiExpression of all their return values?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Definitely, setting a limit on this would be a good starting approach!
|
Implemented the suggested changes, thanks for the detailed review! |
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
At the core, those were the main reasons for these issues + their fixes:
bindmethod or in eithergetLiteralValueAtPath,getReturnExpressionWhenCalledAtPathorreassignPath(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.deoptimizemethod of the original node.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.