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

WIP - only watch the inputs of parsed expressions #9006

Closed
wants to merge 2 commits into from

Conversation

jbedard
Copy link
Contributor

@jbedard jbedard commented Sep 10, 2014

This is a newer version of jbedard@844d04c which was discussed as an alternative to #8942.

The first commit should be good either way and makes a | noop about 20% faster in the benchmakrs by avoiding 2 unnecessary wrapper functions (binaryFn and valueFn). I can put this in a separate PR if wanted, but the main change depends on it.

The main change:

  • expression inputs are watched instead of the full expression - for example a + -a * b -b + 5 will watch a and b
  • when expression inputs change the full expression get evaluated, otherwise the previous result is assumed to still be valid
  • inputs are currently: variables/properties, field access (foo().field), object index (obj[sub-exp]) and function calls (and ; separated statements, at least for now)
  • this means all other expressions are not watched
  • I added (and have not tested) a externalInput flag that filters can set if they have some external state, then those filters will be considered inputs

In the expression watching benchmarks (which do not have the issues listed below):

  • operators are about 2x faster
  • filters are about 3x faster
  • object/array literals are about 5x faster
  • directly watching input expressions should be unchanged

Issues:

  • This only works for primitives today. If any inputs return a non-primitive it is assumed to have changed and executes the full statement. This will hurt performance for expressions with non-primitives inputs.
  • All inputs get executed per digest, if any change and the full expression gets executed then those child input expressions will get re-executed. For basic property inputs I think this is fine (although could be an area of improvement) - but for anything with side effects this will be an issue. The issue is not so much the double-calling but the fact that branching expressions should not always call their child expressions. The best example I can think of would be something such as: $rootScope.$watch('isLoggedIn || loggedOutAction()'). In this case, with the current implementation, isLoggedIn and loggedOutAction() would be considered inputs and executed in the watcher.

I might try other solutions to fix the two issues - maybe something simpler that only supports filters/literals for now which will get the best performance gains with the easiest workaround for those 2 issues.

@Narretz
Copy link
Contributor

Narretz commented Sep 10, 2014

So, what you are saying is that this change makes some expressions faster but others slower? If so, you should also add benchmarks for the stuff that gets slower, so it's easier to see if the change makes sense overall (factoring in how common these expression are).

The externalInput flag seems necessary if this goes in.

@lgalfaso
Copy link
Contributor

This is very interesting, but I am wondering if it should be able to handle expressions within function calls. This is expressions like foo(bar | filter)

@jbedard
Copy link
Contributor Author

jbedard commented Sep 11, 2014

Currently function calls are considered an input so they are reevaluated every time (along with their arguments). If functionCall was refactored a bit maybe we could split the function calling vs argument executing, and avoid rexecuting the filter in your example while still executing the function call. Or provide a flag to state that a function is "pure" like filter functions where the same input always produces the same output (or assume pure and add the opposite flag, but I think many (or most?) function calls are getters...). I think doing both of those would be good actually...

@lgalfaso
Copy link
Contributor

Hi,
It looks like there is some issue with the watch function. Here is a test that shows this

it('should calculate the literal every single time', inject(function($parse) {
  $filterProvider.register('foo', valueFn(function(input) {
    return input;
  }));
  scope.$watch($parse('{x: 1} | foo'), function(input) {
    expect(input).toEqual({x:1});
  });
  scope.$digest();
}));

@jbedard
Copy link
Contributor Author

jbedard commented Sep 11, 2014

There are a couple issues with this PR right now. I almost have an update ready which will also fix the unit tests. I'll try to update this tonight and I'll look into your test case.

One question I have (for anyone): should interceptor functions depend on external inputs or only depend on the output of the $parsed expression? Currently the isolated scope reference interceptor has external state that it depends on, so even if the watched $parse statement has not changed it must be called anyway. I think this is wrong and the isolated scope reference watcher should be updated, which would make this PR easier and could avoid the interceptorFn being called per digest (only call it when inputs change...). I think this would be a 2 line change to make the isolated scope watcher a normal function watcher instead of $parser + interceptorFn...

@lgalfaso
Copy link
Contributor

At $compile, when there is a directive with a 2-way bind (e.g scope: { foo: '='}}) there is a use of parse that relies on the interceptor function being called even when the value does not change. I do not think it is possible to go back to the previous approach where a wrap on the parsed function was used without some special handling for one-time binding

@jbedard
Copy link
Contributor Author

jbedard commented Sep 12, 2014

@lgalfaso Thanks, that's good to know. So I think I'll treat interceptors like filters and add the same externalInput flag which disables the input-watching.

Your test case is also interesting. That expression needs to be marked as a constant now that we are assuming filters (unless marked with externalInput) always return the same output given the same input...

@jbedard jbedard force-pushed the parse-watch-children branch 2 times, most recently from 0f025c8 to e656a78 Compare September 12, 2014 03:31
for (var i=0, ii=inputExpressions.length; i<ii; i++) {
var valI = inputExpressions[i](scope);
if (changed || (changed = !simpleEquals(valI, inputs[i]))) {
inputs[i] = valI;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm thinking of putting a break here. This may cause a couple extra parseExpression calls the first few digests until all the inputs have been evaluated once, but this will reduce the overhead when inputs are non-primitive. Or only break if non-primitive (maybe simpleEquals returns null instead of true/false to indicate a non-primitive), this might actually be the best for both cases...

@jbedard
Copy link
Contributor Author

jbedard commented Sep 12, 2014

Updated to pass all the tests and handle the case @lgalfaso pointed out.

The isPrimitive test was changed to a simpleEquals which is very similar, except allows support for simple objects such as dates.

Also changed the term "children" in the code to "inputs" like I've been using in the PR.

I'd still like to handle the main two issues I mentioned in the PR. I think expression nodes having a "has-side-effect" flag could handle one of the issues. Hopefully the comment I made in expressionInputsWatch can help the non-primitives. Still need lots of test cases and performance tests (especially for non-primitives).

@jbedard jbedard force-pushed the parse-watch-children branch from e656a78 to c6a6dd9 Compare September 12, 2014 16:16
@IgorMinar
Copy link
Contributor

I think that executing bar() in foo || bar() all the time is a problem since some already depend on short-circuiting behavior of such expressions.

I'm thinking of the best way to get this in, and it really comes down to identifying all the breaking changes and making them now. We are already in RCs and I'm not keen on any further breaking changes, but I'm willing to make an exception for this particular change because of the benefits it brings.

Am I correct that the only intentional breaking change is that filters are now treated as pure functions? Is there anything else?

If that's it then we should make a change that will treat filters in this new way and then take our time to fine-tune this PR.

@jbedard
Copy link
Contributor Author

jbedard commented Sep 12, 2014

Updated again.

Added some tests and another perf test case.

Removed the input-watching for branching expressions which fixes what you just mentioned with foo || bar(). I tried using input watching on these if there are no sub expressions with side effects (like bar()), but that actually seemed slower (in the new benchmark case I added) because watching the full expression takes advantage of the short-circuiting.

I slightly changed how the interceptorFn works so that $parse("a", interceptorFn) basically treats the interceptorFn the same as filters now, including the externalInput flag.

Breaking changes:

  • Filters and interceptor functions are considered pure unless flagged with externalInput
  • Sub-expressions may be executed more then previously, once to check for changes, once to execute the full expression. If sub-expressions such as function calls have side effects or the number of calls has meaning then this might break (see ngRepeatSpec diff for an example of depending on the number of calls)

I think that's it, but I'm in a rush so I might be forgetting one :P

I agree that the breaking change should be made asap, then we can work on the implementation more. I think the externalInput flag needs a better name as well. I think using a flag like this should work with any implementation such as #8942.

One other thought I had was treating this flag like an input to the filter/interceptor. Then for things such as translation filters this value can be the language and $parse can watch it like any other argument to the filter. Then changing the language will cause the filter to be re-executed...

@lgalfaso
Copy link
Contributor

@IgorMinar

this commit landed in master as 5cf1d89..6af2ff2

?!

expect(filterCalls).toBe(1);
expect(watcherCalls).toBe(1);
}));
it('should not treat constants passed to filters with externalInput as constants', inject(function($parse) {
Copy link
Contributor

Choose a reason for hiding this comment

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

are you planning on writing this test?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Was a bit rushed this morning but wanted to post an update so I may have missed a few things...

This was referenced Sep 14, 2014
@IgorMinar
Copy link
Contributor

I rebased this PR and added some changes. see #9082

IgorMinar pushed a commit to IgorMinar/angular.js that referenced this pull request Sep 16, 2014
With this change, expressions like "firstName + ' ' + lastName | uppercase"
will be analyzed and only the inputs for the expression will be watched
(in this case "firstName" and "lastName"). Only when at least one of the inputs
change, the expression will be evaluated.

This change speeds up simple expressions like `firstName | noop` by 20%
and more complex expressions like `startDate | date` by 2500%.

BREAKING CHANGE: all filters are assumed to be stateless functions

Previously it was a good practice to make all filters stateless, but now
it's a requirement in order for the model change-observation to pick up
all changes.

If an existing filter is statefull, it can be flagged as such but keep in
mind that this will result in a significant performance-penalty that will
affect the $digest duration.

To flag a filter as stateful do the following:

myApp.filter('myFilter', function() {
  function myFilter(input) { ... };
  myFilter.$stateful = true;
  return myFilter;
});

Closes angular#9006
Closes angular#9082
IgorMinar pushed a commit to IgorMinar/angular.js that referenced this pull request Sep 16, 2014
With this change, expressions like "firstName + ' ' + lastName | uppercase"
will be analyzed and only the inputs for the expression will be watched
(in this case "firstName" and "lastName"). Only when at least one of the inputs
change, the expression will be evaluated.

This change speeds up simple expressions like `firstName | noop` by ~15%
and more complex expressions like `startDate | date` by ~2500%.

BREAKING CHANGE: all filters are assumed to be stateless functions

Previously it was a good practice to make all filters stateless, but now
it's a requirement in order for the model change-observation to pick up
all changes.

If an existing filter is statefull, it can be flagged as such but keep in
mind that this will result in a significant performance-penalty that will
affect the $digest duration.

To flag a filter as stateful do the following:

myApp.filter('myFilter', function() {
  function myFilter(input) { ... };
  myFilter.$stateful = true;
  return myFilter;
});

Closes angular#9006
Closes angular#9082
IgorMinar pushed a commit to IgorMinar/angular.js that referenced this pull request Sep 16, 2014
With this change, expressions like "firstName + ' ' + lastName | uppercase"
will be analyzed and only the inputs for the expression will be watched
(in this case "firstName" and "lastName"). Only when at least one of the inputs
change, the expression will be evaluated.

This change speeds up simple expressions like `firstName | noop` by ~15%
and more complex expressions like `startDate | date` by ~2500%.

BREAKING CHANGE: all filters are assumed to be stateless functions

Previously it was a good practice to make all filters stateless, but now
it's a requirement in order for the model change-observation to pick up
all changes.

If an existing filter is statefull, it can be flagged as such but keep in
mind that this will result in a significant performance-penalty (or rather
lost opportunity to benefit from a major perf improvement) that will
affect the $digest duration.

To flag a filter as stateful do the following:

myApp.filter('myFilter', function() {
  function myFilter(input) { ... };
  myFilter.$stateful = true;
  return myFilter;
});

Closes angular#9006
Closes angular#9082
@IgorMinar IgorMinar closed this in fca6be7 Sep 16, 2014
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.

5 participants