Proposed for the first time in #2996637: [META] Improve collection and include performance.
Currently, included items are resolved for each entity in a response. This means that for a collection endpoint, like /jsonapi/node/article, with 50 resources. 50 separate node loads will be executed for ?include=uid, 50 more for ?include=uid,node_type.
This is because includes are resolved as each entity is normalized.
A corollary to this is that include logic is centered on the idea of normalizing entire JSON API top-level documents to share logic.
Ideally, entity loads would be aggregated as much as possible to reduce DB round-trips and only the resources themselves would be normalized without also running all the other logic associated with the top-level document in a recursive process.
| Comment | File | Size | Author |
|---|---|---|---|
| #48 | 2997600-48.patch | 78.19 KB | gabesullice |
| #48 | interdiff.txt | 670 bytes | gabesullice |
| #47 | 2997600-47.patch | 80.54 KB | wim leers |
| #47 | interdiff.txt | 603 bytes | wim leers |
| #45 | 2997600-45.patch | 80.56 KB | wim leers |
Comments
Comment #2
gabesulliceThis depends on #2996000: Deduplicate `meta.omitted.links` based on `href` (`via` link).
I'll provide a self-review of this patch to help explain it in my next comment.
Comment #5
gabesulliceSince includes are now being resolved prior to normalization, we need access to the
includeparameter. Hence, we add theRequestobject.Now, the primary data and includes are passed as an explicit argument to
buildWrappedResponse.The same is true for
respondWithCollectionThis is what resolves includes in the controller. We only enter the resolution process if the request actually has an include parameter and it's not empty.
includedmust not contain duplicate resources.EntityCollection.EntityCollectionto then have a method to deduplicate itself.EntityCollectioncan receive objects that are not entities, but have a 1:1 correspondence with an entity. Like aLabelOnlyEntityor anEntityAccessDeniedHttpException.EntityCollectionneeds a uniform interface that it can use to compare and deduplicate its items.ResourceIdentifierInterfacefor that purpose and a trait that makes it easy to implement.The include resolver works with a 3 phased approach.
includequery parameter into a tree of field names.EntityCollection. As it descends the tree, it loads targeted entities and adds them to a resolvedEntityCollection.EntityCollectionit deduplicates it.This is how the include resolver appends newly resolved includes to an
EntityCollectionThis is how the
ResourceIdentifierInterfaceis used to deduplicate includes.It seems that we're slowly getting rid of all this :D
This
assertis unnecessary. Will remove it.This patch let's us eliminate SOO much complexity from the normalization process 🤩. I didn't even highlight all of it.
A lot of that complexity to the include resolver, but hopefully having it all in one place will make the whole system easier to reason about, profile, and maintain.
Comment #6
gabesulliceWOOT, #2 only has Kernel and Unit test failures!
Comment #7
gabesulliceThis cleans up all the unit tests.
Comment #10
gabesullice#2984911: Remove access to the Request object in the normalization process landed, so this needed a reroll.
Comment #13
gabesulliceComment #16
gabesulliceI reached out to a few individuals who I thought may have the infrastructure in place to run some performance benchmarks against this patch (@btully, @caseylau, @mglaman, @sime & @steven.wichers)
I'm excited to see all of their results, but I have some initial results to share.
@mglaman was kind enough to set up two environments based on the 2.x-dev branch, one without the patch applied and one with it applied. He also provided test data and a testing URL that emulates a typical commerce site. That data came from Commerce Demo. On those applications, both
page_cacheanddynamic_page_cachewere uninstalled so that each request would thoroughly exercise the changes in the patch.That URL was:
https://{environment_hostname}/jsonapi/commerce_product/simple?include=field_brand,field_product_categories,field_special_categories,stores,variations&fields[taxonomy--term]=nameI used Postman to run 100 requests against each environment from my local machine.
I've compiled the results here: https://docs.google.com/spreadsheets/d/1OkB-MdDXPt3o_v6oxIKx7ZCDnDpFa9y5...
On average, the request took 5.77 seconds to complete without the patch in #13. With the patch applied, the request took an average of 4.08 seconds to complete. These times include network round trips from my local to the testing environment and back.
This means that the patch reduced average request time by 29.19%, or, put differently, the requests were completed 41.22% faster.
Given that these results include network latency, I believe the performance improvement is underestimated.
Comment #17
lawxen commentedThe patch 2997600-13.combined.patch has some bugs which make me can't test the performance.
One of the bug description:
1. one field reference two different bundle, the two different bundle's 'XXX' field reference two different entity type A and B, then nested include A's exclusive field, then an error: detail": "Field bundle_items is unknown.","Field attribute_spicy is unknown." .........
Ps: I intend to test a very complexed url(include many nested include) which use 3.2min on jsonapi:2.x-dev originally.
Comment #18
gabesullice@caseylau, does that bug exist without the patch on 2.x-dev?
Comment #19
lawxen commented@gabesullice I have tested it many times, and confirmed that the bug doesn't exist on newest 2.x-dev, only exist after apply patch 2997600-13.combined.patch
Maybe I can reproduced it through drupal core's entity: "comment" after today.
Comment #20
lawxen commented@gabesullice
Aha, I have reproduced above bug on drupal core
Steps:
Error:
Comment #21
wim leers@gabesullice++
@mglaman++
@caseylau++
Per #20, the current patch doesn't resolve nested references.
Comment #22
gabesulliceThat is very unlikely because we have explicit test coverage for that.
I think this is a more subtle regression than that. Namely that more than one bundle will end up in the
EntityCollectionand that some of those bundles may not have all of the same fields.@Wim Leers, remember this?
My suspicion is that the answer is no, we can't :P
I'm going to try to replicate the issue in #20 in a moment.
Comment #23
gabesulliceI was able to reproduce the bug described in #20.
It was indeed what I suspected in #22.
Here's a simple fix :)
#2996000: Deduplicate `meta.omitted.links` based on `href` (`via` link) landed! So we don't need a combined patch anymore.
Comment #24
gabesullice@caseylau's example URL has something strange in it.
It needs both
field_nodeandfield_node.field_tags. But with this patch applied, it shouldn't needfield_nodeto work. So, why is it needed? Because usingfield_node.field_tagsalone does not work in HEAD. In fact, we already have an old issue for that: #2946537: Test coverage: Inclusion of intermediate resources when include is a multi-part relationship pathManual testing should show that this patch does not require the intermediate field.
I tried to apply the test coverage from #2946537 and found out that it's incomplete. The actual results are correct, but the expectations are wrong.
Comment #27
gabesullice@btully, just shared some results with me. While I can't share the details of project, I can generally summarize the initial results.
He and @steven.wichers's data is primarily focused on testing concurrent users. But out of that, we can extract average response times before and after the patch was applied.
The initial data that I've received is for requests with no includes. That means we should probably not expect much improvement there.
That said, we actually did see some improvement. Average response time dropped from 3.29 seconds to 3.14 (π!). That's 4.8% faster. It's not very significant, but it's useful because it tells us that it didn't make non-include requests slower 👍.
Other observations:
@btully shared a link to New Relic with me. That let me drill down and explore what PHP calls took the most time. Two things jumped out at me...
First, nearly half the time was spent in
getAccessCheckedEntity. While we can't really do anything to help with that, we can perhaps make some recommendations in the module docs about it (e.g. make use of static caching wherever possible).Second, the other half of the time was spent in the normalization process. Not surprising. JSON API is primarily a module that normalizes data into JSON. What was surprising is that a large part of that time was spent normalizing (and computing) the
processedtext property. Again, we can't do a ton about that, but we may want to consider a Drupal core issue to somehow make this a stored value instead a computed property (I realize that would be very difficult since one can have any number of different text formats). If not, suggest that users disable or exclude it if it's not necessary.@btully is running a second set of benchmarks that do have includes. I'll report those results soon.
Comment #28
lawxen commented@gabesullice The patch is so so so so so fast!!! I'm extremely surprised and excited,I can't wait to update.
before the patch the jsonapi:2.x-dev needs 2.7min to get the result, but after the patch just 13.38s,
I test several times, each time was request after clear the cache, and the test result is same.
Test URL:
Comment #29
gabesullice@caseylau, thanks! And.. wow. I did not expect that!
It does raise the question, why is 2.x is so much slower than JSON API 1.x?
Regardless, that's a tremendous improvement. Thanks for sharing your results!
Comment #30
lawxen commentedThis is really true, and this is one of the reason that we are still use jsonapi-1.x, because I always get "502 bad getway" after update to jsonapi2.x(I haven't realized that it was the performance problem of not getting the result untill today).
I spend half an hour to adjust the nginx/php settings to make request not 502 on jsonapi-2.x-dev(no patch applied).
Comment #31
gabesullice@btully, shared his results with me last evening.
Prior to applying the patch, response times were 8.64s. After applying the patch, they dropped to 6.3s. That's 27% less response time or just over 37% faster. That roughly matches my results in #18. It's certainly not as dramatic as @caseylau's results, but that's somewhat logical, @caseylau's request has a lot more includes than @btully's and my request.
Another thing to point out... remember that without includes, the request took 3.14s in #27. Almost exactly half the time. Looking at @btully's query, I suspect that he has about an equal number of included resources as he does primary resources. Which means that the cost of includes has been dramatically reduced. What we're seeing here is a relatively linear scaling between the number of resources serialized and the response time. @caseylau's results show that without the patch, it's much worse than that.
IOW, with this patch, includes no longer bear a significant penalty.
Comment #32
gabesulliceGiven all these results, I think this is a valuable change. Let's try to land it 🙂
Afterward, we can attack the normalization process and find improvements there.
Comment #34
e0ipsoThis is an awesome improvement, as noted elsewhere. We already suspected this, but it's nice to validate the suspicions. I can't wait for this to get into 2.x
After reading #28 I suspect that we introduced a performance penalty somehwere between 1.x-dev and 2.x-dev. Even though this patch overcomes that problem, we should investigate it and solve it as well. I suspect we can get a juicy improvement as well.
@caseylau can you create a new ticket with the info you have so we can investigate the problem?
Comment #35
gabesulliceThis failed testing because of #2998601: HEAD is failing tests for various reasons.
Comment #36
lawxen commented@e0ipso sure, because I can't public our company's code, so I will use some modules we used to build a clean drupal8 site for test on github, give me several hours
Comment #37
e0ipsoPartial review. Sorry I had to drop the review mid patch. Hopefully these are good pointers. Overall this is a fantastic patch. I'm very excited!
This is way too vernacular for my taste. Can we avoid assignments as parameters of function/method invocations?
Why the change in the order of params?
What about LanguageItem, that is also a DataReference according to TypedData.
I think we should implement a NullEntityCollection (the null design pattern) instead of polymorphic arguments.
Again, the parameter reorder seems unnecessary. Specially if
$entity_collectionis no longer optional.Let's avoid assignments inside of
empty()calls.Let's try to simplify this service and make it deal only with collections of entities. We can wrap a single entity as a collection of one.Ha! you already did this :-D
Let's use https://secure.php.net/manual/en/class.ds-set.php instead.
Comment #38
gabesulliceI'll bet you had a good excuse ;) Don't worry about it!
1. heh, that was a little too clever wasn't it? Honestly, that was a "leftover" from some bug hunting that I did. Fixed.
2. That's just what I thought made most sense when I was resolving conflict during a rebase. I'll reorder it to better respect HEAD. Having the NullEntityCollection you suggest will make it nicer since the optional arg will go away (meaning it doesn't need to be the final arg).
3. AFAICT, we always treat the
langcodefield as an attribute, not a relationship. That means that this function will never be called because there's not a related route for it.4. Done. I really like that :)
5. Yep. Done.
6. Done.
7+8: :D
9. That's a PHP7 only thing. Can we make a followup issue to convert this using a polyfill? We could do that under the
ForwardCompatibilitynamespace.Comment #39
gabesulliceEww, I generated that last patch with
-Mto make a smaller patch, but it's unreadable. Here's a more readable version.Comment #41
gabesulliceFixed.
Comment #42
lawxen commented@e0ipos @gabesullice
In order not to public our company's code, I use drupal8.6 and 4 contribute modules: commerce commerce_pricelist commerce_product_bundle group to build a clean test site, it doesn't has any other function except the test data.
https://github.com/caseylau/jsonapi-performance-test
Used for test performance between 1.x vs 2.x vs this patch
Comment #44
gabesulliceThank you SO much @caseylau. That was incredibly useful!
I set up the site you provided then used
git bisectto find the commit which introduced the performance regression. That gave me this:db8111aff492a7d7ff9ab1b14b10bd74ca6de49e is the first bad commitSo, that means that the regression was introduced in #2948666: Remove JSON API's use of $context['cacheable_metadata'].
Checking out that version and the one immediately prior to it, I isolated the issue. Before that commit, includes were rasterized in
JsonApiDocumentTopLevelNormalizer. After that commit, rasterization of includes was moved intoJsonApiDocumentTopLevelNormalizerValue::rasterizeValue().Why did that cause an issue?
Because
JsonApiDocumentTopLevelNormalizer::normalize()used to have these lines:What those lines did was cause an early return just before includes are rasterized.
After #2948666, include rasterization was moved to
JsonApiDocumentTopLevelNormalizerValue::rasterizeValue(). That method is called recursively from the top level object. This recursive call meant that nested includes were rasterized multiple times. At least as many times as there were levels of includes. IOW, an include 3 levels deep would be rasterized at least 3 separate times.That is where I stopped.
This patch eliminates recursive rasterization and therefore eliminates that performance regression, explaining the 12x improvement @caseylau saw in #28.1.
The remaining 3x improvement is likely due to the fact that we're not normalizing duplicate includes and because we're aggregating entity loads rather than loading includes 1 by 1.
Comment #45
wim leers#23: great — and thanks for that very helpful comment!
#27:
This makes sense: if you're dealing with lots of includes, that'd mean computing this property for lots of resources.
#28: 😲 🎉
#34:
+1!
#37.9: 😍 I can't wait to use this! I didn't even know this was in PHP7!
#42: WOW! Thank you so much, @caseylau! 🙏 ❤️
#44: Thanks for doing a bisection! And makes me go 😱😱😭. Thanks for digging in! I don't yet see how exactly the recursion (which is intentional, since JSON API was designed years ago to have a tree of value objects that are rasterized at the last possible moment) causes the same rasterization to happen multiple times.
Patch review time!
I like that the diffstat is pretty much a net zero change, despite this increasing code clarity and adding quite a bit of code docs.
Thanks again for #5, that really helped a lot when reviewing this patch! It answered some of the questions I'd otherwise have asked.
There's some weird stuff in the patch though: deletions are omitted somehow?!EDIT: it seems dreditor is doing seriously weird things 🙃 It's omitting all deletions for me. Which makes it hard to comment on them…Let's make it private.
Nit: s/from/for/ I think?
This alone is a huge performance win! Far fewer DB queries. 🎉
Nit: Doesn't this mean that
$related_fieldcan beNULL?EDIT: yep, by tracing the call possible callers, I confirmed this.
New meaningful actions/helpers on the value object! 👌
🎉
NullEntityCollectionI had to re-read this a few times. It makes sense though: Either the primary data is NOT an error collection, or it is, and if it is, then the includes must be the NULL collection. (Since errors can't have includes.)
Why not allow
NullEntityCollectionto be returned? Then we don't need any of this logic?NULLcan then not ever be returned?Then
hasIncludes()becomes obsolete?I think this is something you just forgot to do in #38 :)
I like this!
😲🙈
Why does it make sense for
$omissionsto ever be set toFALSE?I think you meant to update this too in #38 but just forgot :)
RelationshipItemNormalizerbecame so much simpler! No more "subcontext" stuff! 🎉 Your changes made me notice one more weird bug/leftover in that area:RelationshipItemNormalizerValue's$resourceconstructor parameter. Fixed.EntityNormalizerValueandFieldNormalizerValuebecame SOOO much simpler too!rasterizeIncludes()! Excellent! There's only one left:\Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue::rasterizeIncludes().RelationshipItemNormalizerValueno longer implementsValueExtractorInterfaceat all. Interesting … because arasterizeValue()implementation is still there. This seems wrong?Attached patch addresses all points except for the last one.
Comment #47
wim leersGah, I can't wait for Drupal to update its Symfony version so we can finally do https://www.tomasvotruba.cz/blog/2018/05/17/how-to-test-private-services....
Comment #48
gabesulliceThanks for the thorough review!
I agree with all your changes. (And I also lament that fact that private services are such a PITA!)
#45.15 was a good catch. Restored the implementation.
IMHO, this is ready for commit. We've had thorough review and approval by all three maintainers, more community review and validation than any other issue in the module's history, and we've simplified and made the codebase faster. YEAH!
Comment #49
wim leersAgreed. Let's get this in :)
Comment #51
wim leers