Skip to content

Linux browser: WPE WebKit at the display's rate, frames shared with GPUI as dma-bufs - #1039

Merged
zeronsh[bot] merged 7 commits into
zeronsh:mainfrom
alexandre-vl:zeron/linux-in-app-browser-slow
Oct 27, 2026
Merged

zeronsh[bot] merged 7 commits into
zeronsh:mainfrom
alexandre-vl:zeron/linux-in-app-browser-slow

Conversation

@alexandre-vl

@alexandre-vl alexandre-vl commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Refs #540

On a 240 Hz display, pages in the Linux browser now show a new frame on every refresh, at every viewport size measured. That holds for a 2D canvas, WebGL 2, scrolling and CSS animation: one refresh missed in 69,086. main shows 5–60 fps and never starts a video. With dma-bufs no frame pixel goes through the CPU, and a frame costs 1–2.4 ms of CPU against up to 230 ms on main.

2849×1956 px viewport, 240 Hz main this branch (WPE dma-buf)
2D canvas / WebGL 2 / scrolling / CSS, frames shown per second 7 / 5 / 21 / 22 240 / 240 / 240 / 240
Video 1080p60, frames shown per second 0 (never starts) 60
Display refreshes without a new frame 94% 0 of 23,029
CPU per frame shown, 2D canvas 191.9 ms 2.4 ms
Frame pixels copied CPU→GPU per frame 22.29 MB 0
Press → frame painted, p50 · p95 36.8 · 44.1 ms 7.6 · 11.1 ms
Peak PSS · NVIDIA GPU memory, Zeron + WebKit 1,125 · 1,458 MiB 524 · 888 MiB

The problem

On Linux the sidebar browser ran WebKitGTK in a GtkOffscreenWindow. Each frame was read back to the CPU, converted pixel by pixel through GdkPixbuf, written whole through the helper's stdout, and uploaded by GPUI as a new image. WebKitGTK also read its scale factor before the view had a window, so on a 1.5× Wayland output it rendered at 2× (1.78× the pixels) and GTK scaled that down. Animated pages never got past 60 fps on any display, and large ones fell far below it (#540).

The change

Zeron implements a WPE WebKit platform (crates/ui/src/browser/linux/wpe.c) and runs it in the helper when the build and the system have WPE WebKit 2.50+:

zeron-wpe (helper process)                            Zeron (GPUI, Vulkan)

startup  ◀── render node + DRM modifiers GPUI's Vulkan imports (DmabufTarget)
WebKit renders on that GPU, in one of those layouts
new buffer ── dma-buf fd, SCM_RIGHTS, once ──────────▶ import once (DmabufImport)
frame ─────── buffer id + damage rects (JSON) ───────▶ copy the damage into the page's
                                                      atlas tile, on the GPU
         ◀── "vblank": each GPUI frame callback while WebKit asks for one
         ◀── "presented": at most once per display refresh (FrameDone)
         ◀── "released": once the GPU has stopped reading that buffer
Path When Frame pixels through the CPU
dma-buf the GPU renders a layout GPUI's Vulkan imports none
readback it renders none of them, or ZERON_BROWSER_DMABUF=readback the damage, read back with GLES into a sealed memfd
shared memory no usable GPU, or ZERON_BROWSER_DMABUF=0 the damage, rendered by the CPU
WebKitGTK the WPE helper is not embedded or can't start whole frames through the pipe
  • Pacing. GPUI's frame callbacks are WebKit's vblank. Like a compositor, Zeron tells WebKit a frame is on screen at most once per display refresh, however often GPUI paints. Pages run at 60, 144 or 240 Hz and never render frames the display won't show. Hidden pages, and pages showing their error, get no vblanks.
  • Ownership. A buffer goes back to WebKit only from the atlas update's completion, which runs after the GPU's last read, errors included. The tile keeps the shown frame like a front buffer, so a lost tile is refilled without asking WebKit to render.
  • Input. WebKit runs copy, cut and paste itself through a shared copy of the clipboard, gets click counts and the pointer leaving the page, and sets the cursor.
  • Failure. If the WPE helper can't start, the session uses WebKitGTK. A helper that crashes, sends a packet Zeron can't read, or is still open for writing in a forked child (ETXTBSY) is stopped, replaced or retried.
  • WebKitGTK fallback. Its helper re-reads the scale factor once the view has a window, and sends Cairo's ARGB32 surface as-is instead of converting it through GdkPixbuf.
  • Packaging. build.rs embeds the WPE helper when pkg-config finds wpe-webkit-2.0 >= 2.50 and its dependencies, and warns otherwise. The release workflows install only WebKitGTK, so release builds and CI keep using WebKitGTK until they add the WPE packages.

Dependencies: zeronsh/zui#18 (in-place atlas updates from pixels or imported dma-bufs, display-rate frame callbacks in inactive windows, a Wayland frame-loop fix) and zeronsh/gpui-component#6 (the same zui pin, so the lockfile has one gpui). Cargo.toml pins both PR heads; re-pin to their merge commits once they land.

Numbers

Release builds of main (c04e374) and this branch, measured by the same bench code. This PR's ZERON_BROWSER_BENCH and its fixture-only frame counters were ported onto main for the baseline; the port is not part of the PR.

  • Machine. Ryzen 7 9800X3D, 30 GiB, RTX 3080 (open driver 615.78.08), Linux 7.2.8, Hyprland 0.56.2 on Wayland. Dell AW2725Q at 3840×2160, 239.991 Hz, scale 1.5. WPE WebKit and WebKitGTK 2.54.1, Mesa 26.2.5, GStreamer 1.28.8.
  • Runs. 3 per cell, interleaved, in a pinned, unfocused window. Each page loads as a fresh document, warms up 2 s and is measured for 8 s. The pages are a 2D canvas of 1,500 moving squares, a WebGL 2 triangle with a full clear, a 300-section article scrolled at 600 CSS px/s, a 160 px CSS spinner on still text, and an H.264 1080p60 video.
  • Statistics. Each cell is the median of its runs. Frame intervals and presses are pooled across the cell's runs.
  • Discards. A run would be discarded if the session locked, the screensaver ran or the display slept; none were, 48 of 48 valid.
  • What is counted. Frames are counted when GPUI paints a new one. CPU is user + system time of Zeron (the fixture process) and of WebKit (every descendant: helper, web and network processes), in % of one core. PSS comes from smaps_rollup, GPU memory from nvidia-smi.
  • Presses. Each press is dispatched through GPUI's input path onto a still page whose square changes colour on press. The time runs from dispatch to the first frame GPUI paints.
  • The WebKitGTK column is this branch's fallback.

Frames shown per second

Page, viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
2D canvas, 899×906 39 60 240 240 240
2D canvas, 1649×1386 16 60 240 240 240
2D canvas, 2849×1956 7 26 240 119 131
WebGL 2, 899×906 44 60 240 240 240
WebGL 2, 1649×1386 15 60 240 240 240
WebGL 2, 2849×1956 5 25 240 160 139
Scrolling, 899×906 59 60 240 240 240
Scrolling, 1649×1386 60 60 240 240 240
Scrolling, 2849×1956 21 60 240 160 139
CSS spinner, 899×906 60 60 240 240 240
CSS spinner, 1649×1386 60 60 240 240 240
CSS spinner, 2849×1956 22 60 240 240 218
Video 1080p60, 899×906 0 0 60 60 60
Video 1080p60, 1649×1386 0 0 60 60 60
Video 1080p60, 2849×1956 0 0 60 60 60

Display refreshes without a new frame · p99 interval between frames

Viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
899×906 80% · 32.6 ms 76% · 32.2 ms 0.00% · 4.3 ms 0.02% · 4.6 ms 0.00% · 4.7 ms
1649×1386 84% · 80.6 ms 76% · 32.4 ms 0.00% · 4.3 ms 0.11% · 6.0 ms 0.01% · 4.8 ms
2849×1956 94% · 204.7 ms 82% · 48.1 ms 0.00% · 4.3 ms 31% · 10.0 ms 41% · 10.8 ms

CPU, Zeron + WebKit (% of one core) · CPU time per frame shown

Page, viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
2D canvas, 899×906 122% · 31.3 ms 58% · 9.7 ms 52% · 2.2 ms 79% · 3.3 ms 80% · 3.3 ms
2D canvas, 1649×1386 128% · 80.3 ms 128% · 21.3 ms 54% · 2.2 ms 141% · 5.9 ms 132% · 5.5 ms
2D canvas, 2849×1956 129% · 191.9 ms 135% · 51.0 ms 56% · 2.4 ms 142% · 11.9 ms 175% · 13.4 ms
WebGL 2, 899×906 128% · 29.3 ms 52% · 8.7 ms 35% · 1.5 ms 58% · 2.4 ms 55% · 2.3 ms
WebGL 2, 1649×1386 129% · 88.5 ms 128% · 21.4 ms 39% · 1.6 ms 106% · 4.4 ms 108% · 4.5 ms
WebGL 2, 2849×1956 127% · 230.2 ms 138% · 55.1 ms 40% · 1.7 ms 141% · 8.8 ms 148% · 10.6 ms
Scrolling, 899×906 41% · 6.9 ms 15% · 2.5 ms 32% · 1.3 ms 55% · 2.3 ms 52% · 2.2 ms
Scrolling, 1649×1386 112% · 18.7 ms 31% · 5.3 ms 33% · 1.4 ms 103% · 4.3 ms 106% · 4.4 ms
Scrolling, 2849×1956 111% · 52.8 ms 86% · 14.4 ms 33% · 1.4 ms 135% · 8.5 ms 147% · 10.6 ms
CSS spinner, 899×906 53% · 8.9 ms 18% · 3.0 ms 25% · 1.0 ms 35% · 1.5 ms 40% · 1.7 ms
CSS spinner, 1649×1386 114% · 19.0 ms 31% · 5.2 ms 27% · 1.1 ms 39% · 1.6 ms 65% · 2.7 ms
CSS spinner, 2849×1956 110% · 49.4 ms 76% · 12.7 ms 27% · 1.1 ms 45% · 1.9 ms 114% · 5.2 ms

MB copied into the page's atlas tile per frame shown

Page, viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
Full repaint, 899×906 3.26 CPU→GPU 3.26 CPU→GPU 3.26 GPU→GPU 3.26 CPU→GPU 3.26 CPU→GPU
Full repaint, 1649×1386 9.14 CPU→GPU 9.14 CPU→GPU 9.15 GPU→GPU 9.15 CPU→GPU 9.15 CPU→GPU
Full repaint, 2849×1956 22.29 CPU→GPU 22.29 CPU→GPU 22.29 GPU→GPU 22.30 CPU→GPU 22.30 CPU→GPU
CSS spinner, 899×906 3.26 CPU→GPU 3.26 CPU→GPU 0.64 GPU→GPU 0.64 CPU→GPU 0.64 CPU→GPU
CSS spinner, 1649×1386 9.14 CPU→GPU 9.14 CPU→GPU 1.18 GPU→GPU 1.18 CPU→GPU 1.18 CPU→GPU
CSS spinner, 2849×1956 22.29 CPU→GPU 22.29 CPU→GPU 2.40 GPU→GPU 2.40 CPU→GPU 2.61 CPU→GPU

Peak memory over a run, MiB: PSS · NVIDIA GPU memory (Zeron + WebKit)

Viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
899×906 531 · 286 483 · 166 501 · 206 523 · 201 526 · 214
1649×1386 741 · 658 561 · 337 509 · 457 560 · 443 579 · 486
2849×1956 1,125 · 1,458 759 · 670 524 · 888 639 · 840 662 · 938

Press → frame painted, ms: p50 · p95 · max (120 presses per cell)

Viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
899×906 13.8 · 20.4 · 20.8 8.4 · 16.7 · 17.4 6.3 · 11.0 · 20.1 7.2 · 11.5 · 15.8 7.9 · 14.3 · 27.2
1649×1386 21.1 · 27.8 · 28.9 10.2 · 18.0 · 18.4 6.8 · 9.7 · 21.5 8.5 · 13.1 · 25.4 10.9 · 13.5 · 20.8
2849×1956 36.8 · 44.1 · 47.3 14.5 · 20.6 · 22.4 7.6 · 11.1 · 12.4 8.8 · 11.8 · 14.8 13.9 · 17.5 · 23.0

Idle page: CPU · frames per second

Viewport (px) main WebKitGTK WPE dma-buf WPE readback WPE shm
899×906 0.4% · 0 0.4% · 0 0.4% · 0 0.2% · 0 0.4% · 0
1649×1386 0.2% · 0 0.2% · 0 0.4% · 0 0.4% · 0 0.2% · 0
2849×1956 0.4% · 0 0.4% · 0 0.2% · 0 0.4% · 0 0.2% · 0

Other configurations (WPE dma-buf unless stated):

Configuration 2D canvas / WebGL 2 / scrolling / CSS / video, fps Refreshes without a new frame Press → frame, p50 · p95
X11 (Xwayland), 1365×900 px, main (2 runs) 60 / 60 / 60 / 60 / 0 76% 20.2 · 28.8 ms
X11 (Xwayland), 1365×900 px, this branch (2 runs) 240 / 240 / 240 / 240 / 60 0.00% 5.9 · 11.0 ms
GPUI and WebKit on the Radeon 610M iGPU (ZED_DEVICE_ID=0x13c0), 1649×1386 px, modifier 0x0200000000401b03 (3 runs) 149 / 153 / 174 / 181 / 59 33% 4.9 · 10.3 ms

The iGPU has 2 compute units and the display hangs off the RTX 3080, so the compositor copies every frame across GPUs. It still renders without readback.

Correctness

End to end, browser-fixture (debug build) on DP-1. It checks input, menus, resizes, sidebar transitions, frosted overlays, captures, the page's frame rate against the display's, and WebKit's buffer count.

Run Result Page frames / display frames per second Buffers shared
Wayland, WPE dma-buf, focused (clipboard checked) ✓ 240 / 240 4
Wayland, WPE dma-buf ✓ 240 / 240 5
Wayland, WPE readback ✓ 240 / 240 1
Wayland, WPE shared memory ✓ 240 / 240 1
Wayland, WebKitGTK, focused (clipboard checked) ✓ 60 / 240 —
Wayland, WebKitGTK ✓ 60 / 239 —
X11 (Xwayland), WPE dma-buf ✓ 240 / 240 5
X11 (Xwayland), WebKitGTK ✓ 60 / 240 —
  • WebGL 1 and 2 work on both engines. A WebGL 1 context cleared to (0.25, 0.5, 0.75) reads back [64,127,191,255].
  • The real helper renders on both GPUs, in every layout their drivers render: 12 NVIDIA modifiers (0x0300000000606010–15, 0x0300000000e08010–15) and 4 AMD ones (0x0200000000401b03, 0x0200000000401603, 0x0200000000000901, linear). It also reads frames back, and renders in shared memory.
  • Given 240, 144 and 60 Hz vblanks, pages render 243, 147 and 63 frames/s. The test window counts its edges. Whether GPUI paints at vblanks or as frames arrive, WebKit renders one frame per vblank with 2 buffers.
  • WebKitGTK never starts the video, on main and here: networkState stays NETWORK_NO_SOURCE with no error, although canPlayType answers probably. Not investigated.

Tests

  • New, browser::linux::wpe (24): packet framing and fd pairing, oversized packets, buffer and mirror validation, damage clamping and merging, vblank rate detection, scroll scaling, cursors. Against the real helper:
    • shared memory, every GPU and layout, and vblank rates;
    • at most one frame per vblank however often GPUI paints;
    • input and clipboard, independent pages and a clean exit;
    • a page opened after the last one closed, and resizes releasing buffers;
    • a helper that dies at startup, one Zeron can't read, and one still busy being written (ETXTBSY);
    • no vblanks for a page showing its error, and vblanks again for a page shown after hiding;
    • the profile lock, as for WebKitGTK.
  • cargo test -p zeron-ui --lib browser::linux -- --include-ignored --test-threads=1: 31 passed. Before the rebase, 16 back-to-back runs of the 42 browser tests all passed.
  • CI's ui-tests command (cargo nextest … -p zeron-ui --lib --test voice_orb_geometry), locally: 1950 passed, 12 skipped.
  • CI on 9b37860: 23 passed, 3 skipped. CI has no WPE packages, so linux-browser ran the WebKitGTK fallback on Xvfb and weston, Vulkan and OpenGL.

Review

Three rounds of adversarial review (Claude Fable 5.1, read-only):

  • A page shown again after hiding could stay frozen: WebKit still wanted vblanks but nothing restarted them. present now wakes the surface. The fix is mutation-checked by a test.
  • A buffer could go back to WebKit before the GPU stopped reading it on error paths. release is now always carried by an atlas completion, errors included.
  • A page whose frames can't be shown spun on vblanks. It now shows its error and gets none.
  • A frame the atlas rejected left the next frame's damage incomplete. The next frame is now copied whole.
  • A helper that stopped while a page was being created, or before it attached, left the page unaware. Both cases now surface the error.
  • ETXTBSY when a forked child still held a freshly extracted helper open: the spawn now retries with backoff.
  • A reported tile leak was not one: close evicts the image.

#540 acceptance criteria

  • ✓ No full-frame CPU readback, BGRA conversion or pipe streaming on the accelerated path. dma-buf frames copy 0 B CPU→GPU.
  • ✓ WebGL 1 and 2, CSS animation, scrolling, video and overlays, on Wayland and X11. With WebKitGTK video never starts, as on main.
  • ✓ Resize, scale 1.5 (Wayland) and 1.75 (X11), multiple pages, hide and show, input, helper crashes.
  • ⚠ GPU device loss: tiles are refilled and buffers re-imported, but no real device loss was tested.
  • ✓ Buffer ownership and sync tested. WebKit keeps 4 to 6 buffers per page (the fixture fails above 6), and peak GPU memory is above.
  • ✓ CPU, frame pacing, latency, GPU memory and bytes per frame compared with main at three viewport sizes.
  • ⚠ NVIDIA and AMD verified, with readback, shared memory and WebKitGTK fallbacks. Intel not tested (no hardware).
  • ⚠ macOS: zui only adds API (default trait impls), and macOS CI passes. Not run on a Mac.

Not verified

Intel GPUs, a real GPU device loss, a release package with WPE WebKit (the workflows don't install it yet), and macOS or Windows at runtime (CI only).

@alexandre-vl
alexandre-vl marked this pull request as ready for review October 26, 2026 08:24
@zeronsh
zeronsh Bot force-pushed the zeron/linux-in-app-browser-slow branch 3 times, most recently from 1a8a732 to bb8fb59 Compare October 27, 2026 03:38
alexandre-vl and others added 7 commits October 27, 2026 04:57
…PUI as dma-bufs

The sidebar browser on Linux ran WebKitGTK in an offscreen window: every
frame went through the CPU, over a pipe, into a new GPUI image, so pages
topped out near 60 fps whatever the display, and large ones fell well below.

Zeron now carries its own WPE WebKit platform (browser/linux/wpe.c) and runs
it in the helper when the build and the system have WPE WebKit 2.50+:

- WebKit renders on the GPU GPUI draws with, into dma-bufs in a DRM format
  modifier GPUI's Vulkan device imports. GPUI copies only each frame's
  damaged regions into the page's atlas tile, on the GPU (zeronsh/zui#18).
- Where the driver renders no layout Vulkan imports, the helper reads frames
  back with OpenGL ES into a sealed memfd. Without a GPU, WebKit renders on
  the CPU into shared memory. Both update the tile in place.
- GPUI's frame callbacks are the pages' vblank: WebKit hears a frame is on
  screen at most once per display refresh, so pages run at 60, 144 or 240 Hz
  and never render frames the display won't show. Hidden pages, and pages
  replaced by their error, get none.
- WebKit runs copy, cut and paste itself, sets the pointer's cursor, and sees
  click counts and the pointer leaving the page.

If the WPE helper can't start, the session uses WebKitGTK. Its helper now
renders at the output's scale (it rendered 4x the pixels at 1.5x on Wayland
and downscaled them) and sends Cairo's surface as-is instead of converting it
through GdkPixbuf, which took most of its CPU.

A helper that crashes, sends a message Zeron can't read or is still being
written by a forked child (ETXTBSY) is stopped, replaced or retried; the
tests run the real helper on every render node, in every layout its driver
renders, read back and in shared memory, at 60, 144 and 240 Hz.
`ZERON_BROWSER_BENCH` measured one page's frame rate and CPU. It now loads
a fresh document for each of a 2D canvas, WebGL 2, scrolling, a CSS spinner
and, with `ZERON_BROWSER_BENCH_VIDEO`, a 60 fps video, and records for each:
the page's frame rate, when GPUI painted every new frame, the bytes copied
into the page's tile from CPU memory and between GPU buffers, CPU, PSS and
NVIDIA GPU memory for Zeron and for WebKit's processes, and a capture. It
then times 40 presses from GPUI's input to the frame showing them, and
measures an idle page. `ZERON_BROWSER_BENCH_WIDTH` sizes the browser.

The counts are kept only in fixture builds (`browser-fixture`), for both
engines, so WebKitGTK and every WPE path are measured the same way.

`ZERON_BROWSER_DMABUF=readback` keeps WebKit on the GPU but reads its frames
back, the path GPUs that render no importable layout take.
The WebGL page's benchInfo() clears a WebGL 1 context and reads a pixel back, so a bench run records that both WebGL versions render.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
The WPE helper kept the last clipboard text Zeron shared, and Zeron shared
only text. Pasting with an image or nothing on the system clipboard then
inserted that earlier text, possibly into another page. Zeron now shares
the clipboard on every paste, and empty text clears the helper's copy.

build.rs embeds an empty WPE helper, as when WPE is missing, if wpe.c does
not compile against the installed development files (an older GBM), and
detects them again when PKG_CONFIG_PATH changes. probe_gpu releases the GBM
device and render node it opened when the GPU is unusable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
…ers idle frame callbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: zeronsh[bot] <346184242+zeronsh[bot]@users.noreply.github.com>
@zeronsh
zeronsh Bot force-pushed the zeron/linux-in-app-browser-slow branch from cc6fcd6 to 266c0c6 Compare October 27, 2026 05:14

@zeronsh zeronsh Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security audit: no malicious code, network calls, telemetry, binary blobs or CI changes. The WPE helper's navigation/permission/download policy matches the WebKitGTK helper's line for line; developer extras off, all permissions denied. The zui (#18) and gpui-component (#6) re-pins add only the existing crates.io ash. Fixed: a paste without text no longer pastes the helper's earlier clipboard copy (with a test); build.rs falls back to WebKitGTK if wpe.c fails to compile; probe_gpu leak. Verified on the rebased head: zeron-ui lib tests, the WebKitGTK linux-browser regressions (X11/Wayland × Vulkan/GL), and on WPE WebKit 2.54 (Arch rootfs, shared-memory path, no GPU here) all browser::linux tests plus the fixture on X11 and cage. dma-buf zero-copy was not exercised (no GPU). Follow-up: land zui#18 / gpui-component#6 and re-pin, since b564a34/59f8924 exist only in the contributor's forks.

@zeronsh
zeronsh Bot merged commit c31124a into zeronsh:main Oct 27, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants