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.
As the animation continues it gets slower and slower. I opened up Chrome Devtools and made a timeline recording:
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.
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.
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?
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.
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.
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 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:
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.
Ahh the sweet smell of 60fps. Hardly touching the CPU. The critical path now has very little to complain about.
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