git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit0b2c87015a580bdd9d18c7b4c762dc22f9eae02f
parenta981c586e6
authorLucas Galante <[email protected]>
date2026-09-10 10:05
perf: stop copying the page three times per frame

Scrolling was bounded by memory traffic, not by the engine. Every frame of
page content made three full-buffer trips through the CPU and forced two GPU
stalls, at 35 MB a frame on this 3840x2400 display — about 38 ms of copying
before any real work, a ~26 fps ceiling fullscreen.

Three of them are gone:

- read_shm no longer swizzles BGRA to RGBA. WebKit's ARGB8888 is BGRA in
  memory and is now handed to the registry as PixelFormat::Bgra, which the
  sampler reads at no cost. 7.4 ms a frame.
- read_shm no longer allocates. The destination comes from recycle_buffer,
  and the frame sink refills the superseded frame's buffer instead of
  dropping it, so a burst where the engine outruns pump does not allocate per
  discarded frame. 4.5 ms a frame, mostly zeroing and page faults.
- pump no longer clones the frame. The clone existed to let sample_pixel
  answer with a top-left pixel, which only examples/wpe_dark.rs asks for;
  last_pixel keeps the three bytes. 7 ms a frame.

And the upload now goes through cce_ui::vk::update_pixels when the tab
already has an image that size, replacing the texture's contents in place
rather than creating a new image and freeing the old one — that free waited
for the whole device to go idle, once per frame. The create path is left for
a resize or a tab's first frame.

Verified in a headless shadow at scale 2: a known-colour page reads back
exact (255,0,0 / 0,255,0 / 0,0,255 / 255,200,0 sampled from the screenshot),
so the BGRA change is not a channel swap and the sRGB round trip is intact;
correct rendering across a floating/fullscreen resize round trip, which is
the recreate path; clean scrolling on a long page; no Vulkan validation
errors. 29 tests pass.

Needs cce-ui a9c2c78.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md       | 37 +++++++++++++++++++++++
 src/wpe/host.rs | 92 ++++++++++++++++++++++++++++++++++++++++++---------------
 2 files changed, 105 insertions(+), 24 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 08aae92..0061f3b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -115,6 +115,43 @@ Two consequences worth holding onto:
   time-based (see the force-dark reload deadlines) has to spawn a thread that sends
   `Message::Spin` when the deadline passes, or it simply never fires.
 
+## The frame pipeline on WPE
+
+WebKit renders into a **mappable SHM buffer** (`toplevel_formats` asks for
+one; DMABuf import is still the Phase 2 in WPE-PORT.md), `read_shm` copies it
+out, and the pixels are drawn as one full-bleed quad. What that path costs is
+worth knowing, because it is paid on **every frame of every scroll** and this
+display is 3840x2400 — 35 MB a frame fullscreen.
+
+Three things it deliberately does *not* do any more, each measured at that
+size before it went:
+
+- **No CPU swizzle.** `WPE_PIXEL_FORMAT_ARGB8888` is BGRA in memory and is
+  handed over as `PixelFormat::Bgra`; the sampler reads either channel order
+  at no cost. Rearranging the bytes cost **7.4 ms a frame**.
+- **No per-frame allocation.** The destination comes from
+  `cce_ui::vk::recycle_buffer`, and the sink refills the *superseded* frame's
+  buffer rather than dropping it — when the engine outruns `pump`, which is
+  exactly when frames are being thrown away, allocating a new buffer each time
+  would be the most expensive possible way to discard work. A fresh Vec per
+  frame was **4.5 ms**, nearly all zeroing and page faults.
+- **No copy for `sample_pixel`.** It used to clone the whole frame to answer a
+  three-byte question (only `examples/wpe_dark.rs` asks); `last_pixel` keeps
+  the three bytes instead. That clone was **7 ms a frame**.
+
+And on the GPU side `pump` calls `update_pixels` when the tab already has an
+image of the same size, so the frame replaces the contents of one texture
+instead of creating an image and freeing last frame's — that free took
+`device_wait_idle`, once per frame. Only a resize (or a tab's first frame)
+takes the create path.
+
+What is left per frame: one memcpy out of SHM (whole-buffer when the stride is
+tight, per row otherwise), one memcpy into the shared staging buffer, and the
+transfer. The remaining `queue_wait_idle` inside the toolkit's update is
+explained there. **Do not reintroduce a `Vec` allocation, a swizzle, or a
+second copy on this path without measuring** — the numbers above are what each
+one costs.
+
 ## Tabs
 
 One `WebView` per tab, all sharing the single rendering context; only the active one
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 489f6d3..dd0860b 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -148,7 +148,8 @@ pub struct WebKitHost {
     watcher: Option<*mut WebKitUserScript>,
     /// Retained only so tests can assert on rendered output; the registry
     /// owns the copy that actually gets drawn.
-    last_frame: Option<(Vec<u8>, u32, u32)>,
+    /// Top-left pixel of the last frame — three bytes, not the frame.
+    last_pixel: Option<(u8, u8, u8)>,
     /// Installed on every webview when force-dark is on.
     ucm: *mut WebKitUserContentManager,
     /// A pre-built hidden webview parked on about:blank, WebProcess already
@@ -260,9 +261,15 @@ impl WebKitHost {
             let pending = Rc::new(std::cell::RefCell::new(Pending::default()));
             let sink = pending.clone();
             FRAME_SINK = Some(Box::new(move |buffer: *mut WPEBuffer| {
-                if let Some(f) = read_shm(buffer) {
-                    // Replace, never accumulate: the newest frame wins.
-                    sink.borrow_mut().frame = Some(f);
+                let mut slot = sink.borrow_mut();
+                // Replace, never accumulate: the newest frame wins. The
+                // superseded frame's buffer is refilled rather than dropped —
+                // when the engine outruns `pump`, which is exactly when frames
+                // are being thrown away, allocating a new one each time would
+                // be the most expensive possible way to discard work.
+                let previous = slot.frame.take().map(|(px, ..)| px);
+                if let Some(f) = read_shm(buffer, previous) {
+                    slot.frame = Some(f);
                 }
             }));
 
@@ -293,7 +300,7 @@ impl WebKitHost {
                 session,
                 download_started,
                 prompts,
-                last_frame: None,
+                last_pixel: None,
                 ucm: webkit_user_content_manager_new(),
                 watcher: None,
                 spare: None,
@@ -667,11 +674,26 @@ impl WebKitHost {
         let Some((px, w, h)) = frame else {
             return (false, dirty);
         };
-        self.last_frame = Some((px.clone(), w, h));
-        let id = cce_ui::vk::upload_rgba(px, w, h);
+        // The one pixel anything actually reads back (see `sample_pixel`),
+        // kept instead of a copy of the whole frame. Cloning 35 MB per frame
+        // to serve a three-byte question cost 7 ms of every frame.
+        self.last_pixel = (px.len() >= 4).then(|| (px[2], px[1], px[0]));
         let tab = &mut self.tabs[self.active];
-        if let Some((old, ..)) = tab.image.replace((id, w, h)) {
-            cce_ui::vk::free_image(old);
+        match tab.image {
+            // Same tab, same size: replace the contents of the image that is
+            // already there. No allocation, no descriptor, and above all no
+            // image freed — freeing one waits for the whole device to go idle,
+            // which on this path meant once per frame.
+            Some((id, iw, ih)) if (iw, ih) == (w, h) => {
+                cce_ui::vk::update_pixels(id, px, w, h, cce_ui::vk::PixelFormat::Bgra);
+            }
+            _ => {
+                let id = cce_ui::vk::upload_pixels(px, w, h, cce_ui::vk::PixelFormat::Bgra);
+                if let Some((old, ..)) = tab.image.replace((id, w, h)) {
+                    cce_ui::vk::free_image(old);
+                }
+                tab.image = Some((id, w, h));
+            }
         }
         (true, true)
     }
@@ -700,8 +722,7 @@ impl WebKitHost {
     /// Top-left pixel of the last frame, for tests that need to assert on
     /// what was actually rendered rather than on what was configured.
     pub fn sample_pixel(&self) -> Option<(u8, u8, u8)> {
-        let (px, ..) = self.last_frame.as_ref()?;
-        Some((px[0], px[1], px[2]))
+        self.last_pixel
     }
 
     pub fn image(&self) -> Option<(u32, u32, u32)> {
@@ -1129,11 +1150,25 @@ impl WebKitHost {
     }
 }
 
-/// Copy an SHM buffer's pixels out as RGBA for `upload_rgba`.
+/// Copy an SHM buffer's pixels out for the image registry.
+///
+/// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, which
+/// is handed over **as BGRA** rather than swizzled: the sampler reads either
+/// channel order at no cost, and rearranging 35 MB of bytes per frame on the
+/// CPU cost 7.4 ms at this display's fullscreen size — most of a frame budget,
+/// spent on nothing.
+///
+/// The destination comes from `cce_ui::vk::recycle_buffer`, so in the steady
+/// state this allocates nothing: a fresh frame-sized `Vec` per frame was
+/// another 4.5 ms, almost all of it zeroing and page faults rather than
+/// copying. What remains is one memcpy per row, and only when the stride
+/// forces it — a tight stride is copied whole.
 ///
-/// `WPE_PIXEL_FORMAT_ARGB8888` is B,G,R,A in memory on little-endian, and the
-/// stride is not assumed to equal `width * 4`.
-unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
+/// The stride is not assumed to equal `width * 4`.
+unsafe fn read_shm(
+    buffer: *mut WPEBuffer,
+    reuse: Option<Vec<u8>>,
+) -> Option<(Vec<u8>, u32, u32)> {
     if g_type_check_instance_is_a(buffer as *mut GTypeInstance, wpe_buffer_shm_get_type()) == 0 {
         return None;
     }
@@ -1148,15 +1183,24 @@ unsafe fn read_shm(buffer: *mut WPEBuffer) -> Option<(Vec<u8>, u32, u32)> {
         return None;
     }
     let stride = wpe_buffer_shm_get_stride(shm) as usize;
-    let mut out = vec![0u8; (w * h * 4) as usize];
-    for y in 0..h as usize {
-        for x in 0..w as usize {
-            let s = src.add(y * stride + x * 4);
-            let d = (y * w as usize + x) * 4;
-            out[d] = *s.add(2);
-            out[d + 1] = *s.add(1);
-            out[d + 2] = *s;
-            out[d + 3] = *s.add(3);
+    let row = w as usize * 4;
+    let need = row * h as usize;
+    if (len as usize) < stride * (h as usize - 1) + row {
+        return None;
+    }
+    let mut out = match reuse {
+        Some(mut buf) if buf.len() == need => {
+            // Every byte below is overwritten, so nothing has to be cleared.
+            buf.truncate(need);
+            buf
+        }
+        _ => cce_ui::vk::recycle_buffer(need),
+    };
+    if stride == row {
+        std::ptr::copy_nonoverlapping(src, out.as_mut_ptr(), need);
+    } else {
+        for y in 0..h as usize {
+            std::ptr::copy_nonoverlapping(src.add(y * stride), out.as_mut_ptr().add(y * row), row);
         }
     }
     Some((out, w, h))