diff --git a/core/lib/Drupal/Core/Cache/CacheableMetadata.php b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
index 0a22f56..1bd01a5 100644
--- a/core/lib/Drupal/Core/Cache/CacheableMetadata.php
+++ b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
@@ -140,9 +140,38 @@ public function setCacheMaxAge($max_age) {
    */
   public function merge(CacheableMetadata $other) {
     $result = new static();
-    $result->contexts = Cache::mergeContexts($this->contexts, $other->contexts);
-    $result->tags = Cache::mergeTags($this->tags, $other->tags);
-    $result->maxAge = Cache::mergeMaxAges($this->maxAge, $other->maxAge);
+
+    // This is called many times per request, so avoid merging unless absolutely
+    // necessary.
+    if (empty($this->contexts)) {
+      $result->contexts = $other->contexts;
+    }
+    elseif (empty($other->contexts)) {
+      $result->contexts = $this->contexts;
+    }
+    else {
+      $result->contexts = Cache::mergeContexts($this->contexts, $other->contexts);
+    }
+
+    if (empty($this->tags)) {
+      $result->tags = $other->tags;
+    }
+    elseif (empty($other->tags)) {
+      $result->tags = $this->tags;
+    }
+    else {
+      $result->tags = Cache::mergeTags($this->tags, $other->tags);
+    }
+
+    if ($this->maxAge === Cache::PERMANENT) {
+      $result->maxAge = $other->maxAge;
+    }
+    elseif ($other->maxAge === Cache::PERMANENT) {
+      $result->maxAge = $this->maxAge;
+    }
+    else {
+      $result->maxAge = Cache::mergeMaxAges($this->maxAge, $other->maxAge);
+    }
     return $result;
   }
 
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index 93a9310..7951a54 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -42,9 +42,29 @@ class BubbleableMetadata extends CacheableMetadata {
    */
   public function merge(CacheableMetadata $other) {
     $result = parent::merge($other);
+
+    // This is called many times per request, so avoid merging unless absolutely
+    // necessary.
     if ($other instanceof BubbleableMetadata) {
-      $result->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $other->attached);
-      $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
+      if (empty($this->attached)) {
+        $result->attached = $other->attached;
+      }
+      elseif (empty($other->attached)) {
+        $result->attached = $this->attached;
+      }
+      else {
+        $result->attached = static::mergeAttachments($this->attached, $other->attached);
+      }
+
+      if (empty($this->postRenderCache)) {
+        $result->postRenderCache = $other->postRenderCache;
+      }
+      elseif (empty($other->postRenderCache)) {
+        $result->postRenderCache = $this->postRenderCache;
+      }
+      else {
+        $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
+      }
     }
     return $result;
   }
@@ -151,4 +171,65 @@ public function setPostRenderCacheCallbacks(array $post_render_cache_callbacks)
     return $this;
   }
 
+  /**
+   * Merges two attachments arrays (which live under the '#attached' key).
+   *
+   * The values under the 'drupalSettings' key are merged in a special way, to
+   * match the behavior of:
+   *
+   * @code
+   *   jQuery.extend(true, {}, $settings_items[0], $settings_items[1], ...)
+   * @endcode
+   *
+   * This means integer indices are preserved just like string indices are,
+   * rather than re-indexed as is common in PHP array merging.
+   *
+   * Example:
+   * @code
+   * function module1_page_attachments(&$page) {
+   *   $page['a']['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
+   * }
+   * function module2_page_attachments(&$page) {
+   *   $page['#attached']['drupalSettings']['foo'] = ['d'];
+   * }
+   * // When the page is rendered after the above code, and the browser runs the
+   * // resulting <SCRIPT> tags, the value of drupalSettings.foo is
+   * // ['d', 'b', 'c'], not ['a', 'b', 'c', 'd'].
+   * @endcode
+   *
+   * By following jQuery.extend() merge logic rather than common PHP array merge
+   * logic, the following are ensured:
+   * - Attaching JavaScript settings is idempotent: attaching the same settings
+   *   twice does not change the output sent to the browser.
+   * - If pieces of the page are rendered in separate PHP requests and the
+   *   returned settings are merged by JavaScript, the resulting settings are
+   *   the same as if rendered in one PHP request and merged by PHP.
+   *
+   * @param array $a
+   *   An attachments array.
+   * @param array $b
+   *   Another attachments array.
+   *
+   * @return array
+   *   The merged attachments array.
+   */
+  public static function mergeAttachments(array $a, array $b) {
+    // If both #attached arrays contain drupalSettings, then merge them
+    // correctly; adding the same settings multiple times needs to behave
+    // idempotently.
+    if (!empty($a['drupalSettings']) && !empty($b['drupalSettings'])) {
+      $drupalSettings = NestedArray::mergeDeepArray(array($a['drupalSettings'], $b['drupalSettings']), TRUE);
+      // No need for re-merging them.
+      unset($a['drupalSettings']);
+      unset($b['drupalSettings']);
+    }
+    // Apply the normal merge.
+    $a = array_merge_recursive($a, $b);
+    if (isset($drupalSettings)) {
+      // Save the custom merge for the drupalSettings.
+      $a['drupalSettings'] = $drupalSettings;
+    }
+    return $a;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 9ba7613..28915da 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -514,28 +514,6 @@ public function addCacheableDependency(array &$elements, $dependency) {
   /**
    * {@inheritdoc}
    */
-  public function mergeAttachments(array $a, array $b) {
-    // If both #attached arrays contain drupalSettings, then merge them
-    // correctly; adding the same settings multiple times needs to behave
-    // idempotently.
-    if (!empty($a['drupalSettings']) && !empty($b['drupalSettings'])) {
-      $drupalSettings = NestedArray::mergeDeepArray(array($a['drupalSettings'], $b['drupalSettings']), TRUE);
-      // No need for re-merging them.
-      unset($a['drupalSettings']);
-      unset($b['drupalSettings']);
-    }
-    // Apply the normal merge.
-    $a = array_merge_recursive($a, $b);
-    if (isset($drupalSettings)) {
-      // Save the custom merge for the drupalSettings.
-      $a['drupalSettings'] = $drupalSettings;
-    }
-    return $a;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
   public function generateCachePlaceholder($callback, array &$context) {
     if (is_string($callback) && strpos($callback, '::') === FALSE) {
       $callable = $this->controllerResolver->getControllerFromDefinition($callback);
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index d407f0f..4aa5f9f 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -347,50 +347,6 @@ public function mergeBubbleableMetadata(array $a, array $b);
   public function addCacheableDependency(array &$elements, $dependency);
 
   /**
-   * Merges two attachments arrays (which live under the '#attached' key).
-   *
-   * The values under the 'drupalSettings' key are merged in a special way, to
-   * match the behavior of:
-   *
-   * @code
-   *   jQuery.extend(true, {}, $settings_items[0], $settings_items[1], ...)
-   * @endcode
-   *
-   * This means integer indices are preserved just like string indices are,
-   * rather than re-indexed as is common in PHP array merging.
-   *
-   * Example:
-   * @code
-   * function module1_page_attachments(&$page) {
-   *   $page['a']['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
-   * }
-   * function module2_page_attachments(&$page) {
-   *   $page['#attached']['drupalSettings']['foo'] = ['d'];
-   * }
-   * // When the page is rendered after the above code, and the browser runs the
-   * // resulting <SCRIPT> tags, the value of drupalSettings.foo is
-   * // ['d', 'b', 'c'], not ['a', 'b', 'c', 'd'].
-   * @endcode
-   *
-   * By following jQuery.extend() merge logic rather than common PHP array merge
-   * logic, the following are ensured:
-   * - Attaching JavaScript settings is idempotent: attaching the same settings
-   *   twice does not change the output sent to the browser.
-   * - If pieces of the page are rendered in separate PHP requests and the
-   *   returned settings are merged by JavaScript, the resulting settings are
-   *   the same as if rendered in one PHP request and merged by PHP.
-   *
-   * @param array $a
-   *   An attachments array.
-   * @param array $b
-   *   Another attachments array.
-   *
-   * @return array
-   *   The merged attachments array.
-   */
-  public function mergeAttachments(array $a, array $b);
-
-  /**
    * Generates a render cache placeholder.
    *
    * This can be used to generate placeholders, and hence should also be used by
