Skip to content
This repository was archived by the owner on Dec 3, 2020. It is now read-only.
This repository was archived by the owner on Dec 3, 2020. It is now read-only.

Make Fathom extraction more performant #319

@biancadanforth

Description

@biancadanforth

TL;DR: isVisible, which will likely be a common rule in many Fathom applications, accounts for the majority of the 460 ms of Fathom-related jank (67%). It may be possible to reduce this overall Fathom jank by as much as 374 ms (81%) by reducing style and DOM property accesses in isVisible, but this solution requires a privileged context.

Is this a feature request or a bug?

Feature request

What is the current behavior?

Fathom currently causes considerable jank on page load. Given that Price Tracker was an experiment, performance was not a major consideration in development of the current ruleset used to extract a product from a page.

Initial profile

Initial profile[1]

Numbers

  • The largest unresponsive chunk is caused by Fathom extraction, contributing 460 ms of jank right around page load.
  • isVisible calls Fathom’s ancestors function and a bunch of CSS2Properties getters, altogether taking up 311ms (67%) of this jank.

Screen Shot 2019-07-16 at 8 33 26 PM

isVisible(fnode) {
  for (const ancestor of ancestors(fnode.element)) {
    const style = getComputedStyle(ancestor);
    const isElementHidden = (
      style.visibility === 'hidden'
      || style.display === 'none'
      || style.opacity === '0'
      || style.width === '0'
      || style.height === '0'
    );
    if (isElementHidden) {
      return false;
    }
  }
  return true;
}
/**
 * Yield an element and each of its ancestors.
 */
export function *ancestors(element) {
    yield element;
    let parent;
    while ((parent = element.parentNode) !== null && parent.nodeType === parent.ELEMENT_NODE) {
        yield parent;
        element = parent;
    }
}

What is the expected or desired behavior?

Fathom should be able to run much faster with much less jank.

I shared this profile with some Firefox engineers, including a layout (Emilio) and a performance engineer (Greg).

Why is isVisible taking so much time?

  • There is a known layout performance bug that causes getComputedStyle() on a display: none subtree to be very slow.
    • Fix: Avoid checking display: none.
  • For each element, we are walking the ancestor chain. This means we are doing redundant work for many elements (e.g. for n visible descendants of <html>, we will check <html>'s styles n times).
    • Fix: Mitigate redundant work and/or memoize isVisible result (e.g. in an HTML element => Boolean Map or a note()).
  • getComputedStyle triggers a layout flush (not a natural layout flush).
    • Fix: Use the cheapest style accesses first (e.g. getBoundingClientRect) and early return when possible. After a flush, some style accesses are O(1).
    • Fix: Leverage a natural flush rather than triggering a new flush using the Intersection Observer API for unprivileged and/or promiseDocumentFlushed for privileged code.

Updating isVisible: first attempt

Revised profile: first attempt[1]

This focuses on the fix of using the cheapest style accesses first (e.g. getBoundingClientRect) and early return when possible in isVisible.

Numbers

  • The largest unresponsive chunk is still caused by Fathom extraction, contributing 386 ms of jank right around page load.
  • isVisible made up 244ms (63%) of this jank.
  • This change reduced overall Fathom-related jank by 74 ms (16%) compared to the original implementation of isVisible.
// Avoid reading `display: none` due to Bug 1381071
isVisible(fnode) {
  const element = fnode.element;
  if (element.getBoxQuads().length === 0) {
    // The element has no box (display: none subtree?), checking
    // getBoundingClientRect instead for a width and height of 0 only tells
    // us it is a zero-sized box.
    return false;
  }
  const eleStyle = getComputedStyle(element);
  if (eleStyle.visibility === 'hidden') {
    return false; // not painted.
  }
  // This is assuming inclusive ancestors, otherwise we need to check opacity
  // above too.
  for (const ancestor of ancestors(element)) {
    const style = getComputedStyle(ancestor);
    if (style.opacity === '0') {
      return false;
    }
    if (style.display === 'contents') {
      // display: contents elements have no box themselves, but children are
      // still rendered.
      continue;
    }
    const rect = ancestor.getBoundingClientRect();
    if ((rect.width === 0 || rect.height === 0) && style.overflow === 'hidden') {
      // This is a zero-sized box; zero-sized ancestors don’t make descendants
      // hidden unless the descendant has overflow: hidden
      return false;
    }
  }
  return true;
}

Conclusions

  • While this removed much of the layout querying work, the proportion of jank caused by style accesses was smaller than expected, given the known performance Bug 1381071.
  • Now isVisible is spending most of its time (roughly 62%) accessing properties through X-ray vision. We can inspect this in the profile by filtering for Native code for "js::NativeLookupOwnProperty", "XRayTraits" or "WeakMap".
    • Fix: Spend less time accessing DOM properties. Consider simply checking whether the element is clickable. This requires more work from the browser, but less complexity in isVisible.

Updating isVisible: second attempt

Revised profile: second attempt[1]

This focuses on the fix of mitigating (actually, eliminating) redundant work, removing ancestors entirely to eliminate DOM accesses, and reducing the number of style accesses by checking if the element is clickable.

Numbers

  • The largest unresponsive chunk is no longer caused by Fathom extraction; Fathom extraction contributes only 89 ms of jank right around page load.
  • isVisible made up 30 ms (34%) of this jank.
  • This change reduced overall Fathom-related jank by 374 ms (81%) compared to the original implementation of isVisible.
  isVisible(fnode) {
    const element = fnode.element;
    const rect = element.getBoundingClientRect();
    return !!(rect.height && rect.width && (document.elementsFromPoint(rect.x
    + rect.width / 2, rect.y + rect.height / 2)).includes(element));
  }

Conclusions

  • DOM property accesses were by far the largest contributor to jank (81% compared to 16% for style accesses in the previous profile). This is largely due to the X-Ray work associated with DOM property access.
  • Unfortunately, as implemented, this misses some elements that can be visible but reside outside of the viewport in the -x and/or -y directions due to how getBoundingClientRect and elementsFromPoint work.

Version information

[1]: Profiled on a locally hosted product page with specs from the "Version information" section in this comment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions