-
Notifications
You must be signed in to change notification settings - Fork 484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
8252936: Optimize removal of listeners from ExpressionHelper.Generic #108
Conversation
Hi dannygonzalez, welcome to this OpenJDK project and thanks for contributing! We do not recognize you as Contributor and need to ensure you have signed the Oracle Contributor Agreement (OCA). If you have not signed the OCA, please follow the instructions. Please fill in your GitHub username in the "Username" field of the application. Once you have signed the OCA, please let us know by writing If you already are an OpenJDK Author, Committer or Reviewer, please click here to open a new issue so that we can record that fact. Please use "Add GitHub user dannygonzalez" as summary for the issue. If you are contributing this work on behalf of your employer and your employer has signed the OCA, please let us know by writing |
/signed I have signed the Oracle Contributor Agreement today. Have not received back any confirmation yet though. |
Thank you! Please allow for up to two weeks to process your OCA, although it is usually done within one to two business days. Also, please note that pull requests that are pending an OCA check will not usually be evaluated, so your patience is appreciated! |
hmm ... wouldn't the change violate spec of adding listeners:
|
True, I hadn't read that spec in ObservableValueBase. Non of the JavaFx unit tests test for that specific case as the unit tests all passed. It would be nice if there was a specific test case for this behaviour. I would need to store a registration count for each listener to satisfy this requirement. |
whatever drove it (had been so since the beginning ot java desktop, at least since the days of swing), there is no way to change it, is it?
yeah, the test coverage is ... not optimal :)
a count plus some marker as to where it was added: addListener(firstL) must result in firstL.invalidated, seconL.invalidated, firstL.invalidated .. which brings us back to .. an array? |
The listeners are called back in the order they were registered in my implementation although I didn’t see this requirement in the spec unless I missed something.
The performance unregistering thousands of listeners by TableView from the array is killing the performance of our JavaFX application. It takes up to 60% of JavaFX Application thread CPU and there are various bug reports around this same TableView performance issue.
The set implementation has lowered this to below 1%.
This pull request may be too fundamental a change to go into mainline. We however have to build our local OpenJFX with this fix or our application is unusable.
I’m happy to receive any suggestions.
Danny
On 12 Feb 2020, at 12:22, Jeanette Winzenburg <[email protected]<mailto:[email protected]>> wrote:
Although that does seem odd behaviour to me. Obviously as the original implementation was using an array I can see how the implementation drove that specification.
whatever drove it (had been so since the beginning ot java desktop, at least since the days of swing), there is no way to change it, is it?
Non of the JavaFx unit tests test for that specific case as the unit tests all passed. It would be nice if there was a specific test case for this behaviour.
yeah, the test coverage is ... not optimal :)
I would need to store a registration count for each listener to satisfy this requirement.
a count plus some marker as to where it was added:
addListener(firstL)
addListener(secondL)
addListener(firstL)
must result in firstL.invalidated, seconL.invalidated, firstL.invalidated .. which brings us back to .. an array?
—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub<#108?email_source=notifications&email_token=ABTEOIWBBLX3XA3JV23OP6LRCPSXRA5CNFSM4KQ7YBNKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOELQSLEY#issuecomment-585180563>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/ABTEOIVSPJEJ6FAJ5SFT7V3RCPSXRANCNFSM4KQ7YBNA>.
|
yeah, you are right can't find that spec on sequence right now - maybe I dreamed it :) |
/covered |
Thank you! Please allow for a few business days to verify that your employer has signed the OCA. Also, please note that pull requests that are pending an OCA check will not usually be evaluated, so your patience is appreciated! |
/covered |
You are already a known contributor! |
@dannygonzalez the reason for the |
ExpressionHelper.Generic now uses a Map for speed rather than linear searching through an array. We use a LinkedHashMap<Listener, Integer> in ExpressionHelper.Generic. This ensures we can iterate through the map in insertion order to call back listeners in the order they were registered (although this isn't a requirement according to the spec, I got unit test failures when I used a HashMap instead). It also allow us to keep track if the same listener has been registerd more than once and hence honour the addListener and removeListener requirements. Specifically: addListener: If the same listener is added more than once, then it will be notified more than once. That is, no check is made to ensure uniqueness. removeLister: If it had been added more than once, then only the first occurrence will be removed. Check in unit tests to test the case where the same listener is registered/removed multiple times
249ab01
to
05c3719
Compare
@kevinrushforth just a note to say there are other ExpressionHelper classes (i.e. MapExpressionHelper, SetExpressionHelper and ListExpressionHelper) that also use arrays and suffer from the linear search issue when removing listeners. These however didn't appear in the critical path of the JavaFXThread and didn't come up in my profiling of TableView. If this pull request is accepted, my opinion is that they should probably all move to using the same pattern as here, which is to use Maps instead of Arrays for their listener lists so that all these classes are uniform. Thanks |
Sorry for the interruption, send a PR that corrects the same problem. |
final Map<InvalidationListener, Integer> curInvalidationList = new LinkedHashMap<>(invalidationListeners); | ||
final Map<ChangeListener<? super T>, Integer> curChangeList = new LinkedHashMap<>(changeListeners); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You only need the entry set, so you don't need to copy the map, just the set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, yes the EntrySet would make more sense here. I'll fix that up.
final Map<InvalidationListener, Integer> curInvalidationList = new LinkedHashMap<>(invalidationListeners); | ||
final Map<ChangeListener<? super T>, Integer> curChangeList = new LinkedHashMap<>(changeListeners); | ||
|
||
curInvalidationList.entrySet().forEach(entry -> fireInvalidationListeners(entry)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lambda can be converted to a method reference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, I'll fix.
Predicate<Object> p = t -> t instanceof WeakListener && | ||
((WeakListener)t).wasGarbageCollected(); | ||
|
||
listeners.entrySet().removeIf(e -> p.test(e.getKey())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be listeners.keySet().removeIf(p::test);
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, will change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, actually:
listeners.keySet().removeIf(p::test)
is not the same as:
listeners.entrySet().removeIf(e -> p.test(e.getKey()));
We need to test against the entrySet.key not the entrySet itself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to test against the entrySet.key not the entrySet itself.
I suggested to test against the elements in keySet()
, which are the same as the ones in entrySet().getKey()
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha, sorry I missed that.
private int weakChangeListenerGcCount = 2; | ||
private int weakInvalidationListenerGcCount = 2; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are these set to 2 and why do you need them at all? The previous implementation needed to grow and shrink the array so it had to keep these, but Map
takes care of this for you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, I kept these in as I wasn't sure if there was a need to manually force the garbage collection of weak listeners at the same rate as the original implementation.
Removing this would make sense to me also.
Updated my thoughts on this, see my comments below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I know a LinkedHashMap doesn't automatically remove weak listener entries. The listener maps can be holding weak listeners as well as normal listeners.
So we need to keep the original behaviour from before where a count was checked on every addListener call and the weak references were purged from the array using the original calculation for this count.
Otherwise the map would never garbage collect these weak references.
The initial value of 2 for these counts was just the starting count although this is not necessarily strictly accurate. To be completely accurate then we would have to set the appropriate count in each constructor as follows:
i.e. in the Constructor with 2 InvalidationListeners:
weakChangeListenerGcCount = 0
weakInvalidationListenerGcCount = 2
in the Constructor with 2 ChangeListeners:
weakChangeListenerGcCount = 2
weakInvalidationListenerGcCount = 0
in the Constructor with 1 InvalidationListener and 1 ChangeListener:
weakChangeListenerGcCount = 1
weakInvalidationListenerGcCount = 1
Now, I could have used a WeakHashMap to store the listeners where it would automatically purge weak listeners but this doesn't maintain insertion order. Even though the specification doesn't mandate that listeners should be called back in the order they are registered, the unit tests failed when I didn't maintain order.
I am happy to remove this weak listener purge code (as it would make the code much simpler) but then we wouldn't automatically remove the weak listeners, but this may not be deemed a problem anyway?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So we need to keep the original behaviour from before where a count was checked on every addListener call and the weak references were purged from the array using the original calculation for this count.
The GC'd weak listeners do need to be purged, but why is the original behavior of the counters still applicable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just wanted to keep a similar behaviour as before using the same calculation based originally on the size of the listeners array list and now based on the size of the map. So in theory the weak listeners should be trimmed at the same rate as before.
Happy to hear alternatives.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also bear in mind that the trimming of weak listeners is extremely slow as it has to iterate through each listener performing the weak listener test. We can't call this every time we add a listener as this would lock up the JavaFX thread completely (I tried it).
I assume this is why the original calculation was used where it backs of the rate the weak listener trimming code was called as the array list grew.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I honestly don't quite understand the old cleanup behavior of (oldCapacity * 3)/2 + 1
. Why is it grown by x1.5? In your tests, can you try to change the cleanup threshold to higher and lower values and see what differences you get?
At the very least, the initial values of the counters should be set according to the specific constructor used.
private Map<InvalidationListener, Integer> invalidationListeners = new LinkedHashMap<>(); | ||
private Map<ChangeListener<? super T>, Integer> changeListeners = new LinkedHashMap<>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two comments on this:
- The old implementation initialized these lazily, so we might want to preserve that behavior. I think it is reasonable since in most cases an observable won't have both types of listeners attached to it.
- Since we're dealing wither performance optimizations here, we should think about what the
initialCapcity
andloadFactor
of these maps should be, as it can greatly increase performance when dealing with a large amount of entries.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding to this, we need to ensure that the simple case of a few listeners doesn't suffer memory bloat. It may make sense to initially allocate a Map with a small capacity and load factor, and then reallocate it if someone adds more than a certain number of listeners.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree with the lazy initialisation and the initial setting of capacity and load factor to reduce memory footprint unless it's needed.
I haven't done a detailed review yet, but I worry about the memory consumption and performance of using a Map for the simple cases where there is only a single (or very small number) of listeners. These methods are used in many more places than just the TableView / TreeTableView implementation. |
Replying to @nlisker and @kevinrushforth general comments about the memory consumption of using a LinkedHashMap: I agree that at the very least these should be lazy initialised when they are needed and that we should initially allocate a small capacity and load factor. We could also have some sort of strategy where we could use arrays or lists if the number of listeners are less than some threshold (i.e. keeping the implementation with a similar overhead to the original) and then switch to the HashMap when it exceeds this threshold. This would make the code more complicated and I wonder if this is the worth the extra complexity. I know that in our application, the thousands of listeners unregistering when using a TableView was stalling the JavaFX thread. I am happy to submit code that lazy initialises and minimises initial capacity and load factor as suggested or happy to take direction and suggestions from anyone with a deeper understanding of the code than myself. |
I think that a starting point would be to decide on a spec for the listener notification order: is it specified by their registration order, or that is it unspecified, in which case we can take advantage of this for better performance. Leaving is unspecified and restricting ourselves to having it ordered is losing on both sides. |
Given PR #185, which was mentioned above, (it isn't out for review yet, but I want to evaluate it), this would be a 4th approach. As long as this really doesn't introduce a leak, it seems promising. I note that these are not mutually exclusive. We should discuss this on the list and not just in one or more of of the 3 (soon to be 4) open pull requests. |
@dannygonzalez Per this message on the openjfx-dev mailing list, I have filed a new JBS issue for this PR to use. Please change the title to:
|
Thanks @kevinrushforth, I've changed the title. |
I have found that fixing this rudimentary problematic code alleviates your problem. This fix will reduce CPU usage by about 1/3 without your changes. However, I'm wondering how to report the problem. Should it be handled in this issue? Should I deal with a new issue for a rudimentary issue? @kevinrushforth What should i do? jfx/modules/javafx.base/src/main/java/com/sun/javafx/collections/ObservableListWrapper.java Lines 170 to 206 in 22d4343
Rewritten so that BitSet is not used. @Override
public boolean removeAll(Collection<?> c) {
if(this.isEmpty() || c.isEmpty()){
return false;
}
beginChange();
boolean removed = false;
for (int i = size()-1; i>=0; i--) {
if (c.contains(get(i))) {
remove(i);
removed = true;
}
}
endChange();
return removed;
}
@Override
public boolean retainAll(Collection<?> c) {
if(this.isEmpty() || c.isEmpty()){
return false;
}
beginChange();
boolean retained = false;
for (int i = size()-1; i>=0; i--) {
if (!c.contains(get(i))) {
remove(i);
retained = true;
}
}
endChange();
return retained;
} |
@yososs Please file a new JBS issue for this. You will need to prove that your proposed change is functionally equivalent (or that any perceived changes are incidental and still conform to the spec). You should also think about whether your proposed change needs additional tests. |
Because it is such a small correction It has passed two existing tests for compatibility:
I have just reported it as an enhancement proposal. |
@dannygonzalez Unknown command |
@dannygonzalez Unknown command |
@dannygonzalez Unknown command |
Any news for this PR? Can I help somewhere? |
It seems that performance improvement PR often needs to make up for the lack of existing unit tests. However, this addition of unit tests should not be included in the same PR as it needs to be tested to meet the specifications before and after the change. I think we need a step to fix the lack of unit tests before the performance improvement patch. And care must be taken not to rely on internal implementations for this unit test so as not to hinder performance improvement. Some existing unit test code depends on the internal implementation, so some test changes may be needed. |
This change is very near completion. According to @yososs, what remains is to split the work into two separate PRs. The first PR must contain the updated test covering additional cases to ensure backwards compatibility. The second should contain the performance enhancement changes. This will allow the tests to be merged into master first, to ensure the behavior is maintained before and after the performance enhancement. I see that @dannygonzalez has not been able to continue this work. If possible and appropriate, I would like to finish this work. I have already setup a build environment and have verified that the updated tests alone pass when integrated with the master branch and have 100% coverage of the modified class. |
I think it is also important to check if this is still an issue.
A PR which reduces the amount of listeners on Scene/Window was
integrated in JavaFX 17: #185
Are you experiencing problems still under 17?
I think PR 108 is addressing symptoms of a problem but not the root
cause -- is it ever useful to have >10000 listeners on a single property
which would warrant the use of Sets? PR 185 solved this by not
registering a listener for each Node on the Scene and Window, and there
was some confirmation from the OP that this also addressed the issue,
see here: #108 (comment)
…--John
On 01/10/2021 13:27, John Nader wrote:
This change is very near completion. According to @yososs
<https://github.com/yososs>, what remains is to split the work into two
separate PRs. The first PR must contain the updated test covering
additional cases to ensure backwards compatibility. The second should
contain the performance enhancement changes. This will allow the tests
to be merged into master first, to ensure the behavior is maintained
before and after the performance enhancement.
I see that @dannygonzalez <https://github.com/dannygonzalez> has not
been able to continue this work. If possible and appropriate, I would
like to finish this work. I have already setup a build environment and
have verified that the updated tests alone pass when integrated with the
master branch and have 100% coverage of the modified class.
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#108 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAHTETIDTFZAQSKBVUFO3BLUEWLI7ANCNFSM4KQ7YBNA>.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
|
The behavior is the same between openjdk 16.0.2 and 17.0.0.1. The test to recreate the problem adds many nodes (Rectangle) to a parent Group. The process causes each node to add two invalidation listeners (disabledProperty and treeVisibleProperty) to the parent node here using ExpressionHelper.Generic.addListener(). When the children are cleared from the group the inefficient linear search through the listener array and remove occurs, two linear searches per child. Here is the minimal application I am using to recreate the issue. public class ClearGroupPerformance extends Application {
private static final int GAP = 10;
private static final int SIZE = 40;
private Group groupBase;
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage stage) {
int width = 10000;
int height = 10000;
ScrollPane scrollPane = new ScrollPane();
StackPane root = createGroupRoot(width, height);
scrollPane.setContent(root);
Button clearGroupButton = new Button("Clear Group");
clearGroupButton.onActionProperty().set(e -> {
long startTime = System.nanoTime();
ObservableList<Node> children = groupBase.getChildren();
int totalChildren = children.size();
children.clear();
System.out.printf("Clearing %d nodes took %.2fms%n", totalChildren, (System.nanoTime()-startTime)/1_000_000.0);
});
BorderPane borderPane = new BorderPane();
borderPane.setTop(clearGroupButton);
borderPane.setCenter(scrollPane);
Scene scene = new Scene(borderPane, 100, 100);
stage.setScene(scene);
stage.show();
}
private StackPane createGroupRoot(int width, int height) {
groupBase = new Group();
groupBase.getChildren().add(new Rectangle(width, height, new Color(0.5, 0.5, 0.5, 1.0)));
for (int posX = GAP; posX + SIZE + GAP < width; posX += SIZE + GAP) {
for (int posY = GAP; posY + SIZE + GAP < height; posY += SIZE + GAP) {
Rectangle rectangle = new Rectangle(posX, posY, SIZE, SIZE);
rectangle.setFill(Color.GREEN);
groupBase.getChildren().add(rectangle);
}
}
return new StackPane(groupBase);
}
} |
Thank you for the test case. I see that this is indeed another issue, although again one that seems to be triggered by an excessive amount of listeners being added by Since the listeners added seem to be internal to There is a 3rd property as well involved in the case there are transformations applied which could be given the same treatment (localToSceneTransformProperty). |
Your points regarding unnecessary default listeners are valid. The question now is should the work done on this PR be abandoned. It addresses a current performance limitation taking complexity from O(n log n) to O(1). It appears to be well isolated with low risk to backwards compatibility. The reason work was stopped was a concern that the tests should go first in a separate PR. |
I'll attach the test case you provided to the bug report.
That is a fair question, but it begs two additional questions. Are there enough real world examples where performance is being hurt by using an ArrayList to justify the effort and risk (more on that below)? If so, are there other solutions that would reduce the number of listeners needed? The original problems that motivated this fix were largely addressed by JDK-8252935 / PR #185 and JDK-8252811 / PR #298.
I disagree that this is a low risk change. This proposed fix touches a fundamental area used by all bindings, and does so in a way that will increase memory usage -- and might negatively impact performance -- in the common case of a small number of listeners. By unconditionally switching from an If you still want to pursue this, you will need to do a fair amount of work to provide the additional tests that will be needed, to motivate the need for this enhancement (given that the original reasons are mostly addressed), and to resolve the issues that were raised in this PR. The mechanics of doing this are pretty easy. First, read the CONTRIBUTING guideline, specifically the part about signing the OCA, and create a new PR starting from this one. You would then add the author of this PR as a contributor. In the mean time, I'm going to move this PR to Draft (which I should have done long ago), since it stalled and not being actively worked on or reviewed. |
/reviewers 3 |
@kevinrushforth |
@kevinrushforth makes an important point I was missing. Functionally this change is isolated but the memory overhead of a new data structure used by every node would risk performance degradation across many existing applications. I now see that the recommendation @hjohn to avoid adding unnecessary listeners by default should be prioritized over a faster listener lookup data structure. This will clarifies why work on this PR is currently back in draft status. |
@dannygonzalez this pull request can not be integrated into git checkout listeners_optimisation
git fetch https://git.openjdk.org/jfx master
git merge FETCH_HEAD
# resolve conflicts and follow the instructions given by git merge
git commit -m "Merge master"
git push |
@dannygonzalez This pull request has been inactive for more than 8 weeks and will be automatically closed if another 8 weeks passes without any activity. To avoid this, simply add a new comment to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration! |
@dannygonzalez This pull request has been inactive for more than 16 weeks and will now be automatically closed. If you would like to continue working on this pull request in the future, feel free to reopen it! This can be done using the |
https://bugs.openjdk.java.net/browse/JDK-8185886
Optimisation to ExpressionHelper.Generic class to use Sets rather than Arrays to improve listener removal speed.
This was due to the removal of listeners in TableView taking up to 50% of CPU time on the JavaFX Application thread when scrolling/adding rows to TableViews.
This may alleviate some of the issues seen here:
TableView has a horrific performance with many columns #409
javafxports/openjdk-jfx#409 (comment)
JDK-8088394 : Huge memory consumption in TableView with too many columns
JDK-8166956: JavaFX TreeTableView slow scroll performance
JDK-8185887: TableRowSkinBase fails to correctly virtualise cells in horizontal direction
OpenJFX mailing list thread: TableView slow vertical scrolling with 300+ columns
https://mail.openjdk.java.net/pipermail/openjfx-dev/2020-January/024780.html
Progress
Issue
Reviewing
Using
git
Checkout this PR locally:
$ git fetch https://git.openjdk.org/jfx.git pull/108/head:pull/108
$ git checkout pull/108
Update a local copy of the PR:
$ git checkout pull/108
$ git pull https://git.openjdk.org/jfx.git pull/108/head
Using Skara CLI tools
Checkout this PR locally:
$ git pr checkout 108
View PR using the GUI difftool:
$ git pr show -t 108
Using diff file
Download this PR as a diff file:
https://git.openjdk.org/jfx/pull/108.diff
Webrev
Link to Webrev Comment