-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Serializer] Cache the normalizer to use when possible #27049
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
Conversation
foreach ($this->normalizers as $normalizer) { | ||
if ($cacheable) { |
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.
think this needs to be inside the $normalizer instanceof NormalizerInterface
check
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.
No, if a normalizer in the chain before the matching one doesn't implement this new interface, we cannot use the cache (or it will be a BC break).
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.
but we are only interested in NormalizerInterface
normalizers here? some only implement DenormalizerInterface
like ArrayDenormalizer
? i'm probably wrong...
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.
Ok got it, sorry.
Do you have some nice Blackfire profile to show us the difference? 😃 |
For me this only gave a 9% speed up, as |
@@ -202,8 +205,21 @@ public function supportsDenormalization($data, $type, $format = null, array $con | |||
*/ | |||
private function getNormalizer($data, $format, array $context) | |||
{ | |||
if (\is_object($data) && isset($this->normalizerCache[\get_class($data)][$format])) { | |||
return $this->normalizerCache[\get_class($data)][$format]; |
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.
There will be some BC break here, let's imagine two normalizers with same class and format where:
- The first one (having a high priority) vary on data so not cacheable
- The second one (having a lower priorirty) vary on class only, so is cacheable
When a data will get the second one, then it will have more priority than the first one (as it will be cached and returned here everytime).
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.
No because we don't cache anything if one of the normalizer in the chain before the one that marches is not cacheable (even if the normalizer finally used is cacheable).
@bendavies I added support for primitive types and excluded denormalizers. Can you try again? |
Here is a Blackfire comparison: https://blackfire.io/profiles/compare/ca1d8a07-335f-4a37-8ea7-369f3983d044...0563674b-fc0e-4ab4-9e6d-1b9a089651ad/graph?settings%5Bdimension%5D=wt&settings%5Bdisplay%5D=landscape&settings%5BtabPane%5D=nodes&selected=&callname=main() Improvement of 9.27%. I used an adapted version of @egeloen's benchmark. The gain will probably be bigger on real projects (especially API Platform ones) where a lot of normalizers are registered. Here is the adaptation: $this->serializer = new Serializer(
[
+ new DataUriNormalizer(),
+ new DateTimeNormalizer(),
+ new DateIntervalNormalizer(),
+ new ConstraintViolationListNormalizer(),
+ new JsonSerializableNormalizer(),
new GetSetMethodNormalizer($classMetadataFactory)
],
[new JsonEncoder()]
);
} And the command used: blackfire run bin/benchmark -i100 -sSymfonyGetSetNormalizer --iteration 100 --vertical-complexity 4 --horizontal-complexity 4 |
Here is a profile from of a real project: https://blackfire.io/profiles/compare/a5c802f8-5c45-4d06-ae6f-ed63b4f23b8a/graph (provided by @bendavies) 36% for the whole app! |
@@ -202,11 +205,37 @@ public function supportsDenormalization($data, $type, $format = null, array $con | |||
*/ | |||
private function getNormalizer($data, $format, array $context) | |||
{ | |||
$type = \is_object($data) ? \get_class($data) : \gettype($data); |
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 could lead to buggy behavior since gettype
can return strings that are valid class names (https://3v4l.org/bAJiO).
(this would be a really hard to get edge-case but still possible).
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.
Ok i'll add a prefix for classes.
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.
done
* | ||
* @author Kévin Dunglas <dunglas@gmail.com> | ||
*/ | ||
interface NormalizerWithCacheableSupportsMethod |
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 would you think about renaming NormalizerWithCacheableSupportsMethod
to CacheableNormalizer
?
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's the supports
method that is cacheable not the normalizer itself if I'm not mistaking.
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.
CacheableNormalizer
is misleading (the result of the normalization isn't cacheable itself, it's only the call to the supports
method).
But the performance improvement is so huge that I think caching should be the default behavior, and disabling the cache should be opt-out. Having a normalizer with a supports*
method result that isn't cacheable is a edge case.
I would like to make the cache opt-out instead of opt-in, but it will be a BC break... On the other hand it improves DX and performance for everybody.
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.
Missing Interface
suffix.
What about CacheableSupportsInterface
I'd also add the supports method in the interface if possible.
Also, what about using constants for possible values?
VARY_BY_TYPE, etc.?
*/ | ||
public function varyBy(): array | ||
{ | ||
return array('type'); |
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.
shouldn't we use short array notation here, as PHP 7.1 is the min version for 4.x?
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.
Nop, this has been discussed numerous times already and we keep the sort array notation for consistency 🙃
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.
(s/short/long/)
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'll be fixed in Symfony 5, right? 😂😂😂
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'll be reconsidered at last when 3.4 will be EOLed I suppose.
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.
when 2.8 gets EOLed. 3.4 is not an issue. We can migrate it as it supports PHP 5.5+
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.
Indeed!
* | ||
* @author Kévin Dunglas <dunglas@gmail.com> | ||
*/ | ||
interface NormalizerWithCacheableSupportsMethod |
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.
Missing Interface
suffix.
What about CacheableSupportsInterface
I'd also add the supports method in the interface if possible.
Also, what about using constants for possible values?
VARY_BY_TYPE, etc.?
@@ -202,11 +205,37 @@ public function supportsDenormalization($data, $type, $format = null, array $con | |||
*/ | |||
private function getNormalizer($data, $format, array $context) | |||
{ | |||
$type = \is_object($data) ? 'c-'.\get_class($data) : \gettype($data); |
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.
does it make any sense to cache by internal type?
I feel like only classes should allow caching but I might be wrong
if the answer is yes, I'd suggest using the prefix on the gettype side, would be better for performance.
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 it's doing is caching the fact that no normalizers supported a primitive, which allows a shortcut to escape testing again.
$type = \is_object($data) ? 'c-'.\get_class($data) : \gettype($data); | ||
if ( | ||
isset($this->normalizerCache[$type][$format]) || | ||
(isset($this->normalizerCache[$type]) && \array_key_exists($format, $this->normalizerCache[$type])) |
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.
if (isset($this->normalizerCache[$type][[$format]))
looks enough to me here, provided the case for primitive types is done this way:
$this->normalizerCache[$type][$format] = false;
return $this->normalizerCache[$type][$format] ?: null;
Some additional thought: |
@nicolas-grekas I like your idea but instead of the marker interface I would use interface with a |
*AndFormat - But not sure about that because the (de)normalizer interfaces ask about supportsNormalization. It'd be strange to require implementing two similar methods, especially when the new one would basically just call the current on with a dummy$data as there is no other possible implementation. |
The implementation does not seem to care about the varying though. It always varies the cache by both type and format. |
New implementation proposal:
The gain is higher now, 16% instead of 9%: https://blackfire.io/profiles/compare/ca1d8a07-335f-4a37-8ea7-369f3983d044...b6a8383e-e4e9-4d9c-b514-2e31a47fbff1/graph?settings%5Bdimension%5D=wt&settings%5Bdisplay%5D=landscape&settings%5BtabPane%5D=nodes&selected=&callname=main() We should also update the documentation to encourage to always use this new interface when possible (= almost always). @nicolas-grekas the local cache still has a benefit if (and only if) at least 1 normalizer in the chain doesn't implement this new interface (in some case, there is a real use case: the |
It's not possible, most normalizers use |
bbb4d4e
to
e89c236
Compare
074b9b7
to
11ecc0a
Compare
11ecc0a
to
16f8a13
Compare
Nice improvements @nicolas-grekas 👍 |
@bendavies can you try the last version before we merge it? |
Yes. Will later today.
|
|
||
foreach ($this->normalizerCache[$format][$type] as $k => $cached) { | ||
$normalizer = $this->normalizers[$k]; | ||
if ($cached || $normalizer->supportsNormalization($data, $format, $context)) { |
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 to clarify maybe you can replace $cached
by $isCacheableAndSupportsNormalization
?
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.
bof, this is a local variable, and the concern is separated by 5 lines...
Real world ApiPlatform app comparison (tldr: 36% improvement): |
Thank you @dunglas. |
… (dunglas, nicolas-grekas) This PR was merged into the 4.1-dev branch. Discussion ---------- [Serializer] Cache the normalizer to use when possible | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes <!-- don't forget to update src/**/CHANGELOG.md files --> | BC breaks? | no <!-- see https://symfony.com/bc --> | Deprecations? | no <!-- don't forget to update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tests pass? | yes <!-- please add some, will be required by reviewers --> | Fixed tickets | #24436 <!-- #-prefixed issue number(s), if any --> | License | MIT | Doc PR | todo Still a WIP: * [x] Support `supportsDenormalization` As this will dramatically improve the performance of the Serializer component, can we consider introducing this change in 4.1 @symfony/deciders? ping @bendavies Commits ------- 16f8a13 [Serializer] Generalize caching support a bit c1e850f [Serializer] Cache the normalizer to use when possible
@dunglas @nicolas-grekas It seems that this breaks usage of my custom normalizers. They do not implement |
@emodric can you provide a failing test case so we can iterate? |
…heableSupportsMethodInterface (nicolas-grekas) This PR was merged into the 4.1-dev branch. Discussion ---------- [Serializer] Add ->hasCacheableSupportsMethod() to CacheableSupportsMethodInterface | Q | A | ------------- | --- | Branch? | master | Bug fix? | yes | New feature? | no | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | - | License | MIT | Doc PR | - Enhances the interface introduced in #27049 to make it possible to dynamically define if "supports" methods are cacheable. Commits ------- 04b3692 [Serializer] Add ->hasCacheableSupportsMethod() to CacheableSupportsMethodInterface
*/ | ||
protected $normalizers = array(); | ||
|
||
private $cachedNormalizers; |
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.
@nicolas-grekas what is this property for? it it set below but never used again.
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.
ah, $this->normalizers
is protected so could be edited by extensions. in which case you'd want to reset the cache?
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.
yes, that's the purpose, allowing efficient caching without breaking BC of the protected property (which is now internal so will be removed in 5.0
…dunglas, javiereguiluz) This PR was merged into the 4.1 branch. Discussion ---------- [Serializer] Cache the normalizer to use when possible symfony/symfony#27049 Commits ------- 3e2e30f Minor reword 8d6ca70 RST 8e09eeb RST ccdf894 [Serializer] Cache the normalizer to use when possible
Still a WIP:
supportsDenormalization
As this will dramatically improve the performance of the Serializer component, can we consider introducing this change in 4.1 @symfony/deciders?
ping @bendavies