Solving rendering performance puzzles

You're missing demos in this post because JavaScript or inline SVG isn't available.

The Chrome team are often asked to show the process of debugging a performance issue, including how to select tools and interpret results. Well, I was recently hit by an issue that required a bit of digging, here's what I did:

Layout ate my performance

I wanted to show a quick demo of text on a path in SVG, then animate it appearing character by character. Although we're dealing with SVG, the process of finding and fixing the issues isn't SVG-specific.

It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. Oh, she's telling me to get it. But, girl I don't want it. I don't want you no more. That's why I'm saying. I can't take it no more. All these paps. all these girls. knocking on my door. Heartbreaker, you didn't get the best of me. I hope you're happy now. You made me cry, but do you know the real reason why? girl. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. Oh, she's telling me to get it. But, girl I don't want it. I don't want you no more. That's why I'm saying. I can't take it no more. All these paps. all these girls. knocking on my door. Heartbreaker, you didn't get the best of me.

As the animation continues it gets slower and slower. I opened up Chrome Devtools and made a timeline recording:

Layout costs increasing over time

The animation starts at a healthy 60fps, then falls to 30, 20, 15, 12, 10 and so on. When we look at an individual frame we can see the slowness is all to do with layout.

To render web content the browser first parses the markup, then performs "Recalculate style" to determine which styles apply to each element after the CSS cascade, style attributes, presentational attributes, default styles etc etc. Once this is complete, the browser performs a "Layout" to determine how these styles interact to give each element their final x, y, width and height. At some point later, the browser will "paint" to create pixel data for an area of the document, then finally "composite" to combine parts that were drawn separately. You'll see all these events (and more) fly past in Chrome Devtools' timeline. It's pretty rare for the critical path to be majorly disturbed by layout, but that's what's happening here.

You layout too much and smell of old biscuits
The critical path

So why is layout having a tantrum? Let's look at the code:

SVG

<svg viewBox="0 0 548 536">
  <defs><path id="a" d="" /></defs>
  <text font-size="13" font-family="sans-serif">
    <textPath xlink:href="#a" class="text-spiral">
      It's a heart breaker…
    </textPath>
  </text>
</svg>

JavaScript

var spiralTextPath = document.querySelector('.text-spiral');
var fullText = spiralTextPath.textContent;
var charsShown = 0;

function frame() {
  spiralTextPath.textContent = fullText.slice(0, charsShown);
  charsShown++;
  // continue the anim if we've got chars left to show
  if (charsShown != fullText.length) {
    window.requestAnimationFrame(frame);
  }
}

// start the animation
window.requestAnimationFrame(frame);

Each frame we're changing the value of textContent to include an extra character than the frame before. Because we're replacing the whole text content every frame, the browser lays out every letter despite most of the letters being the same as the frame before. So, as we get more letters, layout takes longer.

Update: As pointed out by Mathias Bynens, .slice isn't a safe way to split a string if it contains chars from the astral plane. Thankfully Justin Bieber thinks an astral plane is a space shuttle so it's ok. But if you're dealing with a string containing unicode surrogates, MDN has a workaround.

Avoiding multiple layouts

Before we animate the text, let's wrap each character in it's own tspan (much like HTML's span). Then we can show each tspan frame-by-frame rather than replacing the whole text every frame.

Using the same SVG as before, we change our JavaScript to:

var spiralTextPath = document.querySelector('.text-spiral');
var fullText = spiralTextPath.textContent;

// empty the text path, we're going to rebuild it
spiralTextPath.textContent = '';

// loop over every char, creating a tspan for each
var tspans = fullText.split('').map(function (char) {
  // I love having to use namespaces to create svg
  // elements, it's my FAVOURITE part of the API
  var tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
  tspan.textContent = char;
  // hide the element, we don't want to show it yet
  tspan.style.visibility = 'hidden';
  // add it to the text path
  spiralTextPath.appendChild(tspan);
  return tspan;
});

// and now the animation
var charsShown = 0;

function frame() {
  // show a char
  tspans[charsShown].style.visibility = 'visible';
  charsShown++;
  if (charsShown != tspans.length) {
    window.requestAnimationFrame(frame);
  }
}

window.requestAnimationFrame(frame);

Update: For the same reasons as str.slice(), str.split('') fails on strings containing unicode surrogates.

Unlike display: none, elements with visibility: hidden retain their size and position in the document. This means changing their visibility has no impact on layout, only painting. Job done.

It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. Oh, she's telling me to get it. But, girl I don't want it. I don't want you no more. That's why I'm saying. I can't take it no more. All these paps. all these girls. knocking on my door. Heartbreaker, you didn't get the best of me. I hope you're happy now. You made me cry, but do you know the real reason why? girl. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. It's a heart breaker, she's a heartbreaker. Tell your friends that that girl is a heartbreaker. Don't go near her. She's a male undefined species. She hunts for him, just to get a whiff of his fame. I can't take it. Tell'em she's a heartbreaker. Oh, she's telling me to get it. But, girl I don't want it. I don't want you no more. That's why I'm saying. I can't take it no more. All these paps. all these girls. knocking on my door. Heartbreaker, you didn't get the best of me.

You're missing a demo here because JavaScript or inline SVG isn't available.

I was really hoping to finish this blog post here. I mean, we've definitely improved things in Chrome, but it's still getting slower as the animation continues. Also, the performance in Firefox and IE has dropped significantly. I have no idea where Firefox is losing speed because it doesn't have a timeline tool like other browsers (c'mon Firefox, we need this).

Anyway, what's the timeline showing now?

Increasing gap between paint & composite

We've lost the huge layout cost, but something is still increasing over time. The timeline is being extremely unhelpful here. Nothing's taking long, but there's an increasing gap between painting and compositing. In fact, looking back at the first timeline recording, the issue is there too, I missed it because I got distracted by the huge layouts.

So, something else is getting in the way of pixels hitting the screen. Time to bring out the big guns.

About about:tracing

Chrome has a lower-level timeline tool hidden in about:tracing. Let's use that to identify the cause.

Debugging with about:tracing

TL;DW: about:tracing shows the majority of each frame to be taken up by calls to Skia, the drawing API used by Chrome. This tells us the slowdown is paint-related. Turning on paint rectangles reveals the whole SVG canvas appears to be redrawn on each frame.

I'm a good web citizen, so I opened a ticket about the lack of SVG paint tracking in devtools. Turns out this is a regression in Chrome Canary, the stable version of Chrome gets it right and shows increasing paints. If only I'd checked there first, I wouldn't have needed to throw myself into the scary lands of about:tracing.

The critical path is happy with layout, but it's awfully concerned with all the painting.

You layout too much and smell of old biscuits
The critical path, again

When does SVG redraw?

I've seen enough complex SVG examples to know Chrome's renderer isn't always that dumb. It usually only repaints the updated area. Let's experiment with updating various elements:

Hello WorldHelloWorld

Hello World

You're missing a demo here because JavaScript or inline SVG isn't available.

Turn on "Show paint rectangles" and click the words above. The first "Hello World" is made of two tspan elements. The second is two text elements. The third is two HTML span elements.

From this we can see that updating a tspan causes the parent text to fully redraw, whereas text elements can update independently. HTML span elements also update independently. The same thing happens in Firefox & Safari (unfortunately there isn't a show-paints tool in IE), but I don't think this over-painting is required by the SVG specification. I've made a ticket about it, hopefully an optimisation can be made.

Update: Although IE11 doesn't show painted areas live, it will show the size of painted regions in its timeline. Unfortunately the Win7 platform preview of IE11 crashes when I try the timeline. Will update this when I get it working.

So there's our answer, we need to ditch the tspan elements and go with a text element per character so they can be painted independently.

From tspan to text

Unfortunately, SVG doesn't allow text elements inside textPath. We need to get rid of the textPath, then position & rotate each character's text element into a spiral shape. Thankfully, we can automate this.

SVG gives us text.getStartPositionOfChar(index), which gives us the x & y position of the character and text.getRotationOfChar(index) which gives us the rotation. We can use this to convert text on a path to a series of absolutely positioned text elements:

var svg = document.querySelector('.text-on-path');
var fullText = svg.querySelector('.text-to-convert');

fullText.textContent
  .split('')
  .map(function (char, i) {
    var text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    var pos = fullText.getStartPositionOfChar(i);
    var rotation = fullText.getRotationOfChar(i);
    text.setAttribute('x', pos.x);
    text.setAttribute('y', pos.y);
    text.setAttribute('rotate', rotation);
    text.textContent = char;
    return text;
  })
  .forEach(svg.appendChild.bind(svg));

// get rid of the old text
fullText.parentNode.removeChild(fullText);

Note that I'm looping twice, once to create all the text elements, and another to add them to the SVG document (using a nifty bit of function binding). This is to avoid interleaving layout-reading operations with layout-changing DOM modifications. If we interleave layout reads and writes the browser needs to relayout the SVG element on each loop, which causes major slowdown, we're talking eleventy-billion times slower. We call this layout thrashing.

Anyway, let's take some text on a path and convert it to a series of text elements. Clicking the button below shouldn't effect the rendering, but look at the DOM to see the difference:

Ohh it's some wobbly text, how exciting!

You're missing a demo here because JavaScript or inline SVG isn't available.

That feels like the answer, but unfortunately doing this for all characters in the spiral takes upwards of 20 seconds, except in IE which nails it almost instantly. The vast majority of this time is spent in calls to getStartPositionOfChar and getRotationOfChar (I've opened a ticket to investigate this). If you want to benchmark your particular browser, go for it, but it may make your browser bleed out:

You're missing a demo here because JavaScript or inline SVG isn't available.

However, we could do this processing once, locally, save the resulting SVG, & serve that to users. This takes our SVG data from 1k to 19k (gzipped), and text position will be based on the machine we generated it on, which may not look right on a machine with a different "sans-serif" font (in which case we could switch to an SVG font). But hey, it works:

You're missing a demo here because JavaScript or inline SVG isn't available.

Devtools showing 60fps
Job done, for real this time

Ahh the sweet smell of 60fps. Hardly touching the CPU. The critical path now has very little to complain about.

…your mum?
Oh you!

In summary...

If layouts are causing slowness:

  • Remember that things like innerHTML & textContent will destroy and rebuild the entire content of that element
  • Isolate the thing you're animating (give it its own element) to avoid impacting other elements
  • When showing/hiding elements, visibility: hidden allows you to pay the layout cost earlier
  • If an element is frequently changing size, try to prevent it being a layout dependency for other elements, eg make it position: absolute or use CSS transforms

If paint is causing slowness:

  • Use paint rectangles to see if too much is being drawn
  • Avoid complex CSS effects
  • Can you achieve the same or similar effect with the GPU's help? Eg using CSS transforms & transitions/animations.

If devtools isn't giving you the answer:

  • Take a deep breath & give about:tracing a try
  • File a bug report against devtools!

Image credit: Forest path

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer of sorts.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.