Problem/Motivation
It's hard to reliable guess what the real cost of it is, but I'm seeing it show up a lot in profiling.
Currently looking at a profile with 1k calls to it and 3% total time. Not much, but it adds up and might be a fairly easy target for optimization, at least in certain cases.
The site in question has large menu structures, mostly pointing to nodes in different groups, so a cache miss on the menu blocks has to load _many_ nodes and check access to them.

In my specific case, it's through group/flexible_permissions and its usage of VariationCache for that. It has a static and a persistent cache, that it calls many times with the same arguments (a few variations) and specifically a single cache context. That goes through this chain:

This is for a static cache lookup, it might not add up to much with a regular cache, as the actual cache lookup is then likely much more expensive, but for a static cache that's expected to be near-instant, this adds up. About 50% of the cost is in optimizeTokens().
Steps to reproduce
Proposed resolution
I'm not sure, just some ideas:
* For the specific case of only having a single cache context (I'm profiling with anonymous user, it's user.roles), running optimizeTokens() seems pointless. It will never be able to optimize a single context away, so we could add an early return?
* A static cache in convertTokensToKeys? but we'd need to understand if and when context values can change during a request.
* Using VariationCache as a static cache like that is obviously fairly expensive, I can definitely see an argument that maybe flexible_permissions/group shouldn't be using it like that or should have some higher-level caching or other logic changes. group specifically always collects 3 different kinds of things in \Drupal\group\Access\GroupPermissionChecker::hasPermissionInGroup() but will always only use 1-2 of those and never all 3.
Remaining tasks
User interface changes
API changes
Data model changes
Release notes snippet
| Comment | File | Size | Author |
|---|---|---|---|
| #26 | static-cache-on-top-of-3457818.patch | 10.64 KB | dries |
| Screenshot from 2024-06-28 13-27-46.png | 47.74 KB | berdir | |
| Screenshot from 2024-06-28 13-26-10.png | 80.11 KB | berdir |
Issue fork drupal-3457818
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
kristiaanvandeneyndeSo couple things for other people reading this:
With that out of the way, I need to point out that this is no longer a Group-specific thing, but a core thing. We now have Access Policy API and, just like Group, it uses a persistent and memory variation cache to store your calculated permissions. So if your permissions are highly variable, we have a bunch of cache contexts to check whenever a permission check is ran. As far as that goes, I would definitely focus on making sure cache contexts are fast.
Agreed, we could make optimizeTokens return early if the count of the array is smaller than 2.
Very dangerous territory as explained above.
Yeah, but it's required for access policies to work. I think the cost-benefit is warranted here given how much is now possible with the access policy API.
Yeah, fully agree we can optimize that in Group.
Comment #3
quietone commentedFixes are made on on 11.x (our main development branch) first, and are then back ported as needed according to our policies.
Comment #6
tibezh commentedSeeing similar results. Profiling data (Drupal 10.5.x, Group module enabled):
-
convertTokensToKeys: 134 ms total, 1,104 calls-
createCacheIdFast: 92.4 ms, 1,032 calls-
optimizeTokens: 54.8 ms, 1,511 calls-
getServicefrom optimizeTokens: 3,775 callsTested optimizations:
-
isset()instead ofin_array()- no real gain- Check ancestors before
getService()- ~2.8x speedup- Cache
getCacheMaxAge()results - ~10x speedupProposed changes
1. Early return for
count($context_tokens) <= 12. Check ancestors first, call
getService()only when ancestor is found3. Cache
getCacheMaxAge()in class property (safe - static metadata, not runtime value)I've added a merge request.
Comment #7
tibezh commentedComment #8
kristiaanvandeneyndeChanges look good, but we cannot cache the outcome of a cache context. Right now, their value may change throughout a request. Until we decide whether we actually want that or not, we have to support that.
So +1 for the optimizations you made, but -1 for the static cache.
Comment #9
tibezh commentedNeed to fix PHP Unit tests
Comment #10
kristiaanvandeneyndeTrue as that may be, we need to look at everyone's project when accepting code into Drupal core :)
I left a comment, as the new PR still has some potential for edge cases going off the rails.
Comment #12
tibezh commentedHm, spent more time to understand what's wrong with the CI, I've created a new MR https://git.drupalcode.org/project/drupal/-/merge_requests/14424
Seems the CI failures are unrelated to infrastructure issues.
Comment #13
smustgrave commentedAppear to have 2 MRs now, 1 should be closed please.
Also proposed solution may need to be flushed out.
Comment #15
tibezh commentedSure, I've closed the old one MR, we need to check an existing opened MR
Comment #18
klausiTests are passing now, nice.
Left some minor comments.
Is it possible to add a test case for this? We want to ensure that the container is not called too often, we could test that with a mock prophet. For example that the container is only called X times when certain ancestors are present in the context tokens.
Comment #19
kristiaanvandeneyndeStarting to look a lot better, thanks :)
Comment #21
longwaveAdded call count expectations to the existing test coverage, also simplified the mock container.
Comment #22
smustgrave commentedSorry this one appears to need a rebase.
Comment #23
longwaveConflicted with #3579897: Convert expectation-less test mocks to stubs - Cache library, rebased.
Comment #24
catchFor the same reasons as in https://www.drupal.org/project/drupal/issues/3539161#comment-16391346 I don't think we need to avoid static caching here in case permissions change during the request etc. as long as we always check the current user, if you have access to something at the beginning of the request you should also have it at the end of that request otherwise pages etc. could be broken.
If we eventually add frankenphp worker support we'll need to segment the cache by request but that will be the case for lots of things.
Comment #25
dries commentedI wasn't aware of this issue, but '’ve been working on the same problem in #3582977: Add static caching to convertTokensToKeys(). I still need to compare the approaches. If it makes sense, I'm happy to bring over any useful parts of my work to this issue.
Comment #26
dries commentedLooked into this some and both approaches seem complementary:
optimizeTokens()call by checking ancestors before callinggetService(). This helps both anonymous users and authenticated users.convertTokensToKeys()is called 800+ times, but only ~30 of those are unique token sets. My cache is for anonymous users only, and eliminates ~97% of those calls entirely.I merged our approaches into one and I did some rough, unscientific benchmarking from the airport this morning: your
optimizeTokens()improvements give 6-8% for both anonymous users and authenticated users. The static cache adds another 4-5% on top for anonymous users. Combined: 11-13%. The combined cache also lowersCacheGetCountinStandardPerformanceTestfurther.@kristiaanvandeneynde and others: you repeatedly mentioned that static caching in
convertTokensToKeys()is dangerous because values can change mid-request. I learned that the hard way in #3582977. :-) However, for anonymous users, I’m not sure how cache context values can change mid-request (outside of subrequest boundaries, which we handle). If they could, the render cache and dynamic page cache might be broken for anonymous users? Maybe that assumption is wrong though ... If so, I’m curious if we could create a test for that because all tests pass in the other issue.I didn't want to commit directly to your branch, but here is a patch with the static cache layered on top of your work. It should apply cleanly against your latest code.
Comment #27
catchThe static caching may be saving on some of the same calls that #3539161: Static cache access policy checking is trying to drop (originally via a static cache but we thought of an approach on there that might not need one).
Comment #28
catchI started looking at #3539161: Static cache access policy checking again, then I realised we probably need to resolve this first, it fixes some things that make that one harder to do, or might make it unnecessary altogether.
Rebased and tried to address @klausi's review comments.
Comment #29
catchNeeded to update StandardPerformanceTest a bit more - the same three cache gets we save in one assertions are saved in a couple of others too. Tests are green again now.
Comment #30
klausiNice, looks good to me! I like it that we are now testing the number of container service calls.
The reduced cache get counts in the performance test also prove that we are improving something here, really cool that we have those!
Comment #32
longwaveI think I am still eligible to commit this as I only made small changes to the test. I am also really excited to see this land as it's a significant improvement when looking at xhprof.
Committed and pushed 8bf67fbd480 to main. Thanks!
Let's also backport this to 11.x, doesn't apply there because of the performance figures.
Comment #34
catchCherry-picked to a new 11.x branch, only got merge conflicts on the unit test changes. Had to backport more unit test changes to make that work - ended up checking out the entire file rather than trying to locate individual commits.
Comment #35
dries commentedThanks @catch! You're right that the user can change from anonymous to authenticated mid-request. But I think it is safe because on every call to
convertTokensToKeys(), we check whether the current user is anonymous before looking at the static cache. Once the user becomes authenticated (whether via AccountSwitcher, login form, or magic link) that check fails, and we skip the cache entirely. But maybe you mean something else?I've also been thinking: is there a test I could write that invalidates this idea? If so, it would be good to add to Core (instead of this change).
Comment #37
longwaveCommitted and pushed 160ab21c68d to 11.x. Thanks!
Let's continue with the static cache optimizations in the other issue.
Comment #41
catch@Dries did you mean to post your comment on #3582977: Add static caching to convertTokensToKeys() ?
Comment #42
berdirJust reporting back with some profiling data where I just tested this, we saw 36% of total request time on a dynamic page cache hit on 11.3 in CacheContextsManager::convertTokensToKeys(), this patch reduced it to 26%, in absolute numbers it went from 103 ms → 55.6 ms. total responses vary quite a bit, so don't put too much value in the specific numbers.
Notes:
* This was tested on an upsun development environment, so not the fastest/most stable environment.
* with blackfire on, obviously, which causes significant overhead, total execution time with blackfire was 210ms with the patch, without blackfire it goes down to ~120ms, so about twice as fast.
* there are multiple expensive cache contexts involved, such as commerce cart ( just created #3584409: Cache result of query in loadCartData, around 5%), a custom one based on group to figure out the active group from the URL (also around 5% of total request time, I could probably figure out a way to optimize this away for the dynamic page cache hit, it depends on the path), also seeing the theme and language contexts. Specific numbers vary a lot, language was 2.2% in one request, .7% in the next.
Comment #43
dries commented@catch: Yes, sorry about that! Too many tabs open.