Concepts — Compositor and GPU

Phase 4 · Spec area §15. Prerequisites: bi-09.


1. Why a Principal Engineer needs this

1. This is the layer that decides whether your page feels smooth while the main thread is busy. Compositor-driven scrolling and compositor-driven animations are the reason a janky page still scrolls — and the reason a single non-passive listener can destroy that.

2. The frame budget is not yours. 16.7 ms at 60 Hz is the whole budget, shared between your JS, style, layout, paint, commit, raster, and the GPU's own work. Engineers who believe they have 16 ms of script time consistently ship jank.

3. It is where "it's fast on my machine" dies. Raster and GPU costs scale with pixels and device capability, not with your code's complexity. A retina laptop and a low-end phone differ by an order of magnitude in exactly this stage.


2. Mental Model

2.1 Two threads, two trees

RENDERER MAIN THREAD                 COMPOSITOR (impl) THREAD
  style, layout, paint      commit
  main-thread layer tree  ─────────►  pending tree
                                        |  raster tiles (worker pool / GPU process)
                                        v  activate
                                      active tree
                                        |
                                        v  produce a CompositorFrame
                                     GPU PROCESS (viz) -> display -> vsync

The essential asymmetry:

The compositor thread can produce frames from the active tree without the main thread.

That is why scrolling and transform/opacity animations survive a blocked main thread, and it is the single most important fact in this module.

2.2 Commit, activate, draw

  • Commit — main thread hands the compositor an updated tree. Main thread is blocked briefly.
  • Activate — the pending tree becomes active once its tiles are ready to draw.
  • Draw — the compositor produces a frame; the display compositor (viz, in the GPU process) composites all sources and presents at vsync.

The pending/active split exists so that half-rastered content is never shown. A simpler design — draw whatever is ready — would produce visible tearing and partially-updated frames. That is the §46 entry for this module.

2.3 Tiles and raster

Layers are divided into tiles, rastered independently and prioritised by proximity to the viewport. This is why:

  • scrolling fast can show checkerboarding — you outran raster;
  • a huge layer costs memory proportional to its area, not its visible part;
  • raster happens off the main thread, and may be GPU-accelerated.

2.4 Scrolling, precisely

Scrolling is compositor-driven unless something forces main-thread involvement. The usual causes:

  • a non-passive wheel/touchstart listener that might call preventDefault(),
  • certain position: fixed/sticky and background-attachment: fixed situations,
  • scroll-linked effects implemented in JS scroll handlers.

{ passive: true } is a promise that you will not call preventDefault(), which lets the compositor scroll without asking the main thread. This is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — and the reason browsers made passive the default for some listeners.

2.5 Compositor-driven animations

An animation can run on the compositor if it only changes property-tree values (transform, opacity, and some filters). Anything that changes layout or requires re-recording the display list must involve the main thread every frame. This is bi-09's property-tree argument, cashed out.

2.6 The frame budget

60 Hz  -> 16.7 ms per frame
120 Hz ->  8.3 ms per frame

Within that: input processing, rAF callbacks, style, layout, pre-paint, paint, commit, raster, GPU draw, and presentation. Your script is one term. On a 120 Hz device the budget halves while raster costs do not — which is why high-refresh displays expose jank that 60 Hz hid.


3. Under the Hood

ConcernWhere
Compositor//cc — layer trees, tiling, raster scheduling, animation
Display compositor, command buffer//components/viz, //gpu
Blink side of compositingcore/paint/ + the compositing decisions in pre-paint
Scheduling framesthe compositor scheduler in //cc

Remember //cc runs in the renderer process across two threads, while viz/GPU work runs in the GPU process (bi-02). Conflating "compositor" with "GPU process" is the standard error here.


3.5 Deep dive: the cc vocabulary, from the source glossary

cc/README.md carries a glossary. These are the terms you need to read a trace or a bug report.

TermDefinition
Layera conceptual piece of content with a known position relative to the viewport. Main thread only.
LayerImplthe same thing, on the compositor thread
Active treethe layers + property trees used to submit a CompositorFrame. Composited effects — scrolling, pinch, animations — are done by modifying the active tree
CompositorFramea set of RenderPasses (each a list of DrawQuads) plus metadata — "the instructions for how to draw an entire scene presented in a surface"
DrawQuadone primitive draw instruction (a textured rect, a solid colour, …)
RenderPassa group of quads drawn to an intermediate target
ElementIDa stable identifier across updates, chosen by cc's clients; Blink uses it to identify the object responsible for a composited animation
DirectRendererabstraction for drawing an aggregated CompositorFrame to a physical output; backends are GL, Skia, or Software
CopyOutputRequesta request to copy part of the output; forces a separate RenderPass

Three observations that repay attention:

1. Layer vs LayerImpl is the thread boundary made into types. You can tell which thread a piece of cc code runs on from the type names alone. That is a design worth stealing: when a boundary matters, encode it in the type system rather than in a comment.

2. "Composited effects are done by modifying the active tree." That single sentence is the mechanism behind compositor-driven scroll and transform/opacity animation. The compositor is not re-deriving anything from Blink — it is mutating its own tree and re-submitting. This is why those effects survive a blocked main thread.

3. A CompositorFrame is instructions, not pixels. Quads with transforms and texture ids. The GPU process executes them. So "compositing" is closer to issuing a display list than to copying bitmaps — the same record-then-execute pattern as bi-09's display items, one layer down.

ElementID deserves a note of its own. It is how Blink says "this animation belongs to that thing" in a way that survives commits, layer reshuffling, and tree rebuilds. Every system that hands work to an asynchronous consumer needs a stable identity that outlives the structures on both sides — key in fw-02, ElementID here. Same problem, same shape of answer.


3.6 Deep dive: the frame pipeline, stage by stage

BeginFrame  ->  main thread: rAF, style, layout, prepaint, paint
            ->  COMMIT  (main thread blocked briefly; hands over the layer tree)
            ->  compositor: pending tree
            ->  RASTER (worker pool / GPU)
            ->  ACTIVATE (pending -> active, only when tiles are ready)
            ->  DRAW: submit CompositorFrame
            ->  viz (GPU process): aggregate frames from all sources
            ->  DirectRenderer draws to the physical output
            ->  PRESENT at vsync

Where things go wrong, per stage:

Stage overrunsSymptomUsual cause
main-thread framejank, input delaylong tasks, forced sync layout, heavy rAF
commitperiodic hitcheshuge layer trees, many property changes
rastercheckerboarding on scrollexpensive paint, large layers, many tiles
activatestale contenttiles not ready; raster outran
draw / aggregatedropped frames on a busy systemGPU contention, too many surfaces

Aggregation is the part application engineers never see. viz aggregates CompositorFrames from multiple sources — every renderer, plus browser UI — into one frame for the display. That is why an out-of-process iframe can composite independently, and why a misbehaving GPU process affects every tab at once.


3.7 Deep dive: tiles, and why scrolling can show nothing

Layers are divided into tiles rastered independently and prioritised by proximity to the viewport. There is a raster budget; tiles outside it wait.

This is why:

  • Checkerboarding (blank regions during fast scroll) is correct behaviour: the compositor drew a consistent frame using what was ready, rather than blocking. The alternative — stall until raster finishes — is worse, and the choice is deliberate.
  • A very large layer costs memory proportional to its area, not its visible portion — so will-change: transform on a 10,000px-tall element is expensive even though you can see 800px.
  • Raster happens off the main thread, and may be GPU-accelerated. GPU raster is usually faster; the CPU path exists because drivers vary enormously and some content rasters better on CPU.

Damage tracking

The compositor tracks a damage rect — the region that actually changed — so the GPU redraws only that. This is why a small blinking cursor does not cost a full-screen redraw. When you see a full-screen damage rect in a trace for a tiny change, something has invalidated a whole layer, and that is a bug worth chasing.


3.8 Deep dive: what actually forces scroll onto the main thread

Compositor scrolling is the default. It is lost when the compositor cannot answer "may I scroll this without asking?" — and bi-09's hit-test data is how it tries.

Causes, roughly in order of how often they appear in real apps:

  1. Non-passive wheel / touchstart / touchmove listeners — the compositor must wait to see whether you call preventDefault().
  2. scroll event handlers that write styles — the handler runs on the main thread, one frame after the scroll it reacts to. Structurally late; not fixable by optimising the handler.
  3. background-attachment: fixed and some position: fixed/sticky configurations.
  4. Scroll-linked effects in JS generally.

{ passive: true } is a promise that you will not call preventDefault(). It is the clearest example in the platform of an author-supplied guarantee unlocking a fast path — the same shape as key (fw-02), contain (bi-07), and sideEffects (fw-07). Browsers eventually made some listeners passive by default because the guarantee was almost always true and almost never declared.

The modern alternatives, which move the effect to the compositor instead of the main thread: IntersectionObserver for "is it visible", CSS scroll-driven animations (animation-timeline: scroll()) for scroll-linked motion, position: sticky for stickiness, and CSS scroll snap for snapping. Each replaces a scroll handler with something declarative that the compositor can evaluate itself.


3.9 Deep dive: the frame budget, itemised

60 Hz  -> 16.7 ms      120 Hz -> 8.3 ms      144 Hz -> 6.9 ms

Within one frame the main thread may need to do: input dispatch, rAF callbacks, ResizeObserver and IntersectionObserver delivery, style recalculation, layout, pre-paint, paint, and commit. The compositor then needs: raster (possibly), activate, draw, submit. The GPU process needs: aggregate and present.

Your JavaScript is one term in that sum. The common failure is a team optimising script time from 8 ms to 5 ms while style+layout costs 9 ms, and reporting no improvement.

Two consequences that are easy to state and hard to internalise:

  • High-refresh displays halve the budget but not the costs. Raster, paint, and GPU work do not get cheaper at 120 Hz. A page that is comfortable at 60 Hz can be visibly janky at 120 Hz, and the fix is usually reducing work rather than yielding differently.
  • Skipping a frame is not proportional degradation. Miss by 1 ms and you lose a whole frame — 16.7 ms of latency for 1 ms of overrun. Frame budgets are cliffs, not slopes, and this is why p95 matters far more than mean.

3.10 Deep dive: what "the GPU makes it fast" actually means

The folk explanation — "transform is GPU-accelerated, so it is fast" — is wrong in a way that produces bad decisions.

The truth is structural, not hardware:

  1. transform and opacity map to property-tree node changes (bi-09).
  2. A property-tree change does not require re-recording display items.
  3. Not re-recording means not re-rastering.
  4. The compositor can therefore produce a new frame from already-rastered tiles.
  5. That path does not need the main thread at all.

The GPU is involved, but the reason it is cheap is that stages were skipped, not that a processor is fast. This matters because it predicts the exceptions correctly: animating filter: blur() is also composited but is genuinely expensive, because the effect itself costs GPU work per frame. A purely hardware explanation cannot tell you that; a pipeline explanation can.


4. Anti-Patterns

Non-passive scroll listeners. Usually accidental; measurably destructive.

JS-driven scroll effects. scroll handlers that write styles run on the main thread, one frame late by construction. Prefer IntersectionObserver, scroll-driven animations, or CSS.

Giant composited layers. Memory scales with area.

will-change as a blanket fix (see bi-09).

Testing only on desktop at 60 Hz. Raster and GPU costs are where device differences live.


5. Trade-offs

Compositor-driven smoothness vs correctness. The compositor draws stale but consistent content while the main thread catches up. That is why scrolling can reveal not-yet-painted areas: the alternative is blocking on the main thread, which is worse.

Pending/active trees vs latency. Never showing partial content costs a frame of latency relative to drawing whatever is ready.

More layers vs fewer. Fewer repaints, more memory and composite cost.

Raster on GPU vs CPU. GPU raster is faster for many workloads and worse for some; drivers vary enormously, which is why there is a fallback path at all.


6. Lab

Your mini-browser will not have a real compositor; the lab is measurement and reasoning.

  1. Budget accounting. Build a page with a rAF animation and measure, from a trace, how much of each frame is script vs style/layout vs paint vs raster vs GPU. Produce a stacked chart.
  2. Passive vs non-passive. Same page, one non-passive touchstart/wheel listener. Measure scroll latency with and without. Explain the mechanism, then explain the number.
  3. Compositor vs main-thread animation. Animate the same visual effect via transform and via top. Block the main thread with a 500 ms task mid-animation. Record both. Describe what the user sees in each case.
  4. Tile/checkerboard. Make a very tall page with expensive content and fling-scroll it on a throttled profile. Capture checkerboarding. Explain it in terms of tiles and raster priority.
  5. 120 Hz thought experiment. Take your frame budget chart from (1) and recompute against 8.3 ms. Which term breaks first?

Deliverable: the four measurements plus one paragraph on which of them would change most on a low-end Android device, and why.


7. Failure Lab

  1. Add a non-passive wheel listener that does nothing. Show the cost.
  2. Animate box-shadow (paint-heavy) vs transform. Compare frames.
  3. Force a giant layer (will-change: transform on a very large element). Measure memory.
  4. Write a scroll handler that writes a style each event. Demonstrate the one-frame lag, then fix it with a compositor-friendly approach and prove the lag is gone.

8. Debugging Exercise

  1. Perfetto: capture a scroll. Identify the compositor thread, the raster workers, and the GPU process. Follow one frame's flow arrows end to end.
  2. Find a frame that was dropped. Determine which stage overran, from the trace alone.
  3. DevTools: use layer borders and the rendering panel to find an unexpected composited layer. Determine what created it.
  4. Determine whether a given animation is running on the compositor. State your evidence.

9. References

  • //cc documentation in-tree; //components/viz docs.
  • Life of a Pixel; the Chromium rendering team's compositor documentation.
  • CSS Transforms, Web Animations, Scroll-driven Animations specs.
  • EventTarget.addEventListener passive listeners (DOM spec) and the interventions that made some listeners passive by default.

10. Principal Engineer Review

  1. Explain why a page with a blocked main thread still scrolls, in four sentences, mechanically.

  2. {passive: true} is a promise from the author that unlocks a browser fast path. Name two other places in the web platform with the same shape, and what makes the pattern work.

  3. Your app is smooth at 60 Hz and janky at 120 Hz. Give the likeliest causes in order and how you would confirm each.

  4. The pending/active tree split costs a frame of latency to avoid showing partial content. Argue the opposite trade-off; what product would choose it?

  5. A team proposes implementing a parallax effect with a scroll listener. Give the mechanism- level objection and two alternatives, with their limitations.

  6. Compositing decisions are heuristic. Design the API you would give authors instead. What would go wrong when they use it?

  7. Checkerboarding is user-visible incorrectness that browsers ship deliberately. Justify it. Under what conditions would blocking be better?

  8. You have one week to improve scroll performance across a large app. What do you measure first, and what is the single most likely finding?

  9. Raster can happen on CPU or GPU, with a fallback path. Argue for removing the CPU path. What breaks, and for whom?

  10. A stakeholder asks for a "performance budget" for a new feature. Express it in frame-budget terms, and say which parts of the budget your team does not control.