Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

perf($compile): Lazily compile the transclude function #12078

Closed
wants to merge 1 commit into from

Conversation

dcherman
Copy link
Contributor

For transcluded directives, the transclude function can be lazily compiled
most of the time since the contents will not be needed until the
transclude function was actually invoked. For example, the transclude
function that is passed to ng-if or ng-switch-when does not need to be
invoked until the condition that it's bound to has been matched. For
complex trees or switch statements, this can represent significant
performance gains since compilation of branches is deferred, and that
compilation may never actually happen if it isn't needed.

In a large application where I work that recently had some very complex ngSwitch cases added, this change significantly reduce the time spent in the compilation stage of the $digest cycle and further reduced the used JS heap size from 123MB to 56.8MB.

There are two instances where compilation will not be lazy; when we scan
ahead in the array of directives to be processed and find at least two of
the following:

  • A directive that is transcluded and does not allow multiple transclusion
  • A directive that has templateUrl and replace: true

In both of those cases, we will need to continue eager compilation in
order to generate the multiple transclusion exception at the correct time. If anyone has a better idea on how to support this while maintaining backwards compatibility with existing unit tests, I'm all ears.

Fixes #6072

@lgalfaso
Copy link
Contributor

This PR proposes a tradeoff

Pro

  • The initial compilation is faster :)
  • Uses less memory

Con

  • If there is a directive with templateUrl that causes the compilation to continue asynchronously, then there is an extra wait when this needs to get compiled
  • Compilation errors on parts that are not displayed will not be detected by just loading the page

The PR still needs a few cleanups here and there and more than a few tests, but that aside, I like the idea proposed.

@petebacondarwin WDYT for 1.5?

// is found, then we know that when we encounter a transcluded directive, we need to eagerly
// compile the `transclude` function rather than doing it lazily in order to throw
// exceptions at the correct time
if (!didScanForMultipleTransclusion && (directive.templateUrl && directive.replace)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be !didScanForMultipleTransclusion && (a || b)? Right now it is !didScanForMultipleTransclusion && a || b...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Absolutely, logic fail on my part.

@jbedard
Copy link
Contributor

jbedard commented Jun 11, 2015

Just to confirm... if you have an ng-if that never evaluates to true, it will never compile? Or a switch with 10 cases but only 1 case is ever used, it will never compile the other 9?

@dcherman
Copy link
Contributor Author

So the deferred loading of child templateUrl/async directives is something that I thought about quite a bit and figured would come up in this discussion. I think I agree that this will probably have to wait for another significant version bump.

I think that is mitigated by two factors:

  1. In many applications ( including my own ), a build step pre-warms the $tempateCache with the content of these templates, thereby avoiding the delayed HTTP request.
  2. In very large applications ( again including some of my own ) where it's not reasonable to inline everything due to file size concerns, adding proper cache headers should help mitigate repeated performance issues since only a single request would be made for the template.

I avoided doing it in this pull request since I am not nearly familiar enough with the compile flow yet, but since the generated link functions are often long lived, I wonder there are references that can be nulled out to make them GC eligible once compilation for the directive is complete.

@dcherman
Copy link
Contributor Author

@jbedard Precisely. I encountered this exact situation in an application that I support where one of the developers had written a giant ngSwitch statement ( like 20 cases ), and each of the cases had different templates to compile. By implementing this change, I brought the app startup and interactivity time from 20-30 seconds down to 1-2.

At the moment, I'm having them work around the performance issue by manually using $compile with a real switch statement in another directive, but I feel like this concept would positively impact nearly all applications that use transclusion that isn't immediately called whether it's through ngIf, ngSwitch, custom directives, etc...

@fenduru
Copy link

fenduru commented Jun 11, 2015

@lgalfaso your point regarding templateUrl's is absolutely correct; however, even in this situation this is a balancing act because while you might save some async processing later, you're causing a flood of HTTP requests at load-time for all of the nested templates that may never even be needed.

@jbedard
Copy link
Contributor

jbedard commented Jun 11, 2015

Maybe it's time for the giant directive loop in applyDirectivesToNode to be split up a bit? Could loop over the directives once before the main loop to look for the multiple transclude / template errors?

That would be a change in how error handling works though since it would throw before compiling anything instead of half way through. But that actually sounds better to me anyway...

@dcherman
Copy link
Contributor Author

The only problem with that is that when we encounter the templateUrl + replace case, we can't synchronously know whether or not we will actually encounter the multiple transclusion error.

I can very easily move the loop outside of the for loop, however I don't think I'd be able to maintain the exact same error messages as I did with this PR since $compileNode can be modified by directives based on their definition, and that is used to generate the error message ( looks like it pulls the starting tag name ).

The file could probably benefit from some maintenance though for sure.

@dlongley
Copy link
Contributor

dlongley commented Jul 8, 2015

This is just a note that this PR is related to #9413. Lazy compilation of transcluded content was the original goal that led to discovery of that bug.

There's a test here in the core suite that exercises it: https://github.com/angular/angular.js/blob/master/test/ng/compileSpec.js#L6064-L6077

And a directive here that has been used to improve performance as mentioned above: https://github.com/digitalbazaar/bedrock-angular-lazy-compile

A very significant performance improvement was seen in start up time (and memory usage) for applications that include modals (or "sheets") that only need to be shown once activated. Note that the above directive also includes an experimental feature that attempts to cache the same transcluded content only once, further saving memory in certain situations.

Having this sort of behavior on by default in angular core would be great.

@lgalfaso
Copy link
Contributor

@dcherman we would like to get this into master and for it to make it into 1.5, but for this to happen it needs some tests and documentation changes. Would it be possible for you to add these to this PR?

@dcherman
Copy link
Contributor Author

@lgalfaso Happy to do so. Other than the confirming that the new lazy behavior is working, were there any other tests that wanted added related to this?

@lgalfaso
Copy link
Contributor

@dcherman

  • Test that check that the lazy behavior works when expected
  • That it does not in the cases outlined in the code

In all cases, using templateUrl and template

@dcherman
Copy link
Contributor Author

Working on this now. @lgalfaso out of curiosity, what's the timeline for 1.5? An alternative solution if you feels it's worthwhile could be to add $compileProvider.lazyCompilation( boolean ) which would default to false ( the current behavior ). If users wanted the optimization, they could opt into it without having to wait for 1.5. Once 1.5 hits, you could choose to swap the default to true if you wanted.

@lgalfaso
Copy link
Contributor

@dcherman I would start by making this into 1.5, and then talk about back-porting this as something optional. The compiler core is very complex, and adding configurable behavior changes in this specific part of the code is not something I am very fond of. Anyhow, if there is enough interest and traction, then it is something that can be discussed.

@dcherman
Copy link
Contributor Author

I've added some tests and am thinking about how to document some things. You'll notice that some tests for the eager cases weren't added; those code paths are already covered by earlier tests that check for multiple transclusion errors ( the reason eager loading was added in the first place ).

I can add tests if you want, but they'll literally be copied and pasted from those earlier tests with the name of the test changed.

}
}

didScanForMultipleTransclusion = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if there is some corner case and we should add at line 1859
didScanForMultipleTransclusion = false?

This is, after a directive with a template and replace: true is found, then we might need to rescan once again

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Rather than doing that, template and replace: true should just be another case that can cause an eager compile if a second one is found. I'm not overly concerned about that path compiling eagerly since replace: true is deprecated

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, sounds good

@lgalfaso
Copy link
Contributor

Overall, I like how this looks like. We should take a look at some of the most weird test cases, but overall, it looks fine.

@lgalfaso lgalfaso modified the milestones: 1.5.x - migration-facilitation, Ice Box Jul 18, 2015
For transcluded directives, the transclude function can be lazily compiled
most of the time since the contents will not be needed until the
`transclude` function was actually invoked.  For example, the `transclude`
function that is passed to `ng-if` or `ng-switch-when` does not need to be
invoked until the condition that it's bound to has been matched.  For
complex trees or switch statements, this can represent significant
performance gains since compilation of branches is deferred, and that
compilation may never actually happen if it isn't needed.

There are two instances where compilation will not be lazy; when we scan
ahead in the array of directives to be processed and find at least two of
the following:

* A directive that is transcluded and does not allow multiple transclusion
* A directive that has templateUrl and replace: true
* A directive that has a template and replace: true

In both of those cases, we will need to continue eager compilation in
order to generate the multiple transclusion exception at the correct time.
* @param previousCompileContext
* @returns {Function}
*/
function compilationGenerator(eager, $compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext) {
Copy link
Contributor

Choose a reason for hiding this comment

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

[Not a blocker to land this]
I am somehow torn on having all the parameters here or if we should use arguments. i.e.

function compilationGenerator(eager) {
  var compileArguments = Array.slice(arguments);
  compileArguments.shift();
  [...]
    return compile.apply(null, compileArguments);
  [...]
}

It is somehow more code, but it should be more resilient to changes. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If someone makes a change that makes the arguments passed invalid, we should have sufficient unit tests in order to catch any resulting bugs. Since the compile stage is generally considered a hot path, before making this change I'd be curious to see what ( if any ) perf implications there might be from V8 being unable to optimize this function due to the use of arguments like that.

We can also consider copying the arguments manually with a for loop which avoids the deopt if you think it really would make it more resilient.

Copy link
Contributor

Choose a reason for hiding this comment

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

SGTM

@lgalfaso
Copy link
Contributor

Other that that, LGTM

@lgalfaso
Copy link
Contributor

Will wait until we branch 1.4.x to land this.

Thanks @dcherman !

@lgalfaso
Copy link
Contributor

landed as 652b83e

@lgalfaso lgalfaso closed this Sep 19, 2015
@Narretz
Copy link
Contributor

Narretz commented Sep 19, 2015

Nice! Let's see what it breaks ;)
Am 19.09.2015 22:35 schrieb "Lucas Mirelmann" notifications@github.com:

Closed #12078 #12078.


Reply to this email directly or view it on GitHub
#12078 (comment).

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants