Problem/Motivation

JavaScript and CSS asset processing takes around 20ms on my machine with the standard profile.

With Drupal 7, we mostly dealt with individual files - this led to lots of different potential combinations of files, but very little metadata for each file.

With Drupal 8, everything is libraries. Which means the list of libraries and their ordering is predictable, but more metadata is required on every request.

On my machine I see ~10ms for CSS assets, ~8ms for Js assets and ~4.5ms for Js settings assets.

With the patch applied, it saves 4,500 function calls

Proposed resolution

Add caching to AssetResolver::getCssAssets() and AssetResolver::getJsAssets().

Given the same list of assets, we can assume that the sorting etc. is going to be the same. If the list of assets changes, so will the cache ID.

As well as saving loading metadata and sorting, this also saves file_exists() calls for aggregates with warm caches. Given page cache or reverse proxy request don't check for file existence, this ought to be OK, although #1014086: Stampedes and cold cache performance issues with css/js aggregation would improve things on cache misses and make things more robust overall.

Remaining tasks

Attaching a patch that adds dumb caching to this. Needs cache service injected. Needs cache tags.

Also system_js_settings_alter() will be broken because that adds per-page/per-user settings changes. We might need to take the alter out of the caching there.

Would be good to verify just how much this saves for performance before going to far with the patch, but removing 4,500 function calls seems promising.

User interface changes

API changes

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

catch’s picture

Status: Needs review » Needs work

The last submitted patch, asset_resolver.patch, failed testing.

Wim Leers’s picture

Issue tags: +D8 cacheability

+1 on concept, this is exactly what I've been wanting to get to ever since working on #2381473: Decrease the size of the asset library cache item at DDD Montpellier.

Will review ASAP.

catch’s picture

Priority: Major » Critical

So problems with this:

1. locale_js_alter() - this has to run per-language. We could probably add the current language to the cache key though. Then document that the results of the hook are cached.

2. hook_js_settings_alter() - this has to be per-request unless we change the API quite a lot. system_js_settings_alter() adds per-path things, user_js_settings_alter() adds per-user things. Per-user could be shifted client-side by using AJAX/local storage and updating settings based on that. system_js_settings_alter() is a complete mess but I don't see a way to avoid running this and fill things in client-side - or at least it can't be AJAX/client side caching so it'd have to be markup added elsewhere then moved into drupalSettings by js, which is worse than just putting it in drupalSettings.

On the plus side, hook_js_settings_alter() is not the big saving here.

Also note that #1014086: Stampedes and cold cache performance issues with css/js aggregation helps with the optimizer side of this, but even if we do that issue, there are still significant separate savings to be made here.

Since this is 20ms and is likely to involve at least some API change, bumping to critical for now.

dawehner’s picture

1. locale_js_alter() - this has to run per-language. We could probably add the current language to the cache key though. Then document that the results of the hook are cached.

I'm curious whether we could have a build time hook as well as a runtime hook, so that we can save what we need but still have the flexility, for example for system_js_settings_alter()/

I've seen damian complaining recently about too much time needed there as well.

Wim Leers’s picture

Can't we just not cache getJsSettingsAssets()? Precisely because it is dynamic, it doesn't make a whole lot of sense to cache it.

RE: locale_js_alter(): once again, what we're missing is… cacheability metadata :) Alternate suggestion: make \Drupal\Core\Asset\AttachedAssetsInterface implement \Drupal\Core\Cache\CacheableDependencyInterface, and give it setters to indicate that it should vary by language.
Of course, that opens the door for more such dependencies. But it also opens the door towards configurable overrides (i.e. some module has Config to override a certain asset, then we need the corresponding cache tag to be associated).
Note that I'm fine with hardcoding per-language — I'm just pointing out that there is a more general solution.

Wim Leers’s picture

Title: Cache CSS / Js Asset processing » Cache CSS/JS asset resolving
catch’s picture

Status: Needs work » Needs review
FileSize
4.48 KB

RE: locale_js_alter(): once again, what we're missing is… cacheability metadata :) Alternate suggestion: make \Drupal\Core\Asset\AttachedAssetsInterface implement \Drupal\Core\Cache\CacheableDependencyInterface, and give it setters to indicate that it should vary by language.
Of course, that opens the door for more such dependencies. But it also opens the door towards configurable overrides (i.e. some module has Config to override a certain asset, then we need the corresponding cache tag to be associated).
Note that I'm fine with hardcoding per-language — I'm just pointing out that there is a more general solution.

So it would be nice to be able to do this, but given locale_js_alter() adds the language dependency in the alter hook, we'd need to return the cacheability metadata there. And that then would mean we'd need to support not only cache contexts but cache redirection. I think that would be a useful capability to add outside the render API, but not sure we need it here.

Also, I found this comment from you on another issue #2421323-2: Parse js files from library definitions during rebuild to minimize variable_set() calls which firmed up my belief that hard-coding language is OK.

Attaching a new patch - adds 'library_info' cache tags, took caching out of getJsSettings (although I think we may need to cache part of that to get a full benefit here - will tr to add once the number of fails has gone down a bit).

Status: Needs review » Needs work

The last submitted patch, 8: asset_resolver.patch, failed testing.

Wim Leers’s picture

Sounds good!

catch’s picture

Status: Needs work » Needs review
FileSize
15.21 KB

Some chance this is a green patch.

Everything gets cached except the js settings alter hooks more or less, and we save the separate settings cache entry by re-organising the code slightly.

Haven't repeated profiling yet - will do that if tests pass though.

Status: Needs review » Needs work

The last submitted patch, 11: asset_resolver.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
14.43 KB

Trying without the debug.

Status: Needs review » Needs work

The last submitted patch, 13: asset_resolver.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
15.23 KB
catch’s picture

system_js_settings_alter() now runs uncached again. The most expensive thing it does is LibraryDependencyResolver::getMinimalRepresentativeSubset() (~3ms) - this could be moved into the cached case somehow.

With the current patch (empty front page, user/1) here's an xhprof diff:

Run #558024d442307 Run #558024ae4daba Diff Diff%
Number of Function Calls 31,549 28,051 -3,498 -11.1%
Incl. Wall Time (microsec) 205,202 177,562 -27,640 -13.5%
Incl. CPU (microsecs) 193,483 164,438 -29,045 -15.0%
Incl. MemUse (bytes) 18,979,216 18,854,896 -124,320 -0.7%
Incl. PeakMemUse (bytes) 19,130,040 18,925,536 -204,504 -1.1%
catch’s picture

FileSize
20.7 KB
6.78 KB

Adding hook_js_settings_build() and moving half of system_js_settings_alter() into there.

If this passes, this gets us green and -4,400 function calls. Original proof of concept that didn't actually work was ~4,500 so I think this is as much saving as we can get.

Run #558024d442307 Run #55802a7a66f41 Diff Diff%
Number of Function Calls 31,549 27,139 -4,410 -14.0%
Incl. Wall Time (microsec) 205,202 195,403 -9,799 -4.8%
Incl. CPU (microsecs) 193,483 182,784 -10,699 -5.5%
Incl. MemUse (bytes) 18,979,216 19,452,912 473,696 2.5%
Incl. PeakMemUse (bytes) 19,130,040 19,523,488 393,448 2.1%
dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +215,140 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +    $cid = 'js:' . $theme_info->getName() . ':' . $this->languageManager->getCurrentLanguage()->getId() . ':' .  Crypt::hashBase64(serialize($assets));
    

    is there a reason why we not just call out to sha512 from the beginning?

  2. +++ b/core/modules/system/system.module
    @@ -607,9 +607,47 @@ function system_page_attachments(array &$page) {
    +    // Checks that the DB is available before filling theme_token.
    +    if (!defined('MAINTENANCE_MODE')) {
    +      $settings['ajaxPageState']['theme_token'] = \Drupal::csrfToken()->get($theme_key);
    +    }
    

    I guess this needs to be dynamic, given that this even depends on the session ...

catch’s picture

FileSize
20.66 KB

is there a reason why we not just call out to sha512 from the beginning?

Do you mean hashing more parts of the cid? It can be easier for debugging to have the theme/language outside the hash.

Fixing #2. I'm 99.9% certain the MAINTENANCE_MODE check is no longer necessary, but yes good catch that depends on session. Moved back to the alter.

Wim Leers’s picture

The patch looks splendid :)

  1. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -92,6 +115,10 @@ protected function getLibrariesToLoad(AttachedAssetsInterface $assets) {
    +    $cid = 'css:' . $theme_info->getName() . ':' . Crypt::hashBase64(serialize($assets)) . (int) $optimize;
    

    We should document why the theme needs to be part of the cache key.

  2. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +215,140 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +    $cid = 'js:' . $theme_info->getName() . ':' . $this->languageManager->getCurrentLanguage()->getId() . ':' .  Crypt::hashBase64(serialize($assets));
    

    We should document why the theme and current language need to be part of the cache key.

  3. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +215,140 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +      $settings_required = in_array('core/drupalSettings', $libraries_to_load) || in_array('core/drupalSettings', $this->libraryDependencyResolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()));
    +      $settings_have_changed = count($libraries_to_load) > 0 || count($assets->getSettings()) > 0;
    +      $settings_needed = $settings_required && $settings_have_changed;
    ...
    +      if ($settings_have_changed && $settings_needed) {
    

    You've changed the logic a bit here, and I think you have a small mistake: $settings_needed implies $settings_have_changed, so you only need to include $settings_needed in the if-test.

  4. +++ b/core/lib/Drupal/Core/Render/theme.api.php
    @@ -826,6 +826,28 @@ function hook_library_info_build() {
    +function hook_js_settings_build(array &$settings, \Drupal\Core\Asset\AttachedAssetsInterface $assets) {
    +
    +  // Manipulate settings.
    

    Weird newline.

Status: Needs review » Needs work

The last submitted patch, 19: asset_resolver.patch, failed testing.

catch’s picture

Status: Needs work » Needs review
FileSize
3.81 KB
20.96 KB

Fixes #1, #2, #3 and #4 from #20. #4 was cruft from a previous approach to caching the asset settings, but I do think we should slightly rename the variables there so kept that bit.

And apparently we do need to check maintenance mode after all.

dawehner’s picture

I think we should explicitly update also hook_js_alter() / hook_css_alter() and document that this is no longer request aware.

Fixing #2. I'm 99.9% certain the MAINTENANCE_MODE check is no longer necessary, but yes good catch that depends on session. Moved back to the alter.

If I understand correctly this is about ensuring that the right theme is used for ajax requests ...

Status: Needs review » Needs work

The last submitted patch, 22: asset_resolver.patch, failed testing.

Wim Leers’s picture

The failure in CKEditorLoadingTest points to a likely bug in drupalSettings, this is the failing line:

isset($settings['ajaxPageState']) && in_array('ckeditor/drupal.ckeditor', explode(',', $settings['ajaxPageState']['libraries'])),

The other failures seem similar.

dawehner’s picture

The failures are interesting, looking at them atm.

dawehner’s picture

FileSize
1.95 KB
1.08 KB

The problem is with that patch we add the theme_token to normal requests, unless before. This itself should not be a problem, so let's fix our code against unsane checks ...
the second interdiff just fixes the problem mentioned there ...

dawehner’s picture

Status: Needs work » Needs review
FileSize
24.95 KB
5.09 KB

Alright, so this patch fixes first all the failures with the approach of interdiff-1.txt and then apply interdiff-2.txt on top of it

Wim Leers’s picture

  1. +++ b/core/modules/system/system.module
    @@ -607,9 +607,43 @@ function system_page_attachments(array &$page) {
    - * Sets values for the core/drupalSettings and core/drupal.ajax libraries.
    + * Sets values for the core/drupalSettings library.
    

    Given that theme_token is only added for the drupal/ajax library, this change seems wrong?

    The majority of the work is now done in system_js_settings_builder(), but the session-dependent values are still handled here. Perhaps we should describe it that way ([…] and session-dependent values for the core/drupal.ajax library.)?

  2. +++ b/core/modules/system/system.module
    @@ -647,36 +681,15 @@ function system_js_settings_alter(&$settings, AttachedAssetsInterface $assets) {
    +  // Add the theme token to ajaxPageState, ensuring the database is available
    +  // before doing so.
       /** @var \Drupal\Core\Asset\LibraryDependencyResolver $library_dependency_resolver */
       $library_dependency_resolver = \Drupal::service('library.dependency_resolver');
       if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()))) {
    -    // Provide the page with information about the theme that's used, so that
    -    // a later AJAX request can be rendered using the same theme.
    -    // @see \Drupal\Core\Theme\AjaxBasePageNegotiator
    

    I think the deleted comment contains important information that we now lost?

Wim Leers’s picture

Status: Needs review » Needs work

Left it at NR for more reviews, but I think this looks at the very least close to RTBC otherwise.

NW so we can get those details fixed, and then hopefully RTBC.

dawehner’s picture

Status: Needs work » Needs review
FileSize
25.09 KB
1.1 KB

Thank you for your review!

The majority of the work is now done in system_js_settings_builder(), but the session-dependent values are still handled here. Perhaps we should describe it that way ([…] and session-dependent values for the core/drupal.ajax library.)?

Tried to improve the documentation to be clear, what depends on the current request and what not.

I think the deleted comment contains important information that we now lost?

Well, those bits are now in system_js_settings_build(), aren't they?

Wim Leers’s picture

Status: Needs review » Needs work
  1. +++ b/core/modules/system/system.module
    @@ -609,7 +609,8 @@ function system_page_attachments(array &$page) {
    + * active theme but no other request dependent values.
    

    Nit: s/request dependent/request-dependent/

  2. +++ b/core/modules/system/system.module
    @@ -643,7 +644,8 @@ function system_js_settings_build(&$settings, AttachedAssetsInterface $assets) {
    + * as well as theme_token ajax state.
    

    and core/drupal.ajax' 'theme_token' ajaxPageState.

  3. RE: "those bits are": Oops, you're right :) Sorry!

Another review round.

  1. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +217,142 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +      // The current list of header JS libraries are only those libraries that are
    

    80 cols.

  2. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +217,142 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +            // 'scope' is a calculated option, based on which libraries are marked
    

    80 cols.

  3. +++ b/core/lib/Drupal/Core/Asset/AssetResolver.php
    @@ -191,115 +217,142 @@ protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
    +      // If the core/drupalSettings library is being loaded or is already loaded,
    

    80 cols.

  4. +++ b/core/lib/Drupal/Core/Render/theme.api.php
    @@ -826,6 +826,27 @@ function hook_library_info_build() {
    + * Add to the JavaScript settings (drupalSettings).
    

    "Add"? Shouldn't it be "Modify"?


After this one, it will really be RTBC.

dawehner’s picture

Status: Needs work » Needs review
FileSize
25.09 KB
4.04 KB

Thank you for review again.

"Add"? Shouldn't it be "Modify"?

Agreed.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
Related issues: +#2381473: Decrease the size of the asset library cache item

The conclusion of #2381473, at the end of #2381473-37: Decrease the size of the asset library cache item, is precisely what this patch does: cache at a higher level. We could choose to remove the asset library cache entirely and only keep this cache. But doing that can be a follow-up, and can be what we repurpose #2381473 for, after this lands.

Great patch. Thanks!

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

This issue addresses a critical bug and is allowed per https://www.drupal.org/core/beta-changes. Committed 5d3a761 and pushed to 8.0.x. Thanks!

  • alexpott committed 5d3a761 on 8.0.x
    Issue #2506369 by catch, dawehner, Wim Leers: Cache CSS/JS asset...
Wim Leers’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

Wim Leers’s picture

$cid = 'css:' . $theme_info->getName() . ':' . Crypt::hashBase64(serialize($assets)) . (int) $optimize;

Note that $assets includes JS settings. So it'll actually vary by user, even though CSS assets don't care about the JS settings asset.

We should also fix the JS assets caching, but that one will be trickier.

Fabianx’s picture

A good way to fix the JS assets caching is to split this up in two cache entries:

a) Generic libraries + settings --- shared across all users // just would need to change getJsSettingsAssets() to not merge the assets settings
b) Attached settings + hook_js_settings_build result

Unfortunately it would be a behavior change to have hook_js_settings_build() operate on the pure libraries definition without any dynamic parts - though that would be the correct fix.

Then the code would - except for changing the cid to include only $attachments->getLibrariesToLoad() instead of the whole attachments - not need changing at all.

Maybe however that is how hook_js_settings_build() was thought of and we _should_ do this even before release?

Fabianx’s picture

Per #40 I opened #2603138: CSS/JS asset caching can easily be trashed as critical follow-up (justification in the issue).

Wim Leers’s picture

Thanks for filing that issue!