[TwigComponent] Reduce per-render CPU cost of static components - #3692
[TwigComponent] Reduce per-render CPU cost of static components#3692javiereguiluz wants to merge 3 commits into
Conversation
metadataFor() constructed a new ComponentMetadata on every call, and it is called twice per component render (create() and ComponentRenderer::preRender()). ComponentMetadata is immutable, so instances can be safely reused. On a page rendering ~630 components this removes ~1.260 allocations per request. The cache is cleared in reset() alongside the other per-request memo caches.
Every component render dispatched 5 freshly-allocated events (PreCreateForRender, PreMount, PostMount, PreRender, PostRender) even when no listener is registered, which is the common case in production without the LiveComponent bridge. On a page with ~630 components that is ~3.150 event allocations + dispatches per request. When the injected dispatcher implements the introspectable Symfony\Component\EventDispatcher\EventDispatcherInterface, hasListeners() is now checked (a cheap array lookup) before creating and dispatching each event. The PreRenderEvent object is still always created because it carries the resolved template and variables; only its dispatch is skipped. For any other PSR-14 dispatcher the previous unconditional behavior is kept.
| { | ||
| $event = new PreMountEvent($component, $data, $componentMetadata); | ||
| $this->eventDispatcher->dispatch($event); | ||
| if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreMountEvent::class)) { |
There was a problem hiding this comment.
Do you think we can check if we have listeners for events for a specific component class?
I mean, if we have 100 anonymous components (that don't have listener AFAIK), and one component with listener, we will still dispatch the events right?
Note that we have two specials component ux:icon and ux:map, and I think they listen to some events... will it still work? 🤔
There was a problem hiding this comment.
I added a test related to events and static Twig components. hasListeners() is checked per event class, not per component, so once any listener is registered, events are dispatched for every component render, inlcuding anonymous. Also, hasListeners() is evaluated at dispatch time (not cached at construction), since the listeners are added at runtime.
I don't use ux:map, but I use ux:icon and I tested the changes in a page using 628 components (225 of them are icons) and it worked as expected.
The variables array was spread-copied twice per render: once to create the PreRenderEvent and a second time to append this/computed/outerScope/__props/ __context. The second copy is replaced by in-place assignments, the variables are only re-read from the event when it was actually dispatched, and the array_diff_key() call is skipped when there is no outer context (the component() function path).
20ee4ff to
e03d2fa
Compare
| if ($mappedName = $this->classMap[$name] ?? null) { | ||
| if ($config = $this->config[$mappedName] ?? null) { | ||
| return new ComponentMetadata($config); | ||
| return $this->metadata[$name] = new ComponentMetadata($config); |
There was a problem hiding this comment.
| return $this->metadata[$name] = new ComponentMetadata($config); | |
| return $this->metadata[$mappedName] = new ComponentMetadata($config); |
Not 100% sure but this may deserve some thinking
| ]; | ||
|
|
||
| return new ComponentMetadata($this->config[$name]); | ||
| return $this->metadata[$name] = new ComponentMetadata($this->config[$name]); |
There was a problem hiding this comment.
We are keeping data twice here.. as array in $this->config[$name] and as instance in $metadata.
Should we replace one by the other ?
| /** | ||
| * @return array The (possibly modified) mount data | ||
| */ | ||
| private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): array |
There was a problem hiding this comment.
what about using a void + reference, as in mount() method ?
| { | ||
| $event = new PreMountEvent($component, $data, $componentMetadata); | ||
| $this->eventDispatcher->dispatch($event); | ||
| if (null === $this->introspectableDispatcher || $this->introspectableDispatcher->hasListeners(PreMountEvent::class)) { |
There was a problem hiding this comment.
Should we calculate this once and store result ? So we could avoid the entire method call ?
if (true === $this->skipPostMount) {
$this->postMount(...)
}And in postMount we can always toggle it on first call ?
| foreach ($componentMetadata->getPostMounts() as $postMount) { | ||
| if (null !== $newData = $component->$postMount($data)) { | ||
| $event->setData($data = $newData); | ||
| $data = $newData; |
There was a problem hiding this comment.
This may create problems for the profiler .. but this must not be a reason to reject your great optimizations here.
|
Any updates here? |
Pages that render hundreds of small components (e.g. an EasyAdmin index page renders ~630 components of 15 types: icons, table cells, action-menu items, ...) spend a significant share of their CPU time inside TwigComponent's own render pipeline rather than in the templates. Profiling such a workload showed three recurring per-render costs that this PR removes, without changing any observable behavior: the rendered HTML is byte-identical before/after each commit.
This PR contains three independent commits:
1. Cache
ComponentMetadatainstances inComponentFactorymetadataFor()constructed a newComponentMetadataon every call, and it is called twice per render (create()andComponentRenderer::preRender()). The class is immutable, so instances are now cached per component name (~1,260 fewer allocations on the page above). The cache is cleared inreset()like the other per-request memo caches.2. Skip event allocation/dispatch when nobody listens
Every render dispatched 5 freshly-allocated events (
PreCreateForRender,PreMount,PostMount,PreRender,PostRender) even with zero listeners registered, the common case in production when the LiveComponent bridge is not installed (~3,150 event allocations + dispatches per request on the page above).When the injected dispatcher implements the introspectable
Symfony\Component\EventDispatcher\EventDispatcherInterface,hasListeners()is now checked (a cheap!empty()array lookup) before creating and dispatching each event:PreRenderEventis still always created, because it carries the resolved template and variables to the compiled code; only its dispatch is skipped;PostMountEventis skipped, the extra metadata defaults to[], exactly as an undispatched event would report.The LiveComponent test suite (whose bridge subscribes to these events) passes unchanged against this branch.
3. Build the render variables in a single pass in
preRender()The variables array was spread-copied twice per render: once to create the
PreRenderEvent, then a second full[...spread]to appendthis,computed,outerScope,__propsand__context. The second copy is replaced with in-place assignments, the variables are only re-read from the event when it was actually dispatched, and thearray_diff_key()call is skipped when there is no outer context (the{{ component() }}function path).Benchmark
Throw-away Symfony app (
prodenv, no profiler, OPcache on, Xdebug off) rendering a page of 642 components of 15 types modeled on a real EasyAdmin index-page profile: 225 class-backed icons, 161 nested anonymous items with{% props %}defaults, 100 anonymous table cells with attributes, 23 dropdowns with named blocks, 40 renders via thecomponent()function, etc. Numbers are the median of 5 processes × 30 measured iterations each (5 warmup),hrtime()around a full page render, on PHP 8.5 / Apple Silicon.The rendered HTML is byte-identical (same SHA-1) for every step, and both the TwigComponent (299 tests) and LiveComponent (387 tests) suites pass.