SOSOLOG

Improving Rendering Performance (1): Understanding the Rendering Process

February 05, 2023

Translated from the Korean original.

image-thumbnail

Intro

I recently tuned up the rendering performance on a side project, and while I was at it, I mapped out how browser rendering actually works: what was really behind the slowdown, and what fixed it.

Rendering pipeline

You can’t improve rendering performance without understanding the rendering process and what’s involved in it. Everything below is specific to the Blink engine, drawn from the RenderingNG article and the 2020 BlinkOn talk Life of a Pixel. Browsers that don’t run on Blink may work differently.

0. Overview

rendering-main-flow

Rendering kicks off with HTML parsing, runs through Style, Layout, and Paint to work out the page’s composition, builds up layers, and finally the compositor thread and the GPU team up to draw it all on screen.

Here’s each stage, in detail.

1. Parsing

parsing

First, the main thread turns HTML into the DOM tree, a structure the browser can actually work with. This keeps going until the parser runs into a blocking resource: a <link>, or a <script> without async or defer.

CSS blocks both parsing and rendering, so the page doesn’t briefly flash unstyled content.

A <script> tag can also contain code that rewrites the DOM (document.write()), which is reason enough for the parser to pause there too.

Pausing parsing has side effects, like pushing back important resources, so browsers soften the blow with a preload scanner that fires off the requests it can predict, in parallel.

2. Style

style

Once the DOM tree is parsed, the browser parses the CSS and works out each node’s style in three steps.

Step 1. CSS → Style Sheet

css_to_style_sheet

It pulls together the CSS loaded from <link> tags, <style> tags, and inline styles into a style sheet the browser can process.

Step 2. Unit conversion

CSS accepts all kinds of units, px, %, em, rem, and relative ones like rem get resolved down to pixels during this step.

Why pixels specifically: the last stage of rendering builds bitmap data, and a bitmap is just pixels.

Step 3. Style computation

style_calc

Finally, the browser resolves each element’s final style, accounting for things like overrides.

3. Layout

layout

Layout builds the layout tree. Working from the DOM tree and the style sheet, it figures out which elements go where. Since the layout tree only tracks what actually renders, anything set to display: none gets left out entirely.

layout_cost

None of this is trivial. Even a page as plain as the one in the clip above forces the browser to work out exactly where each line should wrap, based on font size, for every paragraph.

4. PrePaint

PrePaint gets everything ready to build the layers, and it breaks down into two main jobs.

1. Paint Invalidation

paint_invalidation

Whenever something changes upstream, in Style or Layout, it gets flagged with what’s called a dirty bit, and that invalidates whatever paint record was cached.

2. Property Tree

property_tree

The Property Tree tracks the properties assigned to each layer. Apply a CSS property like transform or opacity, and it lands in the property tree, so the compositing stage can apply the right effect quickly later on.

Property Tree data used to live right on the layer itself, so changing one node’s property meant walking down through all its descendants to update them too. The current Blink engine keeps these properties separate instead, and each node just points to its own node in the Property Tree.

5. Paint

Paint doesn’t draw anything to the screen. It produces Paint Records, which describe how something should eventually be drawn. Each record holds three things:

6. Layerize

composition_forest

Layerize takes paint’s output and turns it into a Composited Layer List. Layout already built a Layout Tree out of Layout Objects, and any Layout Object meeting one of the conditions below gets its own Paint Layer:

Anything that doesn’t qualify just gets folded into the nearest ancestor’s Paint Layer instead. (A single Paint Layer can cover more than one Layout Object.)

A Paint Layer gets its own Graphics Layer on top of that if it carries a Compositing Trigger, or holds scrollable content.

Compositing Trigger examples

A separate Graphics Layer can be pixelated on its own, and since it doesn’t have to redo the raster step (more on that later) every frame, the GPU can handle it directly. That’s exactly why scrolling and animation stay fast.

composite_after_paint

Layer creation used to happen before paint, but the CAP (Composite After Paint) project flipped that order to run after paint, and there are plans to eventually move the whole thing off the main thread onto a tile worker thread.

Composite After Paint (CAP)

Before RenderingNG, the effort to overhaul Blink’s rendering, the Composited Layer was built before paint ran. Ordering it that way created a circular dependency in the pipeline every time a style update happened.

composite_after_flow

Take a case where Paint needs to be invalidated. That invalidation can be triggered by a change further upstream (DOM, Style, Layout), or by a change to the previous Layerization result.

implicit-compositing

https://sergeche.github.io/gpu-article-assets/examples/example1.html

If an element’s Stacking Context calls for implicit compositing, the browser has to create yet another composite layer, and that layer change forces Paint to run all over again.

The CAP project exists specifically to close that loop. You can find the details in RenderingNG deep-dive: BlinkNG - Composite after paint and in the project’s design doc.

7. Commit

The Composited Layer List that Layerize produces, along with the Property Tree from PrePaint, gets copied over to the compositor thread. That handoff is called a Commit, and it’s the main thread’s last job in this whole sequence. After that, the main thread is free to run JavaScript or kick off the pipeline again.

commit

The main thread is done, but drawing this one frame isn’t over yet. The compositor thread and the GPU still have their own work left before anything actually reaches the screen.

Splitting the work across threads like this is purely about running things in parallel. While the compositor thread pushes through the rest of the pipeline, the main thread is already free to pick up its next rendering pass.

Compositor thread

Separately from the main thread, the compositor thread handles two jobs: compositing layers and processing user input.

1. Compositing layers

non_composition_raster

Actually putting pixels on screen means converting everything the earlier stages figured out (the HTML structure, each element’s style, its geometry, its paint properties) into actual pixels. That conversion is called rasterizing.

The crudest version just rasterizes whatever’s needed, on demand. Scroll the page, and the browser shifts the already-rasterized frame and rasterizes the newly exposed strip. That’s actually how Chrome handled it when it first launched. Modern browsers do something smarter, called compositing.

composition

Compositing splits the page into separate layers, rasterizes each one on its own, and merges them together on the compositor thread. Scroll, and since the layers are already rasterized, all that’s left is compositing the new frame. Animation works the same way: move a layer, composite it, done.

2. User input

A scroll event on a composited layer can be handled entirely on the compositor thread, skipping the main thread altogether, as long as nothing has an event handler attached to it.

non-fast-scroll-region

Since JavaScript only runs on the main thread, the compositor thread flags any region with an event handler attached as a “non-fast scrollable region” whenever the page gets composited. That flag is how the compositor decides, once an event actually fires, whether it needs to forward it to the main thread at all. Outside that region, the compositor just composites the new frame straight from what the main thread already committed, no round trip required.

document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault();
}
});

Which means event-delegation code, attaching a handler to some parent element, can quietly wreck scroll performance in places you wouldn’t expect. (For more on event phases, Jbee’s Looking at the spec: Document Object Model Event is worth a read.)

non-fast-scroll-region-all

From the browser’s perspective, that marks the entire document, the whole page, as a non-fast scrollable region. Now the compositor has to check in with the main thread on every input event and wait for it to respond, so scrolling can’t stay smooth.

document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, { passive: true });

Setting the passive option on the listener heads this off. With it set to true, the browser ignores defaultPrevented the moment the event fires, so the main thread still gets the event, but the compositor no longer has to wait around for it before compositing the next frame.

8. Tiling

tilling

The compositor thread rasterizes every layer it gets handed from the main thread. A layer can be huge, so it gets cut up into tiles first. Each tile carries the PaintRecord generated during drawing, and tiles get rasterized in different orders of priority depending on things like whether they fall inside the viewport.

9. Raster

raster

Raster is where the draw commands stored in each tile actually get executed. Blink leans on a graphics library called Skia to produce bitmap images and stash them in GPU memory.

Older Chromium architectures ran this on a raster thread inside the renderer process; these days it happens in the GPU process instead, which is what people mean by “hardware acceleration.”

draw

Once every tile is rasterized, the browser builds a DrawQuad, or “quad” for short, out of it. A quad records where and how to draw its tile, built from the layer and Property Tree data from earlier.

10. Activate

activate

The compositor thread runs a multi-buffering setup: a pending tree and an active tree that swap places.

Rasterizing happens asynchronously, so if a new commit shows up while the compositor thread is still chewing through a previous one, it needs to keep showing that older commit’s content until the new one is actually ready.

The pending tree takes the commit, and once everything it needs is ready, it gets copied over to become the active tree. Splitting the trees this way lets committed changes sit and wait in the pending tree while the active tree is busy doing GPU work.

Finally, the now-active quads get bundled into a Compositor Frame and handed off to the GPU process. The compositor thread’s whole job, boiled down, is to take committed layers, tile them, rasterize them, pack them into a Frame, and ship it to the GPU.

11. Display

display

Last step: the viz thread in the GPU process merges however many CompositorFrames there are into one, renders the pixels to the screen, and with that, the pipeline for drawing a single frame wraps up.

Next

The next post builds on all of this to get into how, and why, you can actually improve rendering performance.

References


SO_YOUNG
👋@SO_YOUNG
📝 A small, casual dev log

GitHubX (Twitter)