Problem/Motivation
Performance tests (see #37) revealed the following problems with the Symfony ContainerAwareEventDispatcher
:
- Instantiation of the service is rather costly due to the fact that each subscriber is added using the
addSubscriberService
method call. Supplying the list of[service, method]
pairs to the__constructor
turns out to be faster by an order of magnitude. - Before an event is dispatched, all listeners and their dependencies are instantiated. This is a problem for
KernelEvents::REQUEST
because the time to the first listener will depend on the number of listeners registered for that event. Listeners wishing to return a response as early as possible (e.g. fast ajax callback) will suffer from an unnecessary delay because of that.
Regrettably none of those issues can be fixed easily upstream (see pr 12019 and 12069).
Currently there are around 50 subscribers registered in the event manager on a standard Drupal install. This number will likely go up to several 100 when events get used in more and more places (e.g. #2249377: Convert views hooks to events).
Proposed resolution
Rewrite the ContainerAwareEventDispatcher
component completely, in particular:
- Accept a list of ordered definitions [serviceid, method] for lazy loaded subscribers/listeners on the constructor (cut the service construction time)
- Adapt
RegisterKernelListenersPass
for the new constructor - Implement a fast version of dispatch()
- Implement the EventDispatcherInterface (support adding/removing listeners at runtime).
Remaining tasks
Review.
User interface changes
None.
API changes
Remove addSubscriberService()
and addListenerService()
(currently not part of in an interface anyway).
Comment | File | Size | Author |
---|---|---|---|
#49 | interdiff.txt | 2.38 KB | znerol |
#49 | 1972300-scalable-event-dispatcher-49.patch | 24.16 KB | znerol |
#41 | interdiff-35-41.txt | 16.87 KB | znerol |
#41 | 1972300-scalable-event-dispatcher-41.patch | 24.17 KB | znerol |
#37 | event-dispatcher-scalability.ods | 110.52 KB | znerol |
Comments
Comment #1
Crell CreditAttribution: Crell commentedThe model I'd been looking into before for this was to have it as a separate object from the container. Basically it would compile to a container-aware object vaguely like this, where dispatch('foo', $event) would map to:
(The dispatch_foo() method would do more than that, like checking for stopPropagation(), but that's the basic idea.)
That should be a separate object from the container, but you can easily put the compiled class into the container instead of the uncompiled one, and then neither the container nor other code would be any the wiser.
Comment #2
chx CreditAttribution: chx commentedComment #3
dawehnerSimple example for now.
Comment #4
chx CreditAttribution: chx commentedThis lets us solve https://github.com/symfony/symfony/issues/12007 just fine and make this shebang much faster.
Comment #6
chx CreditAttribution: chx commentedThe container already does the lazy instantiate - cache service instances just fine; no need for that here.
Ps. I shouldn't be rolling be core patches but I am so pissed noone did this before I wrote another epic rant. Thanks dawehner for taking care of it!
Comment #7
chx CreditAttribution: chx commentedPfft, i wrote :: instead of ->
Comment #9
znerol CreditAttribution: znerol commentedPlease no, if we really need to rewrite the class, then let's not inherit from
EventDispatcher
and just implement the interface. The git history ofContainerAwareEventDispatcher
reveals that this is not the most polished piece of code inside Symfony and therefore it is not a good starting point for a better implementation.It seems to me that with this patch we will lose the ability to add new listeners ad-hoc (via code) and that violates the event-dispatcher contract. If we do that, we need really good justification. If we want to keep class loading to a minimum there would be the option to not use
addSubscriberService
but ratheraddListenerService
. This will then not result in a static invocation of the subscriber each time the event-dispatcher is instantiated.Finally I'd like to humbly point to my version of the ContainerAwareEventDispatcher.php (because this is easier to read than the patch) which does still comply with the contract but cuts the time necessary to get to the first request-event listener to half (in the standard profile).
Comment #10
Wim LeersSo, znerol wrote over there:
I interpret this as: this optimization makes Drupal requests up to 60 ms/page load faster, depending on how many services are actually used. Is this correct? If so, this is HUGE.
Comment #11
znerol CreditAttribution: znerol commentedThis is correct, but only for early request listeners. For example if you set a redirect response way earlier than the router kicks in. It makes no difference for the last-request listener and anything following from there on.
Comment #12
Wim Leers@znerol: what's your total server response time for the front page (which is heavy), the
/contact
page (which is light) and the/system/ajax
page (without arguments, so yes, this will show an error, but it's a very light route)? That would provide some perspective on those numbers. Preferably both with and without your patch.Comment #13
znerol CreditAttribution: znerol commentedMy patch does not influence the runtime of full-page requests (i.e. requests going through the router and controllers). The only thing it does is that services are instantiated as needed when dispatching an event. This only has an effect if a listener stops event propagation early.
Comment #14
chx CreditAttribution: chx commentedznerol points out I had this backwards. Let's try.
Comment #15
chx CreditAttribution: chx commented> seems to me that with this patch we will lose the ability to add new listeners ad-hoc (via code) and that violates the event-dispatcher contract
Huh?
Comment #17
znerol CreditAttribution: znerol commentedI'm referring to EventDispatcherInterface::addListener().
Comment #18
chx CreditAttribution: chx commentedI have ran the test both with HEAD and the patch applied while dumping the classes and methods dispatched.
For example, the
KernelEvents::TERMINATE
subscribers need to fire in this order:HEAD does this:
patch does this:
The orders differ but as far as I understand both of these orders are correct. (I believe there are as many as 12 possible orders, all correct priority wise.)
The attached diffs show more of the same. One of them cause the three failures in this patch. Either the test is subscriber order sensitive or much worse, the subscribers, the Drupal functionality itself is dependent upon the particular order that krsort produces. It is not beyond the realm of possibilities that order might change even in a minor PHP version.
Comment #19
chx CreditAttribution: chx commentedHere's the two txt files for study.
Comment #20
chx CreditAttribution: chx commentedComment #21
znerol CreditAttribution: znerol commentedInitially I was not sure whether this change really makes a noticeable difference performance wise. Therefore I did put together i small module which is capable of generating as much noop-subscribers as desired. The linked sandbox also provides a script to test the effects via the command line.
I performed the test 11 times with 0, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096 generated subscribers once for vanilla drupal and once with chx patch from #20 applied.
The performance gain realized by replacing the method calls by an array during service construction are massive:
The performance gains of the optimized
dispatch()
function are not that spectacular but still noteworthy.initial call to dispatch
subsequent call to dispatch
Comment #22
Crell CreditAttribution: Crell commentedNeeds docblock.
If this is in our own code, can we make this a keyed array rather than positional? That lets us avoid the list() call below and makes the code more self-documenting. (If it has to be the same format as something in Symfony, then :-( )
znerol's benchmarks are compelling. However, there's 2 questions outstanding that I have:
1) As above, does this preclude adding arbitrary callable listeners at arbitrary times? I don't think it does but I want to be sure.
2) It looks from the code (unless I'm misreading it entirely) like the performance boost comes mostly from pushing more work to the container compile time. Classy, but nothing Drupal-specific. Could any of this be pushed upstream to Symfony, so it would benefit us and everyone else?
Comment #23
chx CreditAttribution: chx commentedznerol, thanks so much for the benchmark.
> 1) As above, does this preclude adding arbitrary callable listeners at arbitrary times?
Sure it does. The code still needs work -- calling addListener will do nothing. Battle plan is to fall back to the current dispatcher code if such a call is issued. There's however 1 addListener call in core, namely in GotoAction and as such, this optimized code path makes a ton of sense.
> t looks from the code (unless I'm misreading it entirely) like the performance boost comes mostly from pushing more work to the container compile time. Classy, but nothing Drupal-specific. Could any of this be pushed upstream to Symfony, so it would benefit us and everyone else?
See above. This optimization is for code that doesn't use addListener calls. Drupal is such, upstream I guess less so.
Working with upstream would make me patch their precious private properties to be protected which I guess will be met with resistance given the abudnance of such unextendable code in Symfony. I have no desire for that particular fight.
Comment #24
Crell CreditAttribution: Crell commentedI've no idea what is "common" for Symfony projects vis a vis non-container listeners. It's worth asking someone. I don't know why you'd need to muck with private vs protected, but if there's a valid reason to do so there are protected methods in Symfony already.
Comment #25
znerol CreditAttribution: znerol commentedIt would be great if someone could review / redo the benchmark. It would be stupid if we'd base our decisions on bogus numbers.
Comment #26
znerol CreditAttribution: znerol commentedRe #22.2: It looks from this comment on a pull request proposing a similar optimization, that the current Symfony design is on purpose. This was done such that code-changes in event-subscribers get picked up by the application without needing to recompile the container and clearing the cache.
Comment #27
catchI'd be very fine with not supporting that feature in Drupal.
Comment #28
znerol CreditAttribution: znerol commentedI'm working on this. My plan is to write a standalone version of the
ContainerAwareEventDispatcher
. Specifically it should:dispatch()
EventDispatcherInterface
(support adding/removing listeners at runtime).Also I propose to drop
addSubscriberService
andaddListenerService
because those methods do not make much sense outside of the compiler pass.Comment #29
znerol CreditAttribution: znerol commentedVery rough initial version. This includes chx compiler pass. Next step is to see whether symfony unit-tests pass and then I will do some benchmarks.
Comment #30
znerol CreditAttribution: znerol commentedHum, 0 bytes, not good.
Comment #31
znerol CreditAttribution: znerol commentedThis one passes symfony
EventDispatcherTest
.Comment #33
Crell CreditAttribution: Crell commentedMicro-optimization: $function is faster than call_user_func() and just as capable.
Comment #34
znerol CreditAttribution: znerol commentedOh, I did not know that this works with callables of the form
array($object, 'method')
but apparently it does:Comment #35
znerol CreditAttribution: znerol commentedThe performance test yields comparable results to chx version with this changes:
Comment #36
znerol CreditAttribution: znerol commentedMight be worth moving the nested loops into a functional-style
each
method, such that we can reuse it in all three places. This comes with a slight performance cost, but it is still quite a bit faster thanHEAD
.Also the
removeListener
now trigger instantiation of all listeners. I do not expect that to be a problem because I think it makes most sense to alter the container service listeners/subscribers at compile time. Also that is no different than inHEAD
, its just one optimization less for the benefit of a more consistent implementation.Comment #37
znerol CreditAttribution: znerol commentedUpdated performance test including the patches in 35 and 36, also attaching the spreadsheet with the raw numbers.
event subscriber service construction
initial call to dispatch
subsequent call to dispatch
Comment #38
chx CreditAttribution: chx commentedThanks znerol, I can now unfollow this. I have re-engaged somehow with D8, it is killing me, I need to disengange from issues like these.
Comment #39
bojanz CreditAttribution: bojanz commentedThis can definitely be rewritten to look like PHP.
Comment #40
znerol CreditAttribution: znerol commentedRe #39 certainly. #35 and #36 are equivalent, the only difference is the loop style. The problem is that I'm not comfortable with the duplicated code in #35 and frankly I'm a bit stuck on how to resolve that without introducing a performance regression in
dispatch()
. Note that reusinggetListeners()
is out of question because of reasons illustrated in Symfony issue 12007.Comment #41
znerol CreditAttribution: znerol commentedOk, let's drop #36. This is #35 with better documentation and the event dispatcher unit test from Symfony.
I hope that the Symfony pr 12131 eventually lands, this will allow us to subclass the unit tests instead of copy pasting them.
Comment #42
znerol CreditAttribution: znerol commentedComment #43
znerol CreditAttribution: znerol commentedComment #44
znerol CreditAttribution: znerol commentedComment #45
znerol CreditAttribution: znerol commentedComment #46
znerol CreditAttribution: znerol commentedComment #47
chx CreditAttribution: chx commentedInvert your sorted logic please. Default is sorted. Set unsorted when someone
addListener
. Ie:becomes
This obviously saves some unnecessary effort in the constructor at zero code complexity cost.
As for code repetition, it happens for performance, all good.
Comment #48
chx CreditAttribution: chx commentedNote: beyond the vast performance improvement (thanks znerol for carrying this home!) moving the sorting to build time instead of runtime enables #2352351: Add before/after ordering to events .
Comment #49
znerol CreditAttribution: znerol commentedOf course!
Comment #50
chx CreditAttribution: chx commentedif you are surprised that
$definition['callable']($event, $event_name, $this);
works then a) it does http://3v4l.org/RPcJ1 work since PHP 4 into HHVM b) it's in the language specification:https://github.com/php/php-langspec/blob/master/spec/10-expressions.md#f...
https://github.com/php/php-langspec/blob/master/spec/10-expressions.md#g...
https://github.com/php/php-langspec/blob/master/spec/10-expressions.md#s...
To summarize: a function call starts with a postfix expression followed by
(arguments)
. Subscript expressions are postfix expressions in themselves.$definition['callable']
is a subscript expression.Comment #51
Fabianx CreditAttribution: Fabianx commentedRTBC - if chx agrees this is ready now.
Patch looked great in my review!
Comment #52
Fabianx CreditAttribution: Fabianx commentedChx is happy with this, too.
Therefore after my review and with this great benchmarks !!! RTBC :).
Comment #53
znerol CreditAttribution: znerol commentedThanks @Fabianx, but #25 is not done yet. This is such an intrusive change, I'd prefer if someone would peer-review/reality-check the benchmarks before this gets committed.
Comment #54
Fabianx CreditAttribution: Fabianx commentedHow did you perform the benchmarks then?
Comment #55
znerol CreditAttribution: znerol commentedSee #21, there is a sandbox linked containing a module capable of generating X subscriber services. It a also would be valuable if the performance test would be carried out using another method in order to reality-check the results.
Comment #56
Fabianx CreditAttribution: Fabianx commentedThis even saves around 2-4% on a stock Drupal site measured via XHProf.
Also algorithmic this makes a lot of sense and this unblocks other issues, so is not only about performance.
Comment #57
Fabianx CreditAttribution: Fabianx commentedComment #58
znerol CreditAttribution: znerol commentedCool, thanks Fabianx!
Comment #59
Wim LeersFor "regular" page loads? i.e. front page, nodes, etc?
Comment #60
Fabianx CreditAttribution: Fabianx commentedJust the normal front page, saves 2% of all function calls for that simple scenario - which is 1000 function calls.
Comment #61
Wim LeersSo this does have a pretty big real-world performance impact on a simple Drupal page (1000 less function calls for a mere 6 dispatched events!). As the number of dispatched events and event listeners increases, this number is likely to rise further.
This is kind of scary. It makes me wonder about the performance of other Symfony components. Especially because big parts of our critical path are now Symfony.
Future readers, also see https://github.com/symfony/symfony/pull/12019 for the upstream discussion.
Saving 2–4% is huge at this point in the cycle, so: great work, znerol! Thanks!
Comment #62
znerol CreditAttribution: znerol commentedThe improvement mostly stems from the refactored service construction. The
dispatch()
call alone is not a big deal.Looking at the compiled container, I can identify two additional services we might want to examine closer (i.e. many method calls during service construction):
access_manager
andplugin.cache_clearer
. I hope the latter is not used on every request though.Comment #63
pounardI think that "classic" Symfony applications mostly are business oriented software that use Symfony as a framework whereas Drupal is a generic all-purpose highly complex application. I think that in most classic Symfony applications the number of various listeners and services is very low compared to a stock Drupal. This kind of performance consideration in most cases probably is insignificant. What's scary isn't Symfony components but the usage Drupal does of them.
Comment #64
Berdirplugin.cache_clearer should only by used in module install/uninstall() and cache clears.
Comment #65
Anonymous (not verified) CreditAttribution: Anonymous commentedawesome work! znerrol++ fabianx++
re. the discussion about being scared or not of Symfony's container/event dispatching - i think this entirely misses the point.
when we chose Symfony, we knew already the sort of beast Drupal is. despite that, we swallowed whole a bunch of ideas and implementations from SF2, chanting 'get off the island' and 'something something OO pattern', without paying nearly enough attention to the actual problems Drupal has to solve.
that is scary. really scary. and 100% not a Symfony problem.
Comment #66
Fabianx CreditAttribution: Fabianx commented#65 I only did the benchmarks - It is chx's and znerols genius.
Maybe we should open a major meta child of "resolve known perf reg. in D8" to audit the symfony components we are using for performance?
Comment #67
Wim Leers#65: Indeed. My wording was bad. Symfony's components might be perfectly fine for Symfony. But our usage patterns are very different, so it's very well possible that some Symfony components simply aren't a good match.
Comment #68
catchOn the wider question of Symfony components. I agree that it comes down to a case of 'just not a good match' most of the time - i.e. that those components are written for use cases that never get close to the scale our subsystems have to. In those situations we have to be very, very careful whenever we apply them, and we've not been careful enough so far. Still time to fix that though, and this patch and the performance results from it are fantastic (although still need to review in more depth before I feel comfortable committing). The fact we get these performance numbers despite me individually pushing back on almost every introduction of events in core is worrying though- we'd have many, many more events by now without that push back.
I would note that while it's OK to say things just aren't a good match now, the initial adoption of Symfony components was also enthusiastically encouraged from the Symfony side (both technical and commercial). So when I see upstream PRs rejected or stalled that allow those components to meet our use cases and requirements I get increasingly concerned. Eventually we'll end up using only a few interfaces and very little runtime Symfony code.
Comment #69
catchIsn't this incompatible with marking listeners as not public in the container? This was discussed elsewhere.
It's a shame we have to copy the unit test rather than subclass, hopefully that upstream PR at least for the test will get in.
I didn't do my own profiling but trust between znerol and FabianX on the numbers, the concepts here look great and it's good we managed to come up with a completely compatible dispatcher without compromising.
Leaving RTBC for that one question.
Comment #70
znerol CreditAttribution: znerol commentedIndeed, it is. Any container-aware service is affected by that. Lazy-loading is incompatible with non-public services. Issue is: #2350545: Protect some services from being referenced from outside container.
Comment #72
catch@znerol thanks for the confirmation.
That's unfortunate - it means that once this patch lands we'll be stuck with event listeners showing up in IDE autocomplete with no way to remove them.
However a longer autocomplete list for IDEs vs. a base performance improvement of 2-4% is an OK trade-off for me. We may need to annotate event listeners to make it explicit they aren't part of the API, even if they're available via container get.
No worse than hook implementations getting called directly as functions, but not an improvement.
The performance issue would get significantly worse if we'd converted more hooks (or added #1972304: Add a HookEvent). Now we've fixed part of the performance issue, at a DX cost if there's more conversions/listeners, good reason to think carefully before going further along the Symfony events route.
Also I had quick look through EventDispatcher and I think we're only using the Event class and maybe a couple dozen other lines of non-interface code now. Not great but if you 'get off the island' and find out you're flying to a prison colony, subclassing/re-implementation of the interface is OK for an emergency landing.
Committed/pushed to 8.0.x, thanks!
Comment #73
znerol CreditAttribution: znerol commentedAny container aware service is affected, including Symfonys ContainerAwareEventDispatcher we used before.
Comment #74
catchYeah not blaming the patch, just unfortunate in general.
edit: actually reading back I was blaming the patch, but wrongly so. I'd not come to the realisation that ContainerAwareEventDispatcher prevents this until now.
Comment #75
chx CreditAttribution: chx commentedYeah, a "visible to friends only" would be great :)
Comment #76
Fabianx CreditAttribution: Fabianx commentedYeah, and not even that difficult to add a $container->injectMe('private_service') class, which calls injectMe('private_service', theService) or whatever.
I still think neither setter DI nor constructor DI are enough for our extended use cases ...
Comment #78
jibranHere is the small update on the upstream PR [EventDispatcher] Add Drupal EventDispatcher is closed in favor of [DI][EventDispatcher] Add & wire closure-proxy argument type which is reverted in [Di] Remove closure-proxy arguments and symfony added [EventDispatcher] Handle laziness internally instead of relying on ClosureProxyArgument in symfony 3.3. Over in #2874909-52: Update Symfony components to 3.3.* I'm trying to fix the EventDispatcher fails. Can we drop Drupal EventDispatcher in favour of Symfony EventDispatcher now?
Comment #79
jibranCreated #2909185: Replace ContainerAwareEventDispatcher with Symfony EventDispatcher for further discussion.