Problem/Motivation
When normalizing collection requests we should be able to leverage the cached data from the normalization of the individual entities.
Proposed resolution
If a resource (entity) has been already normalized in the past, then reuse it during the normalization process. Incrementally expand the cache entry for a resource (an entity), just like Render Cache already does (see #16).
See a more detailed explanation of the performance improvements in #108. This is a summary.
Scenario | Resp (avrg) | Resp (max) | Resp (min) |
---|---|---|---|
No Cache | 459 ms | 817 ms | 436 ms |
With Cache | 191 ms | 925 ms | 174 ms |
Remaining tasks
None.
User interface changes
N/A
API changes
None.
Data model changes
None.
Release notes snippet
JSON:API now caches resource (entity) normalizations, resulting in a typical 2⨉ speed-up. The improved performance is especially noticeable on responses with many resources (entities), typically when using lots of includes. Notably, using different combinations of sparse fieldsets does not cause different cache entries; all requests for the same resource uses the same cache entry, regardless of the sparse fieldset requested.
Comment | File | Size | Author |
---|---|---|---|
#195 | 2819335-195.patch | 25.79 KB | Wim Leers |
#195 | interdiff.txt | 1.41 KB | Wim Leers |
#194 | 2819335-194.patch | 25.79 KB | Wim Leers |
#194 | interdiff.txt | 1.29 KB | Wim Leers |
#189 | descoped.txt | 54.15 KB | Wim Leers |
Comments
Comment #2
e0ipsoAfter a conversation with @Wim Leers we decided to postpone this. Views is doing this because it's incredibly complex. That complexity already includes the layers of abstraction to leverage this.
We don't have those layers of abstraction, and it's not worth adding them for this.
Comment #3
Wim LeersI think it's safe to close this — nobody has complained about JSON API performance. Let's see if it's an actual problem first — we can still open an issue then.
Comment #4
btully CreditAttribution: btully commentedWe're seeing LOTS of performance issues with the normalization process with collections of 50 nodes. Leveraging cache for requests that have already been normalized seems like a big win, especially since normalization seems to be a big bottleneck in our experience.
Comment #5
steven.wichers CreditAttribution: steven.wichers commentedNote that it's down to this code
Which calls
and is ultimately responsible for nearly a million function calls split between it and child functions.
This is with what I would consider a fairly simple and typical content type. Only core fields are on the content type. There are entity reference (taxonomy term) fields but with minimal references. No nested fields. If I override renderResponseBody and do my own normalization the response times drop from 10-20s~ to 0.5-2s~.
I think many people likely have this problem but it goes unnoticed because of lax requirements or caching after the first request. In our case we have a strict performance target and caching is either minimal or unavailable, so this problem is brought front and center. I haven't dug into the normalization system, but my brainstorming of possible changes is below:
* Implement what this ticket discussed.
* Static cache of which fields support normalization so there's only one check per request checking over and over. Idea is to reduce needless, nested function calls.
* Like the above, but couldn't this be done by entity_type:field_type instead of the individual field?
* Add the ability to normalize sparse fieldsets instead of always normalizing the entire node (runs counter to caching discussed here).
Comment #6
steven.wichers CreditAttribution: steven.wichers commentedI've implemented a custom normalizer for the specific endpoints we need to be highly performant. The difference is fairly drastic.
Process for the results was:
Between the original and custom test, I flipped a toggle that lets me enable/disable the custom normalization on the fly.
Comment #7
steven.wichers CreditAttribution: steven.wichers commentedComment #8
e0ipsoI concur with @Wim Leers, I don't think that is a widespread problem. However, I'm curious to learn about your patch. Would you be willing to post it here?
Comment #9
Wim LeersGreat research, @btully and @steven.wichers, thank you very much for posting it! 🙏 👏
#6 shows a 3.5-fold improvement. That's wonderful! But it's curious that the response size is different. Why is that? It seems like some things are no longer being normalized that should be normalized…
Agreed with @e0ipso that it'd be great to see this patch posted here.
Finally: can you share:
Comment #10
steven.wichers CreditAttribution: steven.wichers commentedre e0ipso: I don't have a patch to share because I didn't make modifications to the jsonapi module itself. I decorated the jsonapi.resource_response.subscriber service to add my own functionality. In my class I check if the request is for the specific endpoint we need to make sure performance is better for and then call a custom document builder. My normalization grabs the fields from the entity and pulls the value depending on the field type. Ex:
It's "generic" but definitely only tested and validated against our specific usage. I might be able to pull out some of the custom client information to share the rest in a gist, but I am sure there are different field types or other scenarios not being accounted for.
I haven't fixed any bug in jsonapi, only worked around it.
re Wim:
I cannot share the endpoint.
The response size is different because my normalization is tailored specifically to the project. For example, we don't need the original value of formatted text fields. The normalized value for my implementation only includes the "processed" key instead of the original and processed. The other big difference is that we don't need the vocabulary added a as a relationship, so it's not brought in. I believe the OOTB normalization adds that on its own. I've done a diff of the before/after to validate and outside of those two things it's the same.
Amount of entities being queried against: 39076
Amount of entities returned in a typical response: 50
Number of includes: Will vary, let's say 4 on overage but with overlap (so number of included items is lower).
However, number of includes is not what bumps the response size up. For example, if I go to /jsonapi/node/{content type} with no filters/includes then response size is 350KB~. If I go to /jsonapi/node/{content type}?page[limit]=1 the response size is ~6KB. I don't see how someone wouldn't reach >15KB with any sort of body field being included.
I should also point out that even with a liberal usage of sparse fieldsets the response times were dog slow. I believe because everything is still being normalized regardless of what is included in the response.
Comment #11
Wim LeersI made a typo, I meant to write 150 KB 😅
Comment #12
steven.wichers CreditAttribution: steven.wichers commented150KB makes more sense.
There are 18 fields on the content type, not including title and body.
9 are simple text fields/values (short/small values)
1 is a boolean
1 is a date
4 are formatted text (short/small values)
3 are multivalue text fields (short/small values, 0-3 values)
If I request everything but the body field then the response size is 70k. If I include the body then the response size is 250k+ (depends on article length).
Article length can vary and ranges from 1k~ to 145k on the extreme. Out of 40k articles, only a few are near the extreme end. The average length of articles is 4476. Which means 4.5 * 50 = 225k + 70k (of other fields/overhead) = 295k rough estimated average response.
But that's not indicative of a real request we're making. We are using sparse fieldsets to restrict the returned data down to a few fields. We are including the taxonomy terms, but restricting them down to just their labels. If I limit the results to just one item with no includes but still including the taxonomy fields, it's 3KB. If we pretend that is a consistent size then we're still back at 150kb for 50 items.
I believe, however, the entire set of entities (node+includes) still get normalized regardless of if we're requesting just the title or the entire thing. If we pretend I have a 1MB body field I am pretty sure that is getting normalized even if I don't want it in the response. I can only assume that means on a site with complex content (referenced nodes) this problem is greatly magnified.
Comment #13
Wim LeersIf you apply #2997600: Resolve included resources prior to normalization, should we consider this issue moot/solved, or is it still relevant?
Comment #14
Wim LeersComment #15
gabesullice@e0ipso alluded to this in our API-First call. This is still relevant as a next-step in the overall performance epic. It's roughly mentioned in #2996637-8: [META] Improve collection and include performance.
Where I'd like to see this issue go is for
EntityNormalizer
to check a persistent cache for a normalized entity first.I'm not sure if cache entries should be varied by sparse fields or not. @e0ipso seemed to think "no" and would like normalize and cache all fields, then strip unwanted fields at runtime.
I think it depends on how the cache is filled.
If the cache is only filled on-demand, then I think it should vary by field set.
OTOH, a queue worker could normalize all entities on insert/update, warming the cache ahead of time. In that case, I would say it should not vary by field set.
Comment #16
Wim LeersI'd be happy to take this on. Not right now though, we have more important issues to tackle first. This is very similar to what I've had to do for Drupal core's render cache and Dynamic Page Cache.
Comment #17
gabesulliceComment #18
e0ipsoI think we should not vary per field set because that increases the cache HIT ratio, even if there is an initial penalty to normalize all fields. This cache becomes important when other layers MISS, in those situations field set configurations will not be homogeneous. In other words, these cache entries will be useful for cross pollination between unrelated requests that happen to touch the same entity.
Comment #19
Wim LeersI think #18 is saying that a cached JSON:API resource should always include all fields. Then the same cache entry can be used for both the case of no sparse fieldset being specified as well as any sparse fieldset, because all that needs to happen is some key-value pairs need to be removed.
Correct?
Comment #20
e0ipsoCorrect.
Comment #21
Wim LeersThanks for confirming — I agree with you! 👍
Comment #22
Wim LeersI think #3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization is shaping up to help simplify this. While working on #3022584-49: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization, I saw the silhouette of how we could make this work.
Comment #23
Wim Leers#3022584: Consolidate and simplify NormalizerValue objects: introduce CacheableNormalization landed! Now we can use that infrastructure that it introduces.
My hunch in #22 was correct: the below PoC patch was written in ~30 minutes.
Let's test this:
ab -c 1 -n 100 -H "Accept: application/vnd.api+json" http://d8/jsonapi/node/article
with only 2 small articles. Dynamic Page Cache & Page Cache disabled.… and of course the benefit increases linearly; this is the same but with 4 articles:
10 ms extra in HEAD (so about 5 ms per entity), but only 1 ms extra with patch. Without digging into details (which we should before committing this), this seems to be reducing the normalization cost per entity by 80% for warm caches.
This patch's caching is not impacted by using sparse fieldsets: one and the same cache item is used for both the absence of sparse fieldsets and any combination of sparse fieldsets. This means some work still needs to be done per entity, but that's a fine trade-off: for zero work, there's Dynamic Page Cache. But that only works for the specific URL that was requested, whereas the partial caching introduced here works for any URL where that entity appears.
Finally, thanks to #2997600: Resolve included resources prior to normalization, this also automatically works for includes. :)
Comment #25
Wim LeersComment #27
Wim LeersClean-up.
Comment #29
Wim LeersOops :)
Sign it's getting late. More tomorrow!
One of the failing tests seems to be related to
\Drupal\jsonapi\LabelOnlyEntity
, and one seems to be failing due to a different order of the key/value pairs underrelationships
, but the actual values are identical.Comment #31
gabesulliceWhat concerns me about this patch is that cold caches will be made slower when sparse field sets are involved. Why?
Because in order for this to work, when an entity is not in cache, we need to normalize every field. In another issue, we showed that skipping complex fields (like a large node body field) can provide huge gains on a cold cache requests.
***I think it would be wrong to assume that most real-world requests that miss PC or DPC will still have a significant number of cached entity normalizations, so we should probably be more concerned about cold cache normalizations.***
This patch doesn't currently handle the fact that sparse field sets need to be ignored on cache miss, but when it does get to that, we must not forget that scenario.
What I propose as a solution is to actually skip setting a cache value if a sparse fieldset is requested. In that case, we should create a normalization queue and insert that entity as a queue item instead of setting it in cache. On
Kernel::TERMINATE
, we can then process that queue and normalize it with no skipped fields.Aside: with this feature, a nice contrib module could be made to preemptively do entity normalizations for entities that haven't even been requested.
Comment #32
gabesulliceThis is the optimization we lose on cold caches.
Comment #33
Wim LeersIndeed, that is something we lose in the current patch. I should've mentioned that in #23.
+1
Is it really worth that extra complexity? We could choose to simply only create a cache entry in case no sparse fieldset is specified. That means that if any entity is only ever requested with a sparse fieldset that we don't create a cache item. But the benefit in that case is already lower.
I'll do some benchmarking/profiling tomorrow to check whether the more complete yet more complex approach you propose is worth it.
Comment #34
gabesulliceI thought of this too and I agree it is a lot of extra complexity. I worry that without it, there won't actually be a benefit though. I guess benchmarks are what we really need (of course, it will be hard to simulate real-world usage).
It's probably also worth considering cacheing per sparse field set... we should ask ourselves: how diverse are field sets are in practice? For example, how often do we believe that terms are actually fetched without just
?fields[taxonomy_term--tags]=name
? How variable are field sets? Perhaps there are only 2 or 3 variations (how many View Modes do most content types have?).Finally, I think we need an answer to this before we commit something:
By what mechanism will this cache be warmed so that it will be impactful when [Dynamic ]Page Cache is cold?
The only scenario that seems plausible to me is collections that are serving a Views-like component with "exposed filters". Am I missing another?
Comment #35
e0ipsoI am convinced that this is an acceptable trade-off. The extra work on cold caches will pay off in subsequent requests that:
Additionally (and probably most importantly) this will allow the creation of
jsonapi_boost
which can cache warm the API. This would not be possible if we vary the cache entry by the fieldset combination or skip caching on the presence of sparse fieldsets.We could, alternatively, cache field (and link) normalizations.
Comment #36
Wim LeersThis was my thinking too. :)
This would lead to a lot of I/O, easily hundreds or even thousands of cache retrievals per request. I don't think that's wise.
Thanks to the feedback from both of you, I realized there is a middle ground to have the best both 😄
It will be slightly more complex than the current patch, but far less so than the queue approach (which also involves redoing work that was already done), would not make cold caches slower (i.e. zero regression compared to HEAD's sparse field set optimization), and still only needs one cache item per entity instead of one per sparse field set combination per entity.
It's an approach similar to what I already worked on for Drupal core's
RenderCache
: on cold cache, compute exactly what was requested. For fields that are omitted, generate aFALSE
entry (meaning: "this was not yet computed"), and cache that. Subsequent requests with the same field set will not have to do any computations. But requests with a different field set will run into some fields that haveFALSE
. Those we then compute (still saving quite a bit of time, because we only need to normalize a few instead of all) and … we then update the cache item.In other words: we grow the cache item for a given resource (entity). If for a given resource only 2 fields are ever requested, then only those 2 are ever normalized (and cached). If for another resource sometimes 3 fields are requested, sometimes 17, and sometimes all, then they all end up being normalized and cached, eventually.
Thoughts? :)
Comment #37
e0ipsoI agree. Just a passing thought.
I am not convinced that the increase of complexity pays off. Would you consider implementing the less complex version of this, and make the evolvable cache entry feature a follow-up that we can discuss separately?
Comment #38
Wim LeersThat was my intent all along, ever since I posted that first patch at #23. But … that does mean that we technically would regress in the mean time: it'd remove the performance optimization that #3011099: Only serialize sparse_fieldset fields in \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize() introduced!
So what I propose is that I first get the current patch (#23 – #29) green.
And then, still in this issue, I post an iteration that implements #36. We can then see how we feel about it. Because I think (am hopeful) that may end up not being very complex at all. But we'll have to see.
If that works out, we'd not regress performance for any case. :)
Comment #39
Wim LeersSo, let's first make #23– #29 green. In the overly simplistic approach I've implemented so far, I'm not yet varying by bubbled cache contexts … which is of course essential. Otherwise this would show the results that the first user to access this entity via JSON:API got to all users. That's of course a massive security hole.
This is complex only because #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way hasn't happened yet. But that doesn't need to stop us. Dynamic Page Cache uses this exact same approach (see
\Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber
). In fact, I've copy/pasted the code from there and adjusted to this use case. The complexity is contained: it lives only in those 3 methods that this is adding.Comment #40
Wim LeersFix the
::testPatchIndividual()
failures.Comment #41
Wim LeersI know for a fact that
\Drupal\Tests\jsonapi\Functional\UserTest::testGetMailFieldOnlyVisibleToOwner()
will still fail like it did in #29. However … that's caused by this core bug, for which I filed a patch: #3026264: UserAccessControlHandler::checkFieldAccess() denies access to mail field without varying by the ‘user’ cache context 😬Comment #42
Wim LeersYay, #39's testing completed, and it resulted in a big step forward! Sadly, CS violations make the result harder to see.
NodeTest
and others are down to a single failing test, as expected:::testPatchIndividual()
. And that's what #40 is fixing.Let's fix CS violations so that we can see the results more clearly.
Comment #45
Wim LeersI think this patch might be green :)
Comment #47
Wim LeersAnd it is green! 🎉
So let's build on top of this now: let's see what implementing #36 looks like. (#36 would address the concerns about deteriorating performance in the cold cache scenario.)
This was even simpler than I thought it would be! The first hunk in the interdiff is also just bringing back the original code from HEAD, reducing the overall set of changes.
Comment #48
gabesulliceI think @e0ipso responded to this indirectly with:
But how/when does that actually happen on a real site? Let's say there's this request:
/jsonapi/node/article?include=field_tags&fields[taxonomy_term--tags]=name
. There's no size limit to includes, so you're potentially adding an enormous cost to that request if the articles are referencing a lot of tags. Further, in that scenario, how often is someone then going to directly request a tag entity individually or in a collection with more fields so that the cache actually pays off? My gut tells me that will actually be fairly infrequent and it would be better to let that request be slower.To be clear, I'm not arguing against partial caching, I really want to see it land. I just need to be sure we don't regress the 80% case.
I'm glad we're on the same page :D
Using the render cache and render arrays seems a little smelly to me. What are we buying? Would it be simpler in the end to not reuse this and have less magic and more domain-specific code? (I'm not at all familiar with this stuff, maybe I'm wildly off the mark).
This array feels a little inelegant, could we move the "base" normalization out of this method? Then have rename it to
getFieldNormalizations
.Finally, I did a lot of work in #2997600: Resolve included resources prior to normalization to get rid of n+1 queries, this is bringing back a DB round trip per entity (albeit to save time in normalization). Could we make this work with
CacheBackendInterface::getMultiple()
? I'm thinking we might if we have a normalizer for theEntityCollection
object.Comment #49
Wim Leers#48: I see that you responded to much of the discussion in #31 through #35.
But you did not yet respond to #36/#37/#38, which was implemented in #47. That completely addresses that entire conversation!
\Drupal\Core\Render\RenderCache
. That is extremely risky and a huge burden.See #39. We need to do this, because you accessing a certain entity might result in the normalization varying by
user.permissions
, but for me it might vary byuser
. TheRenderCache
service takes care of all the underlying cache redirection logic that is necessary for that to work correctly, and for it to gradually "learn" the different potential variations.Yes, there are tiny bits of render arrays in here, but that's literally just a transport layer.
That kinda requires leaky abstractions: it means that
EntityNormalizer
won't be self-contained anymore; we'll need something that knows about which entities will be normalized but before normalization starts to prefetch those cache entries. I quickly PoC'd this, and looks like something like this in\Drupal\jsonapi\Controller\EntityResource::getCollection()
would work:at least if we make the
cache.jsonapi_normalizations
cache bin use a chained cache backend that first caches in memory and if it's not in there checks the database, like in this patch: #3018287-62: ResourceTypeRepository computes ResourceType value objects on *every request*.Comment #50
gabesulliceI misread #38, I thought you were agreeing to postpone to a followup and regress temporarily. I agree that my concerns about regression go away with your solution in #47.
I suggested an
EntityCollection
normalizer to keep the abstraction fairly contained. This could fetch all the cache entries for all its entities then only normalize entities that are either incomplete or have no entry. I think a helper likegetSparseEntityNormalization(CacheableNormalization $entity, array $fields): FALSE|CacheableNormalization
(FALSE when the given normalization does not have all necessary fields) would make this fairly clean. It makes sense that anEntityCollection
normalizer would have the "smarts" to lazily normalize its children.I was imagining something like this too, if we didn't want to do the above.
Comment #51
Wim LeersJust talked to @gabesullice. We agreed that if profiling shows that this is still a net speed-up, that we can just stick to the approach of the current patch in #47: we can keep doing individual cache
::get()
s and leave optimizing that to a single massive::getMultiple()
to a follow-up.I was thinking that @jibran might be interested in doing that, since he reported #2996637: [META] Improve collection and include performance which led to #3011099: Only serialize sparse_fieldset fields in \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize(), based on a real-world scenario/workload! Would be perfect to validate this patch using that same scenario/workload. So pinged him on Twitter: https://twitter.com/wimleers/status/1085653956280299526.
Comment #52
ndobromirov CreditAttribution: ndobromirov at FFW commentedWill test this one in the upcoming days depending on free time.
Comment #53
Wim Leers🎉
Comment #54
jibranFirst of all a reroll after #2995960: Add a Link and LinkCollection class to support RFC8288 web linking.
Comment #55
gabesullice<musing>
Going back to the idea of n+1 queries...
If/when #3015438: Wrap entity objects in a ResourceObject which carries a ResourceType lands, I wonder if we could extend
ResourceObject
into aNormalizedResourceObject
? Then, instead of loading entities inEntityResource::getCollection
andIncludeResolver::resolve
, we first try to load instances ofNormalizedResourceObjects
. Doing that would avoid first loading entities, then again loading their normalized values (in which case a loaded entity just isn't needed).The challenge would be in how to handle includes across includes and translations...
ResourceObject
could have agetRelationship($field)
method added to it, which would a list of resource identifiers. TheNormalizedResourceObject
could derive those directly from the normalized value and the existing class could derive them from an entity reference.Translations I guess would just be an extra column in a custom table where we stored the
NormalizedResourceObjects
.Maybe this would never work because of entity access...
</musing>
Comment #56
jibranTo start the benchmarking
page_cache
anddynamic_page_cache
both were uninstalled.Without Patch
Simple node collection endpoint
jsonapi/node/node_type
(cold cache)Simple node collection endpoint
jsonapi/node/node_type
(warm cache)With Patch
Simple node collection endpoint
jsonapi/node/node_type
(cold cache)Simple node collection endpoint
jsonapi/node/node_type
(warm cache)Comment #57
jibranWithout Patch
8 fields, 2 computed, 2 filters, title sort, Pager 0-50 (cold cache)
8 fields, 2 computed, 2 filters, title sort, Pager 0-50 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 50-100 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 50-100 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 100-150 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 100-150 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 200-250 [Empty response] (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 200-250 [Empty response] (warm cache)
With patch
8 fields, 2 computed, 2 filters, title sort, Pager 0-50 (cold cache)
8 fields, 2 computed, 2 filters, title sort, Pager 0-50 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 50-100 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 50-100 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 100-150 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 100-150 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 200-250 [Empty response] (warm cache)
8 fields, 2 computed, 2 filters, title sort, Pager 200-250 [Empty response] (warm cache)
Comment #58
Wim LeersThanks, @jibran! Could you update your comments to indicate what the results look like without this patch? Without that information, it's impossible to interpret your results.
Or am I just overlooking something?
Comment #59
jibranOh! all of these stats are with the patch. I can get without patch stats as well. :)
Comment #60
jibranAdded without patch stats to both comments.
Comment #61
Wim LeersWhat is "warm cache" when the patch is not applied? With Page Cache & Dynamic Page Cache turned off, I don't know what this refers to.
The "with patch" results versus "without patch" results in #57 are quite encouraging, but without an answer to the above, I don't dare interpret these numbers or draw conclusions.
Thanks again for taking the time to test this so thoroughly! 🙏
Comment #62
jibranMaybe I used the wrong terminology. Here is the profiling scenario:
Without patch
drush cr
ab
command. I called this cold cache.ab
command I ran after that has written warm cache againest it.With patch
drush cr
ab
command. I called this cold cache.ab
command I ran after that has written warm cache againest it.I hope this will clear the confusion.
Comment #63
Wim LeersI think when you wrote
you actually meant
Correct?
(Don't worry, I know it was stupidly late over there!)
Comment #64
jibran*facepalm* Yes, you are right. I have updated the comment to show that.
Comment #65
Wim LeersNote that all the
cases are really still 99 "warm cache" responses: only the first is an actual cold cache hit: you can see this by looking at the very last number in each benchmark result. (In the first one it's 4941 for example: 5 seconds for the first request.) That first request is doing things like priming entity caches. Perhaps you even are starting without a built container?Observations:
/jsonapi/node_type/node_type
example, the longest request goes up from 4941 to 5104 ms. That's 3% longer. That seems within the margin of error when so much I/O is going on, TBH.Conclusion
getMultiple()
concern raised by @gabesullice in #48 is a non-issue.Thoughts, @jibran, @gabesullice, @e0ipso?
Comment #66
e0ipso#65 it's awesome to get those numbers to validate our guesses. I'm still OK with the trade-offs.
I have an early prototype for a cache warmer module for JSON:API already :-P
Comment #67
Wim Leers🎉 😃
@gabesullice, thoughts?
It'd be cool to keep up the "release once every 2 weeks" rhythm. We could tag 2.2 this Monday, February 4. This performance boost could be an enticing reason for people to update from JSON:API 2.1 to 2.2 :)
Comment #68
ndobromirov CreditAttribution: ndobromirov at FFW commentedThe numbers seems great 4x speed-up for warm data :D.
I have problems testing this, as it's conflicting with another patch I am using #3018287: ResourceTypeRepository computes ResourceType value objects on *every request*. I will be re-basing one over the other likely next week, so just ping me what will go in first and to re-base on.
Comment #69
gabesulliceFirst I'm HUGELY excited by the potential gains we're seeing.
However, I'm still fairly skeptical of the results. It feels a bit like we're robbing Peter to pay Paul.
The reality is that the "4x better performance" is a moot point if DPC or PC is involved, which in almost all cases it will be. Thus this change is mostly just slowing down uncached responses dramatically.
What if, now that we have a PoC and validation that this can be quite fast, the "JSON:API Boost" module just decorates the entity normalizer itself with this caching behavior?
If we could be sure that most entities in a request (not cached by DPC) already has its entities pre-normalized, that would make me much less worried about this. However, I'm not convinced that will be the case.
I'd also like to see tests without computed fields. They are still not the norm and we might only be looking at the "best case scenario".
Comment #70
Wim LeersMea culpa
You're right. I presented the information far too simply, painted a too rosy picture. I should have added far more nuance. And in doing so, I let myself get carried away by the numbers. I wanted to get an analysis out the door, I shouldn't have. I should've been more thorough.
Mea culpa. 😔
Realistic scenario
The numbers in #56 and #57 are for something that's easy to measure. To properly measure this, we'd need to test with a realistic recorded workload, i.e. with a certain sequence of requests. That's very, very difficult to obtain.
You're right, but @jibran's real-world use case just happens to contain that.
Still, we shouldn't make general conclusions based on one specific case.
@ndobromirov, I'd love to see your test results! 😀
This is not about DPC
That being said…
Of course you're right that repeating the exact same request would already be accelerated by DPC (Dynamic Page Cache). And then it'd be much faster still. So, yes, this patch would not speed up requests 2–N to a URL X1.
However, this does speed up cold cache cases to other URLs than the one that was just stored in DPC (currently URL X1). That is, the first request to URLs X2–N would result in cache misses for DPC, but insofar that the set of entities they return/normalize intersects with that for URL X1, they'll be accelerated.
You would see that 4x increase only if they intersect 100% (in other words: if responses for URLs X2–N are a strict subset of the response for URL X1). That's what you're getting at in:
(An interesting consequence is that with a single well-targeted and super expensive "cache warming request", you could accelerate lots of other requests.)
Writing to cache post-response
It's time we try this. I initially reacted skeptically to this, but back then I didn't yet know how complex or simple the patch would end up being. It's currently quite simple. Let's see how much more complex it becomes once we do this.
So, here it is. Again quite late in the day, definitely not with my top concentration, but this should at least be fairly close 🤞
Comment #72
Wim LeersGreat, #70 has only 3 failures, 2 of which are in a kernel/unit test and 1 is for a specific edge case for the
User
entity. That means this generally works! :)(We'd still want to add explicit test coverage of course.)
@jibran: I know it's a lot to ask but … it'd be super helpful if you could rerun your tests. You'd only need to redo the "after" tests if everything else in the environment has remained the same.
Comment #73
Wim LeersHah, turns out that the one change I made last-minute while cleaning up and after manual testing (adding
public: false
) essentially made this ineffective 😭Private services can't be event subscribers.EDIT: it'd be cool if Symfony actually threw an error for this during container compilation, instead of silently allowing it to "work" yet then not work as expected.
(This is exactly why we need more explicit tests of course.)
Comment #75
Wim LeersCS fix.
Comment #76
gabesulliceJust read the new patch with the
cacher
. That was a stroke of brilliance. I did not think about just delaying the write until after the request. I figured we'd just be saying "oh, and normalize this again later" effectively normalizing everything twice. 👏It's also awesome that the patch is still under 20k!
I know it's early, but let's not forget that this will need to vary on translations and revisions. (🤔 wouldn't it be great if
EntityInterface
had a standard way of fingerprinting/ID'ing every entity variant, like a per-variant UUID).Comment #77
Wim LeersHehe :) And yes, that will be a concern. For revisions, it already is. Although for revisions we could make this simple: only cache the default revision. For translations, we’ll need more nuance.
Do I sense a “RTBC if tests”? 😏🤓
Comment #78
gabesulliceTranslations already have some accidental support. I don't think it's a good idea to break that.
I'm fine with only cacheing the default revision. Although, would it really by all that difficult to have a complete solution?
Almost there!
Might it be worth generalizing this to not be specific to entity normalizations in this issue? It's a really nice pattern that we may find useful in other issues. e.g., we could consider it in #3018287: ResourceTypeRepository computes ResourceType value objects on *every request*
✅ nit: they're not "partial" entity normalizations. They're actually
complete
normalizations.I'd love to have #3015438: Wrap entity objects in a ResourceObject which carries a ResourceType so that this could just take a single
ResourceObject
as its argument. Not a blocker, but we'll want to reroll this if it lands first.✅ "partial" again.
It's only "partial" insofar as it's part of a larger response document. But I think that knowledge doesn't need to be documented/communicated on this class.
A domain object would be nice here. I'm not a fan of magic array keys. I'm going to experiment to see if there's a way to use a
CacheableNormalization
object.✅ s/has/have
✅ Ah, finally I see. It's "partial" because of a sparse fieldsets.
✅ "The format in which the normalization will be encoded."
Comment #79
gabesullice$this->cacher
needs to be defined as a class property.Comment #80
gabesulliceEdit: patches below.
Per #78.1/5 I did a little playing around.
persist
method available to it. This will make it easier for "JSON:API Boost" to write cached normalizations and then persist them.['base' => ['...'], 'fields' => ['...']]
array and made this use just a CacheableNormalization object. I think the end result is a little more elegant (and could eventually play better with the idea of a more generic cache backend).Comment #81
gabesullice🙈
This is just an experiment for now. I'm curious what everyone thinks. The high-level approach is the same. I just reorganized and renamed things.
@jibran, we talked about benchmarking the "latest" patch on the API-first call today. Please don't use this one, instead, use #78 🙏
Comment #83
Wim Leers#80 & #81:
We already have quite a few things in core doing things like that, such as
\Drupal\Core\EventSubscriber\RequestCloseSubscriber
.😀 This is making the same mistake I made in #73: private event subscribers are not possible.
🤔 This means we're adding two new services. I don't see how this is simpler or better than what I had? Just more moving parts.
How would it help a "JSON:API Boost" contrib module? The description @e0ipso gave in #35 is about cache warming, which means it needs to somehow determine which entities to normalize or which subrequests to execute prior to real requests occurring. That is the key complexity, not this.
If we want a super nimble event subscriber, then let's move the cache logic back into the normalizer, so that the event subscriber only calls a "persist()" or "writeCache()" method.
🤓 This is correct, but reads very weird.
👎Unless I'm missing something … this does not account for the fact that some requested fields may simply be inaccessible, this work would be redone every single request. Because this is now introspecting the concrete complete entity normalization, not the per-field normalization results, which might have been a
CacheableOmission
.Comment #84
Wim Leers#78:
Agreed. For translations it's simple though: vary by
languages:language_content
. We don't have a cache context for "active revision" like we do for "active translation".Maybe not, but I don't think it's worth delaying this issue/patch by even a minute for that. It's expanding scope. But honestly, yes, I do think it's difficult: because
ResourceTypeRepository
must traverse all type information in order for the computedResourceType
objects to be correct. Consequently, I don't see how we could gradually grow the cache to be complete. It's all or nothing.Seems like you noticed this in point 7, but still removed all "partial" mentions?
\Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
, the whole point is that we only normalize those fields that requrested for the current sparse fieldset. Which means it is partial. What am I missing? I guess you mean that anything that does get cached is "complete for its purpose"? But it's not necessarily the complete entity normalization, as in for all fields.Comment #85
jibranTo start the benchmarking,
page_cache
anddynamic_page_cache
both were uninstalled and xdebug was disabled.Benchmarked against the current JSON:API HEAD. The cache was cleared using
drush cr
and visited/user
to rebuild the container.Without patch
8 fields, 2 computed, 2 filters, title sort, Pager 0-49 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 0-49 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 50-99 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 50-99 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 100-149 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 100-149 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (2nd run)
Without patch
8 fields, 2 filters, title sort, Pager 0-49 (1st run)
8 fields, 2 filters, title sort, Pager 0-49 (2nd run)
8 fields, 2 filters, title sort, Pager 150-182 (1st run)
8 fields, 2 filters, title sort, Pager 150-182 (2nd run)
With patch
Every 1st request of 1st run is priming normailze cache.
8 fields, 2 computed, 2 filters, title sort, Pager 0-49 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 0-49 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 50-99 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 50-99 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 100-149 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 100-149 (2nd run)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (1st run)
8 fields, 2 computed, 2 filters, title sort, Pager 150-182 (2nd run)
With patch
Every 1st request of 1st run is priming normailze cache.
8 fields, 2 filters, title sort, Pager 0-49 (1st run)
8 fields, 2 filters, title sort, Pager 0-49 (2nd run)
8 fields, 2 filters, title sort, Pager 150-182 (1st run)
8 fields, 2 filters, title sort, Pager 150-182 (2nd run)
Comment #86
e0ipsoFantastic! Those are the numbers we were hoping for. Let's see if we find a moment to digest these numbers into a TL;DR.
Thanks a lot @jibran!
Comment #87
gabesulliceI compiled and analyzed the stats from #85: https://docs.google.com/spreadsheets/d/18fDV_r9eUJFIzLN85PY6SBDRiVVAEpX1...
I focused primarily on computed field for what's below (I don't think they were that big of a factor)
With a 100% warmed normalization cache: 75% faster
Without a warmed cache: 2.4 times slower
That 2.4 number really confuses me! The results should only be marginally slower than the pre-patch request, right? Just the cost of 50 DB reads. We know that they can't be that slow because the post-patch, warmed results are quite fast. What gives?
Comment #88
kim.pepperAccording to https://symfony.com/doc/current/components/http_kernel.html#the-kernel-t...
So you may be waiting for the full php process to terminate and send a response?
Comment #89
jibranYeah, I don't have fast_cgi on my setup.
Comment #90
ndobromirov CreditAttribution: ndobromirov at FFW commented#87 - the google sheet is private.
#88 - even if he is not using the optimal server setup. Users will see this as normal and just marginally slower, but the resources and time spent on the server side is going to be the same. So for scalability purposes it's correctly reported 2.4 slower on cold caches... :(
Comment #91
Wim Leers#88: Gah, that's right. I can't believe that's still not fixed :( @kim.pepper++
Alright, I think that means we should postpone this issue for now. We now know it can be done. Project that need this now can apply the #78 patch. We can't do anything about PHP infrastructure blocking us.
Comment #92
gabesulliceSorry @ndobromirov! I thought I clicked "anyone with the link" but it was "anyone in the organization with the link". Everyone should have access now.
Comment #93
Wim Leers#3026264: UserAccessControlHandler::checkFieldAccess() denies access to mail field without varying by the ‘user’ cache context is fixed — that means at least that work-around won't be necessary anymore here 🙂
Comment #94
e0ipsoDo we want to? In #89 @jibran mentioned that the good numbers above don't take advantage of that. I would assume this is a nice to have, not a blocker.
Also, code in #78 looks good to me.
Comment #95
gabesullice@e0ipso, when writes aren't actually postponed until after the response is sent, they add so much time to the response that it quickly eats away the entire advantage of cached normalizations.
I did some back of the envelope math and even if just 4 entities are uncached in a response with 50 entities, you break even (it won't be faster or slower). However, any more that ratio and it gets slow, fast.
I really think we should just decorate the entity normalizer with the cacheing service created here in JSON:API Boost, that way Boost is entirely self-contained.
Comment #96
ndobromirov CreditAttribution: ndobromirov at FFW commented+1
Comment #97
e0ipsoI think the key here is back of the envelope. I don't see why we should trust that guess over @jibran's proof. As you can see in #89 that is factored by the results posted above.
I think that deferred cache writing is a nice improvement, but not a blocker.
Comment #98
e0ipsoThis re-rolls #78 and changes the direction slightly. This patch was another casualty of #3015438: Wrap entity objects in a ResourceObject which carries a ResourceType.
It still keeps all the supporting code for caching the normalizations. However it will not cache anything, because the normalization cacher follows a null pattern approach. JSON:API Boost will decorate the
NullNormalizationCacher
to do the deferred caching either on terminate or using a queue. In other words, theEntityNormalizationCacher
service from #78 has been moved to JSON:API Boost.Comment #99
e0ipsoI have not tested the patch in #98 it's probably broken in many several ways, but worth sharing for discussion.
Comment #100
e0ipsoAfter a quick glance I noticed:
This should be renamed to
jsonapi.null_normalization_cacher:
This needs to be moved to JSON:API Boost.
Comment #102
e0ipsoA bunch of misc fixes for #98.
Comment #104
e0ipsoFWIW these are the changes to JSON:API Boost to support cache persistence https://cgit.drupalcode.org/jsonapi_boost/diff/?h=2819335--cache-normali...
An alternative to the
NullNormalizationCacher
could beMemoryNormalizationCacher
. That may create less friction to put into core.Comment #105
e0ipsoWhat about now Drupal CI?
Comment #106
Wim Leers#97: That's fair. Thanks for reviving this :) I gave up on this issue in #91, at least for now, because I've got so many other things going on. But thanks to you, I gave this a fresh look. See below.
#89: The two very first
ab
results indicate that the numbers are still kinda difficult to interpret because the resource type repository cache hasn't been warmed yet — that still needs to happen during that first run. I should've advised to visit/jsonapi
first, not/user
. That'd make the numbers easier to compare. That's why we still see100% 3926 (longest request)
in the first run.#97: Four thoughts:
8 fields, 2 filters, title sort
tests in #89 address this because they don't include any fields. They show not a 4x speed-up, but a ~1.8x speed-up:8 fields, 2 filters, title sort, Pager 0-49 (2nd run)
has a median of 158 ms in HEAD and 90 ms with the patch. That's also what @gabesullice concluded in #87.8 fields, 2 filters, title sort, Pager 0-49 (1st run)
without the patch takes3126
ms, and8534
ms with the patch. Δ=5408. The only difference is caching 50 normalized entities. It seems insane, but this appears to indicate that it takes 100 ms per entity to be written to cache. Seems like something is wrong on @jibran's end, because that seems pretty much impossible. If that's right though, then it's indeed ridiculously expensive to do this. That'd be proof to not do this. If I'm reading this right, then @gabesullice's analysis in #87 is not nearly strong enough in saying "no" to this 😃 But it really looks like something is off with @jibran's tests.Let's try to verify this: let's look at
8 fields, 2 filters, title sort, Pager 150-182 (1st run)
Without patch:1739
ms, with patch:4189
ms. Δ=2450. This is caching 30 normalized entities at 80 ms per entity. Not 100 like above, but still same ballpark. So yes, @jibran's results are frustratingly high. I really wonder if something is wrong with his development environment…#98: what's the change in direction? Interdiff would be very welcome.
#105:
It will be hard to claim this is not a public PHP API.
Comment #107
Wim LeersGiven the numbers I discovered in @jibran's latest test run, I think it actually may be totally feasible to do this in JSON:API itself.
I think somebody else should reproduce @jibran's results for
8 fields, 2 filters, title sort, Pager 0-49
. Adding half a dozen fields toarticle
isn't hard, and then generating 50 random pieces of content with Drush also isn't too hard.Heck, we could just important more realistic content by using the Aggregator module to pull in 50 pieces of content after configuring a bunch of fields with complex default values.
@e0ipso: would you have time for that?
Comment #108
e0ipsoThis is what I did.
Scenarios
I have #105 applied. That doesn't do any caching at all, but it allows JSON:API Boost to decorate the Normalization Cacher to do it. The two scenarios are:
Set Up
I created a new content type called Load. I configured it to have 9 fields, all multivalue except for the boolean. Note that in a real situation it is likely that the content types will have more than that. However I decided that if the performance boost is good enough for that case, it will be even better in real life.
Both page cache and dynamic page cache are disabled.
Benchmark
This is the benchmark script I run:
Results
You can see the massive improvement from 459 ms to 191 ms. The price to pay was to make the request with cold caches slightly slower, from 817 ms to 925 ms. Note that in a real scenario we won't be writing the caches for all 50 in a single request, but we'll have some already in the cache.
The
ab
outputs are linked here for completion.Additional Observations
Adding filtering did not change the results, it only made the responses slower overall, but the improvements remained the same
Conclusion
I think we should should go for it! I'm OK with the approach in #105, but I think we should move the cache persistence back to JSON:API.
Comment #109
e0ipsoThis patch will do the persistence in JSON:API rather than JSON:API Boost.
The only thing left for JSON:API Boost is to decorate the normalization cacher and have the
saveLater
functionality optionally operate based on a queue (if php-fpm is not available). That and all the cache prefetching logic.Comment #111
gabesulliceWhoa, those numbers are far less concerning! That makes me hopeful again!
I'd still like to discover why @jibran's results were so different. If the difference is just that you're using PHP-FPM and he's not (or something like that), then we're still stuck. I have my fingers crossed that that is not the case! 🤞
I like #109! Just a few things:
It looks like you missed a super tiny thing in your reroll. I mentioned this in passing in #78.3—there's no need to pass
$resource_type
here. TheResourceObject
already contains it.This would be the first
private
property in JSON:API. I'm not opposed. Just pointing it out. Since we're doing that, should we mark the new classesfinal
? The JSON:API Boost module doesn't look like it needs to extend anything.Nit: let's use standard injection since we're not decorating anything.
I'm still concerned about #76. Do we have a test that proves that we're not breaking revisions with this? Since the cache doesn't vary by revision, I think we are.
I just landed a
getVersionIdentifier
method onResourceObject
in #2992836: Provide links to resource versions (entity revisions). That would help I think.Comment #112
e0ipso#111
I don't have a problem humoring you, if that's the case, I'm curious about your preference here and the relationship to decoration.
This slipped my fingers. I'll revert to
protected
and we can deal with these in #3007431: Make more things `final`.Comment #113
gabesulliceIt's mostly a nit. I'll refrain from inventing a passionate and well-reasoned argument ex post facto :P It's just a twitch I had while reading the diff—as I was reading I thought, "this thing is not like all the others" (in terms of the existing codebase).
I think setter injection works well when one is decorating a class using extension. Using that pattern means the underlying class can change its constructor without the extending class being affected. This is when/where I see setter injection used most often.
Comment #114
Wim Leers@jibran just mentioned in the API-First call that indeed there were some performance problems on his test site independent of JSON:API. It was related to custom access control logic apparently — I'll let him comment with more details.
Comment #115
jibranThis observation was striking for me so after doing a performance sweep of the site we fixed some major issues. These issues were not directly affecting the jsonapi response but it was running extra MySQL queries on each request which was indeed affecting the response time.
Comment #116
ndobromirov CreditAttribution: ndobromirov at FFW commented#109 will need a re-roll. It is not applying cleanly on the latest dev.
Any guidelines how to benchmark this will be appreciated.
Comment #117
ndobromirov CreditAttribution: ndobromirov at FFW commentedPing (1)...
Comment #118
e0ipsoThis is mostly blocked on getting specific test coverage for revisions and translations.
Comment #119
jibranNeeds a reroll after #3036286: Clean-up: JsonApiDocumentTopLevelNormalizer and children to prep for further clean-ups
Comment #120
e0ipsoI did this re-roll on the plane. It was harder than I thought.
Comment #121
e0ipsoComment #122
e0ipsoComment #124
jibranThanks, for the reroll. It was indeed hard to reroll but it is looking good.
Comment #125
e0ipsoThis change took so much of my time to figure out 🤦♀️.
Comment #126
e0ipsoComment #127
e0ipsoAdding needs explicit tests for:
Comment #129
jibranThis patch is becoming complex by the minute but nonetheless great work.
So we are replacing this
With this
Just making sure we are not regressing from older performance improvements.
Comment #130
e0ipso#129 @jibran
How are you perceiving that complexity increase? This just refactors for readability sake.
The only thing it does it to avoid the
continue
statement to simplify theforeach
. We do that by removing the items that will fall into thecontinue
beforehand.Performance should be untouched.
Comment #131
kristiaanvandeneyndeInstead of misusing/abusing the render cache again, please view the progress here, more specifically the changes to DynamicPageCacheSubscriber: #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way. The issue is nearly done (fixing test fails) and would be a huge DX boost for people needing caches like you do here.
If you like what you see, maybe we could collaborate on finishing the other issue first and using the variation cache here?
Comment #132
e0ipso#131 that would be fantastic @kristiaanvandeneynde. I did not know about that issue. I can see this playing out in two different ways. We land this, then when the other lands, we refactor. Or if #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way lands first, we can refactor this before merging.
Comment #133
gabesullice+1 to #132 and keeping the two issue independent. That said, I've bumped #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way in my "things I want to see done" list and will try to help push that forward soon. Thanks for bringing it back up @kristiaanvandeneynde!
Comment #134
kristiaanvandeneyndeThanks, hopefully the other one lands first because of all the test fails i had to fix there :D Would be great to have either of you as reviewers once (almost) all of the tests go green.
Comment #135
e0ipsoMoving to the core issue queue.
Comment #136
e0ipsoThis is not a new feature, it's an implementation detail. We can release this in a patch release of 8.7.x. Updating the version accordingly.
Comment #137
e0ipsoWe need to change the relative paths for the patch to apply and be testable.
Comment #138
e0ipsoI don't usually submit patches to core (so I'm a bit ignorant). My local uses drupal composer project. So after applying the patch I had to account for the repo split.
I used this command. Let's see if it applies:
I'm using 8.7.x because since this is a performance improvement there is no need to wait to 8.8.
Comment #139
e0ipsoYay! it came back green.
Comment #140
gabesulliceAssigning to myself to fix the nits and add a test for revisions.
This should get the same default message as all other internal classes have now that JSON:API is in core.
Nit: "The resource objects"
I'm still concerned about this + revisions. I'll write a test for this myself to convince myself it's fine or find anything we've forgotten.
Good todo.
Nit:
s/entity/resource_object
also normalization.Can we normalize links outside of this method so they're not part of the cached normalization? My hopes for hypermedia controls involve more dynamic links that can be less cacheable than fields are.
I'd be happy to open a follow-up issue to explore caching links too, but I think it'll delay this issue if we discuss it here.
Comment #141
gabesulliceComment #142
e0ipsoYes this I'm hesitant about this part as well.
Comment #143
e0ipsoHard to say… I'm leaning towards excluding them and caching links separately if they prove to be a resource hog. However I remember @ndobromirov identifying the link generation as an issue when using multiple levels of paragraphs.
@gabesullice what if we exclude them from normalization now and create a spike to figure out the impact of link generation?
Comment #144
gabesulliceThis patch addresses my remaining concerns.
Re: #140.3 and #142 (i.e. per-revision caching) I verified that we already have tests that would have surfaced any issues in
ResourceTestBase::testRevisions
. What was happening is that the render cache automatically detects and then varies by theurl.query_args:resourceVersion
cache context which we set on all responses for revisions entity types. Using this context, it knows to redirect the cache based on that context. However, this black magic felt a bit unreliable to me, especially because of the imminent #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way. Therefore, I made this behavior more explicit by directly adding the cache context to our cached value. That resolves all my concerns re: caching + revisions.Re: #143: Yes. This was my thought exactly. I've removed link generation from the cache process for now I've also created an issue for further research: #3054441: [no patch] Investigate caching of resource object links
Comment #145
gabesulliceFrom my perspective, this is RTBC. I'll let someone else confirm that status though.
Comment #146
e0ipsoAh! We did miss something wrt revisions. Did you manually test that everything is working in revisions then?
Comment #147
gabesulliceYes, I verified by manually testing and reading our tests to ensure they already cover my concerns. We didn't really miss anything, it was more that it was working already by coincidence. See #144:
So, I didn't really have to do anything. I just did this to feel safer when/if our implementation changes implementation changes.
Comment #148
e0ipsoAll things check out now!
Comment #149
kristiaanvandeneynde#2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way is fully green now. Would it be of interest to create a combined patch or do we just wait and see which one gets committed first?
Comment #150
e0ipso@kristiaanvandeneynde that's awesome! I hope more people can start leveraging that service.
I think it does make sense to have both options #144 and a combined patch. Would you be open to create it?
Comment #151
kristiaanvandeneyndeI really hope I don't break your RTBC status with this :D
This patch contains 100% of your patch and only the variation cache + variation cache unit test from my patch. Because that's all we care about here: Does your work function with my work powering its cache?
I do have one question: Why not use dependency injection on ResourceObjectNormalizationCacher? Using a setter like that seems weird.
Edit:
Forgot to remove this.
Comment #152
e0ipsoThanks @kristiaanvandeneynde! So impressive that we got a green patch that is so big at the first try. This speaks volumes about the right abstractions.
About the order of commit. I just noticed that this one is for 8.7.x and the other one is of 8.8.x. That means that we'll need these issues to go in separately, if we keep the branch targets as is.
Comment #153
e0ipsoComment #154
e0ipsoComment #155
e0ipsoNote to reviewer please review #144. #151 is here only as a proof of concept and validation of the abstractions created elsewhere.
Comment #156
kristiaanvandeneynde@e0ipso The patch is fully BC so I'm actually hoping to get it into 8.7.x I posted it against 8.8.x so it works there and can then be backported if it doesn't apply cleanly to 8.7.x (which it currently does)
Comment #158
e0ipsoRe-uploading the patch in #144 and changing the target version to 8.8.x after talking to @berdir in Slack.
Comment #160
e0ipsoI am convinced that the failures in
Drupal\Tests\media_library\FunctionalJavascript\MediaLibraryTest
are unrelated to the patch.Comment #161
yogeshmpawarSetting back to RTBC!
This is not happening anymore.
Comment #162
larowlanshould this move to core now?
nit, we don't need the else here
should we be doing this assert in ::saveLater? ie is it too late when we're in terminate?
Should we document why this is private (Which isn't our normal approach)?
same here re private?
so I'm not seeing and new tests here covering the fact that cache entries are building up with each field as they're requested? Am i missing something?
Also, I'm not sold on saveLater being an accurate name given that only happens on php-fpm, so perhaps we should be documenting that in the patch.
Comment #163
e0ipsoprotected
. Force of habit.I think
saveLater
is accurate. When you callsaveLater
the actual save happens later on the request cycle. That is regardless of weather it's on the user thread or not. However, I have no attachment to the name. I'll change it tosaveOnTerminate
. Will that work?Comment #164
ndobromirov CreditAttribution: ndobromirov at FFW commented+1 for
saveOnTerminate
Comment #165
e0ipsoI applied the changes proposed in #163.
Comment #167
e0ipsoBack to RTBC after addressing @larowlan's feedback.
Comment #168
larowlanwe don't normally comment lines out?
should we also assert that the second key is title?
❤️ this test
Comment #169
e0ipsoMoving back to RTBC for @larowlan (is this the procedure?)
Comment #170
e0ipsoYeah… I forgot
--src-prefix a/core/ --dst-prefix b/core/
in the patch in #169. Let's try again.Comment #171
e0ipsoComment #172
Wim LeersI apparently last commented here more than 3 months ago, in #114! Been too busy with media embedding to have time for this — sorry!
#115 🤓
#131: I am so looking forward to reviewing that issue/patch! I'm very sorry I haven't gotten to it yet! I agree with @e0ipso in #132 and @gabesullice in #133 though that we should land this separately, and then when #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way lands we can refactor the code that this issue added. That'll be extra validation too :) Or, if #2551419: Abstract RenderCache into a separate service that is capable of cache redirects in a non-render array-specific way ends up landing first, then we'll just refactor this. The point is that neither should be blocking.
#140.3 + #142: agreed with those concerns. @gabesullice addressed those concerns in #144 fortunately :)
#151: 🤘🚀
I don't think we should be changing all these existing docs in this patch. That's definitely out-of-scope.
Let's create a "minor task" issue for that, to keep this patch tight in scope.
This seems to be the primary reason this patch grew from 15K in #158 to 79K now.
Why does this use a setter? That question was asked by @kristiaanvandeneynde in #151 too but was never answered AFAICT.
Note that the setter on
ResourceObjectNormalizer
is fine, that was explained by @gabesullice in #113. It's only this setter that's odd.This is a git conflict resolution leftover file :)
This applies to not just this method, but the entire class. Because for example
::generateLookupRenderArray()
would then also disappear!Should we make
'base'
a constant on this class? Same for'fields'
.For example:
RESOURCE_CACHE_SUBSET_BASE
andRESOURCE_CACHE_SUBSET_FIELDS
. That also allows us to more clearly document this.Comment #173
e0ipso#172:
Comment #174
Wim LeersComment #175
e0ipsoComment #176
Wim Leers#175.1: I think it's trivial enough that any future git archeology won't be significantly complicated by this, there'll just be more noise to dig through. So I can live with that staying in.
Comment #177
e0ipsoApplied the feedback in #172.
Comment #178
Wim LeersÜbernit: s/resource type/resource type name/
Übernit: s/of/for/ — I think 🤔, because I'm not a native speaker.
Why the assignment by reference?Ah, because you use it for a more terse assignment statement on line 121! This is fine then.
The two übernits can be fixed on commit.
Comment #179
kristiaanvandeneynde#178.2 Correct
You could say "two requests of the same type (i.e.: GET, POST, ...)" but when you talk about what you're requesting, it's "requests for the same resource object".
Comment #180
e0ipsoFixed the two language nits.
Comment #181
itsekhmistro CreditAttribution: itsekhmistro at Adyax commentedHello all,
We're seeing LOTS of performance issues with the normalization&serialization process in JSONAPI.
According to profiling(blackfire.io) results, it takes 90% of all wall time on CPU.
For example, (profiled a request to taxonomy vocabulary):
for more heavy content with (nodes of a type with a lot of fields) it's even worse
The thing is that the JSONAPI module spends 90% of the time on doing normalization&serialization process.
And it does this every time ( unless there is a rendered cache for it ).
@Wim Leers , have you considered an option to radically change the approach on WHEN to do the resource normalization -
I mean to put into a cache ( or static index - DB table ) the normalized content on entity create/update?
It looks perfectly aligned with the idea of "Resource (entity) normalization should use partial caching" presented in this issue.
What do you think?
Comment #182
ndobromirov CreditAttribution: ndobromirov at FFW commented#181 you might be experiencing #3017291: \Symfony\Component\Serializer\Serializer::getNormalizer() is slow, and it's not JSON:API, but a Symphony one that affects by D8 core. You can go through the tread and find workarounds that you can use now. In near future this should be resolved (hopefully 8.8).
Render cache as defined in this issue will help your second user, as the whole normalized data structure will be render-cached., includin related entities.
The first one will be inevitably slow (depending on the complexity of your payloads).
If you can share more granular details (backfire result or similar) we could look into the issues as well.
Comment #183
itsekhmistro CreditAttribution: itsekhmistro at Adyax commented@ndobromirov, Nikolay, you are right about JsonAPI and Symfony.
But
>The first one will be inevitably slow (depending on the complexity of your payloads).
Why do we need to accept "The first one will be inevitably slow".
We can avoid it if the normalized presentation is created once the entity is created and updated once the entity is updated.
It shouldn't be flushed on Clear caches either.
We can't accept being slow on high load to JsonAPI.
Comment #184
e0ipso#181 this is the use case this module is aiming to fix. Did you have a chance to profile with the patch applied and without it for comparison?
#183 please take a look at JSON:API Boost to address that concern. In the meantime, we need to get this patch merged for that module to work.
Comment #185
Wim Leers#181:
Thanks for taking the time to report back! Can you please provide more background information to interpret the numbers you're sharing? For example, the only way that it's possible to be getting a 1.23 second response time for requesting a single taxonomy is if it's being run on an ancient server. So can you please share for both examples:
Thanks!
I'm glad to see you thinking along! However, that would result in significantly more complex code for pretty much equally fast responses. Doing this would be a clear example of premature optimization. The current patch already offers a huge performance improvement, and the benefit is greater for more complex scenarios.
It'd be very helpful if you could apply the patch and report back the improved performance you observe. :) Like @e0ipso asks in #184.
#183:
You're making a big assumption here: that cache capacity is unbounded. Or at least that the cache capacity is sufficient to cache every single stored entity. This may be true for your use case, but definitely is not true for everyone.
I think this is still ready to be committed.
Comment #186
larowlanSorry for the confusion here, this one dropped off my radar
Wim is correct, I was only interested in the one new link, the old ones are out of scope here and would make a good novice issue.
Comment #187
itsekhmistro CreditAttribution: itsekhmistro at Adyax commented#185:
Sure,
to give a better overview of the situation&context I attached the profiling results below.
The test is executed on Macbook Pro 2018 in Docker-based environment.
To demonstrate the issue, I have selected a simple vocabulary with 3 fields.
Content is translatable. There are 10 languages.
There are 2 patches applied to JSONAPI from:
* ResourceTypeRepository computes ResourceType value objects on *every request*
* (this issue) Resource (entity) normalization should use partial caching
1) URL: http://.../taxonomy_term/methods
2) there are 6 Entities in the result ( 6 is all available terms in the vocabulary)
3) there are 3 fields with types:
* Text (plain)
* Text (plain, long)
* Boolean
TEST STEPS
1. Preparation
* drush cr
* Open homepage
* Open JSON API for .../node/basic
// The preparation gives a fresh start and then warmup Drupal caches.
// The goal of preparation steps is to give clean and reproducible results.
2. Do test:
* blackfire --samples 1 curl http://.../taxonomy_term/methods
Test result:
Wall time 855ms
Serializer::Normalize 490 ms = 57.3% of Wall time
Only 6 terms with 3 text fields (2 texts and 1 boolean field).
PS.
Important,
in the given test scenario there is no improvement from the patch in this issue.
Here, the test scenario was selected on purpose.
However, there is a significant improvement from the patch when used in other scenarios.
>You're making a big assumption here: that cache capacity is unbounded. Or at least that the cache capacity is sufficient to cache every single stored entity. This may be true for your use case, but definitely is not true for everyone.
It's always about some tradeoffs.
In my case, we have applied the approach I described above. Rendered cache for an entity is saved on created/updated.
It mitigates the issue of slow "Serializer:Normalize" on request to JSONAPI,
but the issue is still there on entity create/update.
--
I think the patch is ready to be committed.
Comment #188
itsekhmistro CreditAttribution: itsekhmistro at Adyax commentedIn addition to #187:
Comment #189
Wim Leers#186: Okay, done! Back to RTBC. Descoped hunks are in
descoped.txt
, to make it easy for @e0ipso to create a new issue to fix that everywhere else :)#187:
Can you please create a new issue for this? I have never seen a ~850 ms response time for 6 entities in JSON:API. I didn't see that on my old 2014 MacBook Pro I used until a few months ago, and also not on the 2018 MacBook Pro I have since a few months. I'd love to fix this problem for you, but this issue is not the right place. Like you say, this patch is already significantly improving several scenarios :)
I'm glad we agree :)
Comment #190
Wim LeersAlso priority
is quite the understatement!Comment #191
jibranAnother nit.
We don't need
array_merge
here. We can just add both arrays.> For associative arrays the only difference is when using
+
the array doesn’t get overwritten but witharray_merge
it does.In this case, 'attributes' and 'relationships' will not exist in
$normalization_parts[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE]
so it can just be+
.Comment #192
jibranIt is a feature request but it is an under the hood change so I'm not sure that we need a change record or not. I think we do need a release note section in IS.
Comment #193
e0ipso@jibran I find myself avoiding the
+
operator for arrays altogether, I personally find it confusing. We certainly can use it here, but I'm not sure I see a compelling reason to do so. Is there any benefit of one over the other?Comment #194
Wim LeersIssue summary expanded like @jibran requested in #192. Also improved it overall :)
RE:
+
vsarray_merge()
: good catch. I don't feel strongly either way. I think+
is slightly preferable because it conveys that we know for a fact that the sets of keys are different and therefore+
is safe to use. For the casual reader, they'd have to look upRESOURCE_CACHE_SUBSET_BASE
and determine which keys are associated with it to understand what this is doing. So, made the change. But honestly, it doesn't matter. We're clearly down to übernits.Comment #195
Wim LeersMade a mistake in #194, although surprisingly tests still passed locally 🤔
Also spotted a typo in surrounding LoC, fixing that too.
Comment #197
larowlanCommitted 6033d39 and pushed to 8.8.x. Thanks! 🎉
Thanks everyone for all the hard work here, this is a big win.
Comment #198
Wim Leers#195 also applies cleanly and passes tests on 8.7. It'd be super valuable for all Drupal 8.7-powered JSON:API instances out there to get this improvement in the next 8.7 patch release rather than at the end of the year. It does not cause API changes.
Comment #199
e0ipsoBig +1 from me.
Comment #200
jibranI second that.
Comment #201
catchI'm not really very keen on backporting this - it's adding two new services and new classes etc., and projects running JSON:API can probably also apply the patch via composer.
Comment #202
Wim LeersThat's unfortunate, but fair.
Comment #205
gabesullice