-
Notifications
You must be signed in to change notification settings - Fork 29.8k
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
performance,events: use Map to store events in EventEmitter #21856
Conversation
I love this. I do think we’ve seen people take a stab at this before – this is quite a breaking change, unfortunately. Do you think it would be possible to store the map behind a Symbol, add a getter/setter pair for |
See previous PR in #17074. Looping in @apapirovski. |
This also means event names don't have to be strings (or symbols) anymore. |
lib/events.js
Outdated
if (events.removeListener) | ||
if (--this._eventsCount === 0) { | ||
// this._events.clear(); | ||
this._events = new Map(); |
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.
What happens if we remove the if
and always delete
?
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've tried that initially but there is a significant regression for ee-once
if we do that, seems that either Map creation is cheaper or there is some missed optimization. Maybe someone can take a look.
➔ dev/node/node cat compare-events-pr-1.csv | Rscript benchmark/compare.R
confidence improvement accuracy (*) (**) (***)
events/ee-add-remove.js n=1000000 3.38 % ±4.91% ±6.73% ±9.16%
events/ee-emit.js listeners=1 argc=0 n=2000000 -0.73 % ±13.90% ±19.27% ±26.76%
events/ee-emit.js listeners=1 argc=10 n=2000000 *** 15.68 % ±7.91% ±10.91% ±15.04%
events/ee-emit.js listeners=1 argc=2 n=2000000 8.14 % ±9.82% ±13.45% ±18.34%
events/ee-emit.js listeners=1 argc=4 n=2000000 9.24 % ±9.50% ±13.11% ±18.05%
events/ee-emit.js listeners=10 argc=0 n=2000000 -0.54 % ±10.02% ±13.75% ±18.80%
events/ee-emit.js listeners=10 argc=10 n=2000000 1.89 % ±8.26% ±11.33% ±15.48%
events/ee-emit.js listeners=10 argc=2 n=2000000 -2.79 % ±11.40% ±15.66% ±21.44%
events/ee-emit.js listeners=10 argc=4 n=2000000 2.88 % ±6.85% ±9.38% ±12.78%
events/ee-emit.js listeners=5 argc=0 n=2000000 0.59 % ±11.30% ±15.49% ±21.11%
events/ee-emit.js listeners=5 argc=10 n=2000000 2.33 % ±9.27% ±12.72% ±17.36%
events/ee-emit.js listeners=5 argc=2 n=2000000 0.10 % ±11.37% ±15.61% ±21.33%
events/ee-emit.js listeners=5 argc=4 n=2000000 -2.23 % ±9.85% ±13.50% ±18.41%
events/ee-listener-count-on-prototype.js n=50000000 *** 31.36 % ±8.61% ±12.06% ±17.04%
events/ee-listeners-many.js n=5000000 0.69 % ±12.55% ±17.44% ±24.30%
events/ee-listeners.js n=5000000 -2.08 % ±8.51% ±11.78% ±16.31%
events/ee-once.js n=20000000 *** -26.48 % ±5.40% ±7.42% ±10.16%
diff:
@ events.js:323 @ EventEmitter.prototype.removeListener =
return this;
if (list === listener || list.listener === listener) {
- if (--this._eventsCount === 0) {
- // this._events.clear();
- this._events = new Map();
- } else {
- events.delete(type);
- if (events.has('removeListener'))
- this.emit('removeListener', type, list.listener || listener);
- }
+ --this._eventsCount;
+ events.delete(type);
+ if (events.has('removeListener'))
+ this.emit('removeListener', type, list.listener || listener);
} else if (typeof list !== 'function') {
position = -1;
@ events.js:376 @ EventEmitter.prototype.removeAllListeners =
this._events = new Map();
this._eventsCount = 0;
} else if (events.has(type)) {
- if (--this._eventsCount === 0)
- this._events = new Map();
- // this._events.clear();
- else
- events.delete(type);
+ --this._eventsCount;
+ events.delete(type);
}
return this;
}
prof json file (github doesn't like .json, so I renamed it to .txt)
v8-clean-remove.txt
@addaleax That's what I initially thought to do I just wanted to gather feedback before I implement it to avoid needless work 👍. |
I would store things in I exposed my opinion on this approach in #17074 (comment), which is:
|
FWIW, I've even come across (extremely not good) code that replaces |
One approach, that does not absolutely ensure backwards compat but also isn't as bad, would be: Change For A challenge with this approach is that the following would not be const bad_code = { a: () => { /* handle event */ } }
const ee = new EventEmitter()
ee._events = bad_code
ee._events === bad_code /// false!
ee._events == bad_code /// false! |
@jasnell Do I understand it correctly that this proxy will then be used in I'll try to implement a proxy now. Also, it would be great if someone familiar with V8 could take a look at #21856 (comment). |
Hrmmm, the benchmark results in CI show something different. The only benchmark the two sets of results seem to mostly "agree" on is |
@mscdex Hmm, I'll try to rebuild both versions and run them again. Maybe recent commits in master changed something or there was something outdated in my build (I've also started using ccache so maybe it was the problem). Edit:
|
So I've done this before, including a proxy for |
The PR linked above already did all the hard work for the proxy btw. https://github.com/nodejs/node/pull/17074/files |
@apapirovski I don't see anything related to proxy in the diff of #17074. Did you maybe use this branch for another PR and removed the changes? Also, I've added your benchmarks, though I'm not sure if there is a need to change n for different listenerCount, does it really improve the statistical result? For my current proxy defined with
I haven't had time to investigate the issue with |
Yeah, it's in a different branch. I wouldn't recommend spending more time on this given the benchmark results above. There's not much point to making a breaking change like this without significant improvement on the most significant benchmark ( |
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 benchmark results aren't good enough — in fact there are as many regressions as improvements — to break most of the ecosystem.
I tend to agree with @apapirovski. I think the benefit does not outweigh the negative side. |
@apapirovski well, I've been playing around with replacing
(eventNames now actually filters all There seems to be some problem with map.delete in V8. I've been trying to investigate this but to no avail yet. In this comment #21856 (comment), If I understand it correctly you can see that in constantly tries to Map Shrink and replace with new underlying storage upon Well, as I said in the first post, I'm okay with closing this one if it doesn't bring much merit. |
|
@lpinca Yeah, I'm aware, I wanted to check it compared to |
Could someone please run the benchmarks? |
Sure: https://ci.nodejs.org/view/Node.js%20benchmark/job/benchmark-node-micro-benchmarks/215/ |
Well they indeed doesn't look very good even with the V8 patch:
But at least no severe regressions, I don't think these results are 'good enough' but maybe there are indeed some bugs in v8 (or our specific usage of the map prevents some optimizations) that keep this from performing better, as in my simple Map/object benchmarks Map was usually smth like 1.4-2 times better in any use case without my patch (only 1 element map performed just slightly better (1.1-1.2) than object) and my patch fixed this use-case of one element map. |
Don't know why the target of proxy is the emitter, rather than the new Map object. |
@simonkcleung |
@lundibundi I mean other operations of the proxy handler object are missing, e.g. Using |
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.
Basically LGTM
let proxy = this[kEventsProxy]; | ||
if (proxy === undefined && this[kEvents] !== undefined) | ||
proxy = new Proxy(this, proxyEventsHandler); | ||
return proxy; |
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 think you want to set this[kEventsProxy]
here as well, right?
if (!value || Array.isArray(value) && value.length === 0) { | ||
this[kEvents] = new Map(); | ||
this._eventsCount = 0; | ||
} |
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.
Are we okay with making this a no-op if value is a "real" events object?
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 looks wrong. Should value
not be an object with properties? I expect it to look something like:
if (value) {
this[kEvents] = new Map(Object.entries(value));
this._eventsCount = this[kEvents].size;
}
var keys = new Array(len); | ||
i = 0; | ||
for (var value of events.keys()) | ||
keys[i++] = value; |
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.
Is this much faster than var keys = [...events.keys()];
? I'm not sure that this would be particularly hot code
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 just ran a benchmark against this and it does indeed seem to be much better the way it is. I open an issue about it for the V8 team.
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.
What is I'm a bit concerned about that regression. IMHO we should check how this impacts HTTP and Stream. They are both heavy users of EE, and if it does not regress there we should be grand. |
I’m not LGTM this just yet. I want to understand how this impacts other parts of the codebase. I’m specifically interested in the improvements in the once flow. |
It's a bad benchmark mostly. As for, Streams benchmark https://ci.nodejs.org/view/Node.js%20benchmark/job/benchmark-node-micro-benchmarks/216/ |
Anyway, I'm going to remain -1 on this because Maps have exponentially decreasing performance as their size grows. Here's a result from
|
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.
@nodejs/v8 PTAL if the v8 change actually makes sense or not.
I personally am -0.5 on this as the numbers do not really show much profit. @lundibundi thanks a lot for doing this though since it is good to see current numbers with different approaches.
var keys = new Array(len); | ||
i = 0; | ||
for (var value of events.keys()) | ||
keys[i++] = value; |
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 just ran a benchmark against this and it does indeed seem to be much better the way it is. I open an issue about it for the V8 team.
else | ||
delete events[type]; | ||
} else if (events.delete(type)) { | ||
--this._eventsCount; |
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 think _eventsCount
is now obsolete if we just use events.size
instead.
}; | ||
|
||
Object.defineProperty(EventEmitter.prototype, '_events', { | ||
enumerable: true, |
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.
It should likely also be configurable.
if (!value || Array.isArray(value) && value.length === 0) { | ||
this[kEvents] = new Map(); | ||
this._eventsCount = 0; | ||
} |
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 looks wrong. Should value
not be an object with properties? I expect it to look something like:
if (value) {
this[kEvents] = new Map(Object.entries(value));
this._eventsCount = this[kEvents].size;
}
@@ -2,16 +2,23 @@ | |||
const common = require('../common.js'); | |||
const events = require('events'); | |||
|
|||
const bench = common.createBenchmark(main, { n: [1e6] }); | |||
const bench = common.createBenchmark(main, { | |||
n: [5e6], |
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.
Do we really have to push up the iterations here?
const EventEmitter = require('events').EventEmitter; | ||
|
||
const bench = common.createBenchmark(main, { | ||
n: [2e7], |
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 seems to be a pretty high iteration count.
@@ -2,7 +2,7 @@ | |||
const common = require('../common.js'); | |||
const EventEmitter = require('events').EventEmitter; | |||
|
|||
const bench = common.createBenchmark(main, { n: [5e6] }); | |||
const bench = common.createBenchmark(main, { n: [1e7] }); |
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.
Do we have to increase the iterations here?
And this is the
And here's 100 events:
|
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.
-1 on this as well. I think this shows that Map
is still not really a viable alternative.
@apapirovski thanks, I didn't check if the regression was increasing with event size. |
@lundibundi thanks a lot for all your work that you put into this! Since it seems like the change itself regresses performance in some cases while improving it in others without showing a significant gain in general, I am going to close this. |
I've had some time to play around with Map as backing storage for EE and the results turned out to be pretty good. I'm not sure if this is an appropriate change so feel free to note this and close.
If this is acceptable then this should probably be a semver-major, even though _events is 'private'.
EE benchmarks
Also I'd like some help on understanding why is
.clear()
is so much worse than creating a new Map, I thought that this should be the other way around. (it shows regression of around -25% for ee-once). Comments with_events.clear()
are there to show where I intended to use it. I'll remove them later.Perf with
new Map
Perf with
map.clear()
Perf of master if needed:
Checklist
make -j4 test
(UNIX) passes/cc @nodejs/performance