Hello, Graphite: Performance

Earlier this month, we released React Native Skia 3.0, which renders with Skia Graphite, a new rasterization backend designed around modern GPU APIs. By moving away from the OpenGL-centric design of its predecessor and supporting multithreading by default, Graphite brings substantial performance improvements to React Native Skia.

The React Native Skia 3.0 release announcement

Starting with version 3, React Native Skia is published as react-native-skia. Version 2 is still very much maintained and is also now published under that new package name.

Ganesh and its OpenGL-centric design

Version 2 of React Native Skia was based on a rasterization backend named Ganesh. Ganesh ties its GPU context to one thread, and for us, it was the UI thread: everything for an on-screen canvas ran there. That design had two substantial consequences.

UI ThreadDrawing OperationsdrawSkia ContextflushOpenGL / Metal
In version 2, the whole chain of commands needed to go through the UI thread.

The first is stability. In React Native, things start on the JS thread, so every resource created there had to be handed over to the UI thread. This meant that even an operation as simple as decoding an image, uploading it to the GPU and destroying it could quickly become a minefield. And in all honesty, we made that limitation worse: when we got started with React Native Skia, we really focused on providing convenient APIs instead of explicit ones. Simply put, we underestimated the complexity needed to make these automatic APIs stable.

The second is performance. Because every on-screen canvas rendered on the UI thread, drawings stole precious frame budget from other UI elements of the app and from other Skia canvases.

UI ThreadSkia Canvas 1Skia Canvas 1Skia Canvas 2Skia Canvas 2Native ViewsNative Views8 msFrame budget
Because the UI thread handles everything, drawings were using precious frame budget.

Another performance aspect is pipeline compilation. In Ganesh, pipelines were highly specialized per draw, which made it hard to predict which ones a scene would need, so they couldn't be compiled ahead of time. Compiled shaders couldn't be persisted either. This is the root of the infamous "early-onset jank" problem in Flutter.

Graphite and modern GPU APIs

With Graphite, we use a thread pool for rendering. On a dedicated thread, we create a recording containing a list of drawing commands. At that point, all of the GPU resources have been created: all that is left to do is to submit the recording. In React Native Skia, this is done automatically for you: a pool of threads creates the recordings and submits them.

Dedicated threadRecordersnapshot()RecordingDedicated threadRecordersnapshot()RecordingDedicated threadRecordersnapshot()RecordingUI Threadpresent()Skia ContextDawn WebGPU (Vulkan/Metal)
Skia Graphite: recordings are made on dedicated threads.

Using the device's available cores for rendering improves performance, but also stability. An image can be drawn without copying, by any canvas, from any thread: a texture created on the JS thread, a camera frame imported in a frame processor, or an image shared with WebGPU. Failures are explicit too: every command is validated, a lost GPU device is reported and recovered from, and a recording the GPU rejects is logged. In a nutshell: the React Native integration with Skia now has fewer rules to get wrong, and its core is leaner and simpler to reason about.

On the performance side, the first payoff is that the UI thread is free. In v2, a heavy canvas dragged the whole app down with it, because the UI thread drew every frame. In v3, recording and submission run on a render thread, and React Native animations next to the canvas stay at the display rate even when the canvas itself can't keep up.

v2v3
Pixel 8
0306090120Pixel 8 · v2 · 1K rectangles: 76.20 fps76Pixel 8 · v3 · 1K rectangles: 120.20 fps1KPixel 8 · v2 · 3K rectangles: 18.30 fps18Pixel 8 · v3 · 3K rectangles: 120.20 fps3KPixel 8 · v2 · 6K rectangles: 8.50 fps8.5Pixel 8 · v3 · 6K rectangles: 120.10 fps6KPixel 8 · v2 · 12K rectangles: 4.30 fps4.3Pixel 8 · v3 · 12K rectangles: 119.90 fps12KPixel 8 · v2 · 24K rectangles: 2.20 fps2.2Pixel 8 · v3 · 24K rectangles: 119.60 fps24KPixel 8 · v2 · 48K rectangles: 1.00 fps1.0Pixel 8 · v3 · 48K rectangles: 96.30 fps9648Kfps
iPhone 14 Pro
0306090120iPhone 14 Pro · v2 · 1K rectangles: 119.70 fpsiPhone 14 Pro · v3 · 1K rectangles: 119.70 fps1KiPhone 14 Pro · v2 · 3K rectangles: 72.10 fps72iPhone 14 Pro · v3 · 3K rectangles: 119.80 fps3KiPhone 14 Pro · v2 · 6K rectangles: 36.00 fps36iPhone 14 Pro · v3 · 6K rectangles: 119.80 fps6KiPhone 14 Pro · v2 · 12K rectangles: 17.80 fps18iPhone 14 Pro · v3 · 12K rectangles: 119.80 fps12KiPhone 14 Pro · v2 · 24K rectangles: 9.20 fps9.2iPhone 14 Pro · v3 · 24K rectangles: 119.80 fps24KiPhone 14 Pro · v2 · 48K rectangles: 4.30 fps4.3iPhone 14 Pro · v3 · 48K rectangles: 119.80 fps48Kfps
rectangles
Frames per second of the UI thread, measured by a Reanimated animation running next to the canvas.

The second payoff is multiple canvases, where the rendering thread pool comes into play. Canvases work with a thread pool, so they render in parallel on separate cores. In v3, splitting a scene of 24K rectangles into two canvases draws 1.25 times as many rectangles per second as a single canvas on the Pixel 8, and 1.7 times as many on the iPhone 14 Pro, with the UI thread at 120 fps throughout. Adding canvases has its limits, though: with four, the Pixel 8 runs out of fast cores. The figure also shows that even with a single canvas, v3 is significantly faster than v2.

v2v3
Pixel 8
00.511.5Pixel 8 · v2 · 1×24K canvases × rectangles per canvas: 0.07 M rects/s0.07Pixel 8 · v3 · 1×24K canvases × rectangles per canvas: 0.40 M rects/s0.401×24KPixel 8 · v2 · 2×12K canvases × rectangles per canvas: 0.07 M rects/s0.07Pixel 8 · v3 · 2×12K canvases × rectangles per canvas: 0.50 M rects/s0.502×12KPixel 8 · v2 · 4×6K canvases × rectangles per canvas: 0.05 M rects/s0.05Pixel 8 · v3 · 4×6K canvases × rectangles per canvas: 0.24 M rects/s0.244×6KM rects/s
iPhone 14 Pro
00.511.5iPhone 14 Pro · v2 · 1×24K canvases × rectangles per canvas: 0.23 M rects/s0.23iPhone 14 Pro · v3 · 1×24K canvases × rectangles per canvas: 0.78 M rects/s0.781×24KiPhone 14 Pro · v2 · 2×12K canvases × rectangles per canvas: 0.32 M rects/s0.32iPhone 14 Pro · v3 · 2×12K canvases × rectangles per canvas: 1.34 M rects/s1.342×12KiPhone 14 Pro · v2 · 4×6K canvases × rectangles per canvas: 0.23 M rects/s0.23iPhone 14 Pro · v3 · 4×6K canvases × rectangles per canvas: 1.17 M rects/s1.174×6KM rects/s
canvases × rectangles per canvas
The same scene of 24K rectangles split across one, two or four canvases sharing the same area, so the pixels drawn are the same. Rectangles drawn per second across all canvases: frames per second per canvas × number of canvases × rectangles per canvas.

Because we removed so much technical debt from version 2, integrating Graphite with React Native almost felt like a greenfield project, and that brought performance improvements beyond the Graphite architecture itself. Frame pacing on Android is one of these improvements. In version 2, the moment a frame reached the screen depended on how long it took to draw. Graphite's two-step model, where a frame is first recorded and then submitted to the GPU, has a beautiful side effect for us: the moment we present a frame no longer depends on how long it took to draw. This means that frame pacing, a long-standing challenge for us on Android, has become a scheduling problem we can reason about.

A TextureView canvas, the default for non-opaque canvases on Android, now presents twice as many frames per second as in v2.

Pixel 8 · TextureView
0306090120Pixel 8 · TextureView · v2: 58.70 fpsv259Pixel 8 · TextureView · v3: 115.90 fpsv3116fps
One animating canvas backed by a TextureView, frames presented per second as counted by SurfaceFlinger. Pixel 8, display pinned to 120 Hz.

Finally, Skia Graphite uses fewer, more general pipelines than Ganesh, which makes them predictable enough to compile ahead of time on a background thread. And because we run Graphite on Dawn, Google's WebGPU implementation, compiled shaders and pipelines can be persisted to disk: a shader is compiled once, and later launches skip most of the compilation work. Early experiments show roughly double the frame rate when a Skia drawing loads during a navigation transition. This work, which uses the persisted shaders and pipelines to shorten the time to first frame, is still in progress.

What's next

Moving from Ganesh to Graphite brought us many direct performance improvements, most notably multithreading by default. It also helped us remove technical debt from the library and build a leaner architecture, and some of the version 3 performance improvements are a direct result of that cleanup.

Now, another important aspect of this release is the integration with WebGPU. It opens React Native Skia to a large ecosystem of libraries such as Three.js and TensorFlow.js, which now share the same GPU device and resources with it. And because we run on Dawn, we get a lot of the heavy lifting for free: support for YUV textures and GPU fences for efficient video and camera decoding and encoding. This will be the topic of part 2: stay tuned.

Please let me know if you have feedback or questions about this release, and until next time: Happy Hacking!