Proposed commit message:
Issue #2429617 by Wim Leers, Berdir, epari.siva, martin107, Fabianx, yched, MiSc, dawehner: Make D8 2x as fast: SmartCache: context-dependent page caching (for *all* users!)TL;DR
SmartCache in a nutshell:
- cache the
CacheableResponseInterfaceobject (which in case of aHtmlResponsestill has the#attached[placeholders; those are replaced at a later time, byHtmlResponseAttachmentsProcessor) - return that cached response immediately after routing has taken place, hence avoiding the controller service (and its dependencies) being initialized, along with everything else for building the response.
The resulting performance improvement as user 1 on the frontpage (APCu off, OpCache on, PHP 5.5, XDebug off, XHProf on):
| Run #frontpage-before | Run #frontpage-after | Diff | Diff% | |
|---|---|---|---|---|
| Number of Function Calls | 31,794 | 16,749 | -15,045 | -47.3% |
| Incl. Wall Time (microsec) | 89,291 | 57,922 | -31,369 | -35.1% |
| Incl. MemUse (bytes) | 17,050,176 | 13,120,368 | -3,929,808 | -23.0% |
| Incl. PeakMemUse (bytes) | 17,101,080 | 13,153,056 | -3,948,024 | -23.1% |

(See #205 for details.)
Problem/Motivation
- We want D8's authenticated user page loads to be fast.
- Some parts of the page are cacheable across users. To actually cache that across users, we have #2429617: Make D8 2x as fast: SmartCache: context-dependent page caching (for *all* users!).
- Other parts need to be dynamically calculated per user, or are simply uncacheable.
- Those dynamic parts should not prevent us from showing the rest of the page first.
(Drupal 8's anonymous user page loads already are fast since #606840: Enable internal page cache by default.)
We're render caching bits and pieces (blocks & entities), but we're still running expensive controllers to build the main content array. And we're initializing dozens and dozens of services that we barely use. It's getting more difficult to see what to optimize next.
Drupal has historically always generated the majority of the response dynamically for every request.
But we've been doing massive amounts of work to make things more cacheable. Could we make use that work to break the trend, and make Drupal 8 the first release to generate a minority of the response dynamically for every request?
Proposed resolution: the simplified version
Assumption: everything that depends on some (request) context, specifies a cache context. #2429287: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance has made sure that this is implemented consistently (and tested) for the 99% use cases.
- On a cache miss
- A
KernelEvents::RESPONSEevent subscriber detects whether it's aCacheableResponseInterfaceobject, i.e. a response with cacheability metadata that SmartCache can therefore safely cache. This an object representing the entire response. In case of aHtmlResponse, it still has the placeholders (that need to be replaced on every request, i.e. dynamically, uncacheable). - The result is that we therefore have the response that is cacheable across all users, because we know which cache contexts are associated. We use the
RenderCacheclass that is capable of handling cache redirects (which is in fact based on early versions of the SmartCache patch), and ask it to cache the response per route, and if necessary, perform cache redirection. - The event subscriber ends. Then the response is finished and sent as usual. (The other event subscribers fire, including in case of a
HtmlResponsetheHtmlResponseSubscriber, which callsHtmlResponseAttachmentsProcessor, which does all the final rendering (just like for any other request): it renders the attached placeholders, bubbles the bubbleable metadata from the placeholders, and given that final set of attachments, it is able to render the CSS/JS assets, plus HTML<head>tags + HTTP headers.)
- A
- On a cache hit
- We've already done the above, and now we're hitting the same route again.
- Immediately after routing, before even calling the controller (the thing that would otherwise do DB queries and whatnot to build a render array), SmartCache's event subscriber checks if we have a cache item in the
smart_cachecache bin for the current route, following any redirects if necessary. - If such a cache item (including potential cache redirect) exists, then the response for this route is already cached. Hence the controller never needs to be executed, and all that still needs to happen (in case of a response other than
HtmlResponse, otherwise we're done already), is processing the attachments! (Which includes rendering the placeholders.) This is handled automatically; just like for anyHtmlResponse.
How is this related to the BigPipe issue?
The BigPipe issue: #2469431: BigPipe for auth users: first send+render the cheap parts of the page, then the expensive parts.
Basically, SmartCache doesn't care about things that are dynamic (max-age = 0 or cache contexts that we consider "too dynamic to cache", such as "per user"). It's very simple, dumb and stupid. It simply varies by all cache contexts on the page.
So, if e.g.:
/foovaries only by interface language, then SmartCache's entry for that will also vary by interface language/barvaries by interface language, route, permissions, query parameter, user and whatnot, then the SmartCache entry will also vary by all those things
That's where the intersection with BigPipe begins.
BigPipe allows us to NOT have the bits that are "too dynamic" to affect the overall page: it allows us to NOT bubble up the "per user" cache context, for example. Because we replace it with a placeholder, and render it separately.
Proposed resolution
- See the simplified proposed resolution above.
- Read the issue summary at #2429287: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance.
Given what you've just read in #2429287, you may now realize that once:
- Cache contexts are defined by everything, as they should be (i.e.
once that meta is completedsince #2429287: [meta] Finalize the cache contexts API & DX/usage, enable a leap forward in performance is 99% done, which it now is) - Cache context bubbling is implemented (child of the meta: #2429257: Bubble cache contexts)
- All uncacheable things use placeholders (just like: node links, comment links, the comment form on entities, and more)
… then we can start to put all of that cache metadata to the originally intended powerful use: cache the entire HtmlResponse, varied by the contexts associated with that route!
(And once we have that, we can go even further! We could make it configurable which cache contexts (high-frequency ones, e.g. "per user") we don't actually want to vary by, which we could then automatically replace with placeholders. We could then run replace those placeholders either when rendering a response (like today), replace them using something like Facebook's BigPipe, or replace them using ESI. It would be configurable. We have an issue now for auto-placeholdering: #2499157: [meta] Auto-placeholdering.)
A final note: SmartCache works post-routing and caches per-route. You may wonder: why not pre-routing and caching per-URL?
— see #219 for a detailed answer. But in short: because the SmartCache architecture is based on cache contexts, and several cache contexts are not available pre-routing/pre-bootstrap (user, user.permissions, user.*, route, and likely more). Which means SmartCache cannot work pre-routing.
SmartCache reuses the logic in RenderCache, at the cost of some (isolated!) ugliness because RenderCache only knows how to deal with render arrays. The benefit of this reuse is that we don't duplicate the logic, and can just depend on the existing comprehensive test coverage.
Algorithm, proofs & next steps — comments wanted!
This issue corresponds to step 1 in the Google Doc: https://docs.google.com/document/d/1Gw7ohBOUKu38t4kMbN9zj6cX-4-_2ZNXGotv...
(We will move this onto Drupal.org once it's 100% fleshed out.)
Remaining tasks
None!
When this gets committed, please also credit Fabianx.
User interface changes
None.
API changes
None. This is a pure addition; that doesn't change any APIs.
But, notable changes:
- Forms are now marked as uncacheable (
max-age = 0) by default. - The REQUEST event subscriber
ContentControllerSubscriber::onRequestDeriveFormWrapper()has a different priority, because it was impossible to inject an event subscriber at the desired place (i.e. to inject a new event subscriber in between). HtmlResponseAttachmentsProcessornow renders the attached placeholders (#attached[placeholders]). Which means finally all attachments are truly processed in theAttachmentsResponseProcessorInterfaceimplementation for HTML responses.
This meansHtmlRendererno longer needs to render attached placeholders. Which meansHtmlResponseobjects are now perfectly cacheable, because they don't contain anything user-specific.
(This is not an API change, just an implementation detail change.)MainContentViewSubscribernow adds the'url.query_args:' . MainContentViewSubscriber::WRAPPER_FORMATcache context to the response when necessary.
And a few small bugfixes, mostly small remnants of things that were fixed in blocking child issues, but don't really make sense to fix separately because of process overhead:
TestAccessBlock::blockAccess()specifies max-age=0 because it depends on state.PageCacheTestinvalidates therenderedcache tag rather than deleting everything in therendercache bin.PathAliasTestinvalidates therenderedcache tag because it is expecting path alias changes to show up immediately, despite there still being an open issue (#2480077: Using the URL alias UI to change aliases doesn't do necessary invalidations) to make path aliases handle cache invalidation correctly. So, this is a temporary fix, with a @todo added.- The
ConfigCacheTagconfig save event subscriber was only invalidating therenderedcache tag forsystem.theme.global, it also needs to do it forsystem.theme. - The
system.db_updateroute now has theno_cache: TRUEoption (because it never makes sense to cache, and without that, SmartCache would cache it). TestControllers::testEntityLanguage()'s render array was failing to pass on cacheability metadata.tracker.pages.incdid not yet add thecomment_listcache tag anduser.roles:authenticatedcache context, which caused the Tracker to be cached incorrectly by SmartCache (not being invalidated when it should've been).
Credit
HUGE HUGE HUGE thanks to Fabianx, who's helped me enormously in verifying that this actually works! Without him, this issue would be much vaguer. Plus, once this is in, he has amazing ideas for further improvements, he even sees a way to make it work without executing a request at all — resulting in 10–20 ms response times! See the aforementioned Google Doc, steps 2 & 3, for details.