Page MenuHomePhabricator

[SPIKE 8hrs] Determine remedy for MobileFrontend lazy-loading images performance issues
Closed, ResolvedPublicSpike

Assigned To
Authored By
Gilles
Jun 17 2019, 3:45 PM
Referenced Files
F30397619: Capture d'écran 2019-09-19 12.34.37.png
Sep 19 2019, 10:37 AM
F30397623: Capture d'écran 2019-09-19 12.35.51.png
Sep 19 2019, 10:37 AM
F29658647: lazy-loading-on-b8.png
Jun 27 2019, 10:59 PM
F29658539: lazy-loading-off-obama.png
Jun 27 2019, 10:59 PM
F29658508: lazy-loading-on-obama.png
Jun 27 2019, 10:59 PM
F29658660: lazy-loading-off-b8.png
Jun 27 2019, 10:59 PM
F29640573: lazy-load-off.png
Jun 25 2019, 3:56 PM
F29640554: lazy-load-on.png
Jun 25 2019, 3:48 PM
Tokens
"Mountain of Wealth" token, awarded by phuedx."Like" token, awarded by nray.

Description

Background

We're currently taking part in the origin trial for Event Timing on ruwiki and eswiki and the most common type of slow event is the mobile site's click handler for section expansion. Investigating further, it appears that the majority of the cost for that event handler comes from image lazy loading.

When users click on a section to expand it, 61.7% of the time that click's event processing (due to the image lazy loading stuff) takes more than 20ms. When it takes more than 20ms, the median is 56ms and the p95 235ms. Essentially, the majority of the time the browser becomes unresponsive for a short period when the section is clicked/pressed to be expanded.

What happens

When the "section-toggled" event coming from this click is dispatched, it triggers a proximity check for images present in the expanded section:

https://github.com/wikimedia/mediawiki-extensions-MobileFrontend/blob/917d7bd07e1b19dbb2e22aca4199bb9c8ff9bca9/src/mobile.startup/lazyImages/lazyImageTransformer.js#L45

https://github.com/wikimedia/mediawiki-extensions-MobileFrontend/blob/917d7bd07e1b19dbb2e22aca4199bb9c8ff9bca9/src/mobile.startup/lazyImages/lazyImageTransformer.js#L25

https://github.com/wikimedia/mediawiki-extensions-MobileFrontend/blob/917d7bd07e1b19dbb2e22aca4199bb9c8ff9bca9/src/mobile.startup/lazyImages/lazyImageTransformer.js#L60

For each lazy-loading image container, this triggers an expensive call to scrollTop/scrollLeft:

https://github.com/wikimedia/mediawiki-extensions-MobileFrontend/blob/917d7bd07e1b19dbb2e22aca4199bb9c8ff9bca9/src/mobile.startup/lazyImages/lazyImageTransformer.js#L60

Calls to these functions trigger reflows and style recalculations: https://gist.github.com/paulirish/5d52fb081b3570c81e3a

The more images the expanded section contains, the worse it gets.

While this issue has been surfaced by the click handler, they are also affecting the other ways lazy loaded images are processed.

Profile

lazy-load-on.png (1×2 px, 224 KB)

Recommended actions

I understand that this proximity logic was put in place to make the lazy loaded image proximity check "smarter" than the default intersection observer behaviour of knowing about an image only once it starts being in the viewport. However, this feature comes at a cost in terms of page responsiveness, CPU spike usage, and battery.

Minimum optimisation

Currently scrollTop and scrollLeft are accessed for each lazy loading container. This is inefficient, as within an event handler the scroll position of the viewport won't change. Saving this information so that the coordinates are reused when processing each placeholder should be straightforward.

Extended optimisation

The viewport position from the last scroll event should be a sufficient approximation when a section expansion occurs. Expanding the section doesn't change the scroll position. As a result, if the last known viewport scroll position is saved, there is no need to compute it upon section expansion. Reusing the last known viewport coordinates should suffice. While the debouncing of the scroll event handler reduces the accuracy a little, it's identical to the accuracy that happens when scrolling, which I imagine is what the proximity threshold was tuned for.

Similarly this saved viewport scroll position could be used for the resize event.

"Maximum" optimisation

As you might guess, I recommend that this logic is scraped entirely and replaced with an intersection observer. Which will bring us more in line with upcoming native lazy loading.

In order to emulate the current behaviour, one could consider using spare time in the background (eg. using requestIdleCallback) to preload the next 1-2 images in the DOM that aren't visible yet. This might achieve the same effect as the current behaviour most of the time.

I believe that this would be a better compromise in terms of browser responsiveness / bandwidth usage while retaining the bulk of the desired effect.

Acceptance Criteria

  • Out of the options listed above, determine which one we should pursue
  • Create a new task outlining the appropriate action and your findings so we can run it through our pipeline!

Event Timeline

Gilles renamed this task from Mobile Frontend lazy-loading image performance issues to Mobile Frontend lazy-loading images performance issues.Jun 17 2019, 4:03 PM
ovasileva triaged this task as Medium priority.Jun 24 2019, 6:35 PM
ovasileva moved this task from Incoming to Upcoming on the Web-Team-Backlog board.
nray claimed this task.
nray renamed this task from Mobile Frontend lazy-loading images performance issues to [SPIKE] Determine Appropriate Action for Mobile Frontend lazy-loading images performance issues.EditedJun 24 2019, 7:59 PM
nray updated the task description. (Show Details)
nray added a subscriber: ovasileva.

@ovasileva I retitled this as a spike so I believe it can be estimated now

Will write more on this later, but this is what the profile looks like with the event bus not listening to section-toggled events for images (effectively turning lazy loading off when sections are toggled).

lazy-load-off.png (1×4 px, 325 KB)

ovasileva renamed this task from [SPIKE] Determine Appropriate Action for Mobile Frontend lazy-loading images performance issues to [SPIKE 8hrs] Determine Appropriate Action for Mobile Frontend lazy-loading images performance issues.Jun 25 2019, 4:17 PM

Cool, the remaining painting is certainly due to the section that appears, but doesn't block the click event handling, therefore keeping the page responsive.

Functionally, doesn't disabling the lazy loading logic in the case of the section toggling mean that lazy-loaded images that might end up inside the viewport as a result of section expansion (top images inside the previously collapsed section) won't load until the user scrolls?

I think that in the section expansion case you could use a cheap intersection observer to find new images inside the viewport. It won't preload what's below the viewport, but that'll trigger as soon as the user scrolls.

@Gilles Okay, today I carved out time to do some analysis on this. Here were my findings and my interpretation of the findings. Please correct me where I'm wrong!:

Disclaimer: I did all of these profiles on my macbook pro laptop using chrome with a 6x CPU throttle to simulate a mobile device. These tests were done locally so I could easily turn on/off lazy image loading on section expansion and compare the results.

Profile of Obama page

With lazy Image loading ON

First, I profiled the Barack_Obama page with a 6x CPU throttle to simulate mobile device. I was assuming the Obama page has an averageish number of images (~85) on the page if not more than average (but not anything too crazy). First, I did a profile of things as they are now with lazy loading enabled on section expansion:

lazy-loading-on-obama.png (1×4 px, 615 KB)

https://github.com/nicholasray/toggle-lazy-image-load/raw/master/lazy-loading-on-obama.json

With lazy Image loading OFF

Then to get an idea of how much the lazy image loading contributes to the cost, I profiled the same page with the event bus not listening to section-toggled events for images (effectively turning lazy loading off when sections are toggled):

lazy-loading-off-obama.png (1×4 px, 496 KB)

https://github.com/nicholasray/toggle-lazy-image-load/raw/master/lazy-loading-off-obama.json

Observations

  • When lazy image loading is enabled, the first call to makeViewportFromWindow from mediawiki.viewport.js triggers an expensive force synchronous layout, but the subsequent calls do not ( I think because no invalidation (writes) take place). Furthermore, when lazy image loading is disabled, similar recalc style and layout blocks are still there but are moved after the click event handler JS runs.
  • With the exception of the first call that triggers a forced synchronous layout, the subsequent calls to the mediawiki.viewport.js API make up a minority (~26 ms) of the total cost.
  • When lazy image loading is enabled, it takes around 434 ms for the JS and render steps to happen vs. 395 ms when lazy image loading is disabled. The majority of the cost is attributed to the rendering steps that have to happen because of the section that toggles.
  • Bottom line: There appears to be a ~40 ms difference between when lazy image loading is enabled and lazy image loading is disabled for the frame to be produced and the user to see the section open

Profile of B8 Polytope page

With lazy image loading ON

Next, I wanted to profile an edge case page with a ton of images. According to https://en.wikipedia.org/wiki/Wikipedia:Wikipedia_records the B8_Polytope page holds the record for the most pictures (~6000).

lazy-loading-on-b8.png (1×5 px, 597 KB)

https://github.com/nicholasray/toggle-lazy-image-load/blob/master/lazy-loading-on-b8.json

With lazy image loading OFF

lazy-loading-off-b8.png (1×4 px, 405 KB)

https://github.com/nicholasray/toggle-lazy-image-load/blob/master/lazy-loading-off-b8.json

Observations

  • As before, when lazy image loading is enabled, a forced synchronous layout happens and doesn't happen when lazy loading is disabled, but comparable recalc style and layout blocks are present in both cases.
  • This time, when lazy image loading is on, the calls to the mediawiki.api.js API take up a majority of the time because there are so many of them. Although each one (excluding the first one that triggers the FSL) only takes around 1 ms, there are tons of them and the summation is large.
  • When lazy image loading is enabled, it takes around 575 ms for the JS and render steps to complete vs. 260 ms when lazy image loading is disabled.
  • Bottom line: There appears to be a ~315 ms difference between when lazy image loading is enabled and lazy image loading is disabled for the frame to be produced and the user to see the section open

Conclusion and thoughts

  • In pages like Barrack Obama that have a considerable number of images but nothing too extreme (~85), the cost of the lazy image loading code appears to be minimal in regards to how much time it takes for the frame to be produced (and the user to see the section open). Most of the cost occurs because of the rendering steps that are necessary for the section to open. On the flip side, because the lazy image loading triggers a FSL, it adds a delay to the firing of image requests. So they might load a little later than if the FSL (and lazy image loading) wasn't there.
  • In pages like B8 Polytope that have thousands of images (~6000), the cost of the lazy image loading code has a great cost in regards to how much time it takes for the frame to be produced (and the user to see the section open).
  • If we cache the viewport coordinates (as suggested in the "minimum" and "maximum" optimizations) and did nothing else, my theory (but untested!) is that calls that get the position of each image could still trigger an expensive forced synchronous layout. One easy alternative to avoid this is to load every image in the section that is open (and not check each image's coordinates), but this might also not be ideal for bandwidth.
  • If we use an Intersection Observer as suggested by the "Maximum Optimization" this could help greatly in pages that have tons of images (B8 polytope), but would likely not have nearly as much of an impact on pages with an average amount (Obama). My biggest concern with this approach is the support in iOS browsers. iOS safari 12.1 & lower will not support it and they have 3.28% global usage still so we would either have to accept this or build the intersection observer on top of what we already have so those browsers could use a fallback. If we did this, In either case, Is the ROI enough to justify this effort?
  • As @Gilles mentioned in the description, the lazy image loading code also has a certain cost with scrolling / resize performance. I've done ZERO analysis on this and maybe that would swing the pendulum more toward a solution.

@Gilles interested to hear your thoughts on this and if I've missed anything!

Functionally, doesn't disabling the lazy loading logic in the case of the section toggling mean that lazy-loaded images that might end up inside the viewport as a result of section expansion (top images inside the previously collapsed section) won't load until the user scrolls?

That was just an experiment I did to see the effects of the lazy image loading and was not meant to be a solution. More experimentation above!

Moving this back to To Do as I'm interested to hear Gilles's advice to my initial analysis above

MBinder_WMF changed the subtype of this task from "Task" to "Spike".Jul 9 2019, 5:11 PM

Thanks, it's interesting to see that there is still a lot of work going on when the section is expanded with lazy-loading turned off. Still, we're talking about 8% overhead on one end and 45% on the other for every section expansion/collapse. It's not a negligible amount.

I think the simplest approach is to cache the last known viewport coordinates from the scroll and resize events. It might reduce accuracy slightly, but it will avoid the recalc entirely upon section expansion/collapse. And the overhead on scroll already exists, because the same logic is called on scroll: https://github.com/wikimedia/mediawiki-extensions-MobileFrontend/blob/917d7bd07e1b19dbb2e22aca4199bb9c8ff9bca9/src/mobile.startup/lazyImages/lazyImageTransformer.js#L43

Another data point to show how important this is. Looking at the real world data for the last 30 days on Chrome Pagespeed Insights, this is our
First Input Delay for the desktop site:

Capture d'écran 2019-09-19 12.34.37.png (142×720 px, 19 KB)

And for the mobile site:

Capture d'écran 2019-09-19 12.35.51.png (138×724 px, 20 KB)

Based on the Event Timing data, the section expansion is the most common offender, and probably the most common first user event on mobile. So the problem at hand here is probably responsible for this lower score. Let's turn that orange square into a green dot 🙃

@Gilles we talked about this a while back in November. My understanding was this was a priority?

You said you would make a strawman proposal of what we need to do here.

Is this still a priority? If so should we schedule some time to talk through this with @ovasileva ?

We've since established that this isn't in the realm of what First Input Delay captures. Nonetheless, the performance issues are real, but can't be captured at the moment in the field. We have since the November discussion deployed a mitigation of the issue: T239418 This lets the event handler be responsive, and postponing the expensive calculation until the browser is available again after the handler.

The long task that happens still remains, it's just out of the way of the event handling now, but it can still lock up the browser.

For that part I still recommend using an intersection observer https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API which was designed specifically to avoid expensive layout calculations, and to solve lazy loading problems.

The predictive nature of the the current implementation might be reproducible with the rootMargin parameter: https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin which should let you offset the intersection detection by the amount you want.

@ovasileva this was the task that we need to talk to @Gilles about! @Gilles has suggested a solution in T225946#5916039 - if that makes sense to the team we should resolve this task and create an implementation task and put it through the board at our earliest convenience.

Krinkle renamed this task from [SPIKE 8hrs] Determine Appropriate Action for Mobile Frontend lazy-loading images performance issues to [SPIKE 8hrs] Determine remedy for MobileFrontend lazy-loading images performance issues.Feb 25 2020, 4:31 PM

I've opened up a ready to work on task based on these discussions here: https://phabricator.wikimedia.org/T246838