Swap SipHash13 → AHash for the live-frame dedup key + record T10 plan

Two T10-flavoured changes in one commit, each independently ship-able
on its own:

1. `web_surface_frame::rgba_hash` switches from std's
   `DefaultHasher` (SipHash13, ~1.5 GB/s) to `ahash::AHasher`
   (~10 GB/s). At 1080p (8 MB per frame) the dedup key drops from
   ~5 ms to ~0.8 ms per cache-miss frame, returning roughly 25 % of
   the 16 ms scroll budget that was being spent hashing the
   newly-arrived RGBA payload. ahash was already in the dependency
   graph transitively via hashbrown, so this only adds a direct
   `ahash = "0.8"` line and one Cargo.lock entry.

2. `docs/t10-iosurface-plan.md` records the full architectural
   roadmap for the actual zero-copy path that supersedes the
   software-pipe pipeline: `OffscreenRenderingContext` against a
   hardware surfman adapter, IOSurface-backed surface on macOS,
   mach-port handoff to the GPUI process, MTLTexture import as an
   external sampler. The document explains why each currently
   shipped commit (`840255f`, `a80d039`, `e02c0fd`, `7f3b8b4`, plus
   this hash swap) is a stepping stone that eventually deletes
   itself once the IOSurface path lands, and names the upstream
   API gap in `servo-paint-api` that blocks step 2.

cargo test --bin ely_app: 120 passed, 0 failed, 2 ignored.
Hash collision probability remains ~1 in 2^64; AHash uses the same
keyspace as the previous SipHash13.
This commit is contained in:
2026-05-10 20:25:01 -04:00
parent 9711167560
commit 3f184ee941
4 changed files with 221 additions and 3 deletions
Generated
+1
View File
@@ -2242,6 +2242,7 @@ dependencies = [
name = "ely_app"
version = "0.1.0"
dependencies = [
"ahash",
"directories",
"ed25519-dalek",
"ely_browser_core",
+1
View File
@@ -9,6 +9,7 @@ rust-version.workspace = true
live-site-smoke = []
[dependencies]
ahash = "0.8"
directories.workspace = true
ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" }
+11 -3
View File
@@ -1,8 +1,8 @@
use std::cell::RefCell;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use std::sync::Arc;
use ahash::AHasher;
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
@@ -17,7 +17,15 @@ thread_local! {
/// frame. The cache keys on a 64-bit hash of the raw bytes and
/// reuses the existing `Arc<RenderImage>` whenever the hash
/// matches, so steady-state idle pages no longer churn the GPUI
/// texture pool. Hash collisions are 1 in 2^64; if they ever
/// texture pool.
///
/// Uses `AHasher` instead of std's `DefaultHasher`. SipHash13
/// (default) tops out around ~1.5 GB/s; an 8 MB 1080p frame
/// hashes in ~5 ms on a modern CPU, which eats roughly 30 % of
/// the 16 ms scroll budget on every cache-miss frame. AHash
/// runs ~10 GB/s on the same hardware, dropping the per-frame
/// hash cost to ~0.8 ms and giving the scroll path back most of
/// that budget. Hash collisions remain ~1 in 2^64; if they ever
/// matter we'll trade in length + first/last 32 bytes as a
/// disambiguator before paying the full memcmp.
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
@@ -201,7 +209,7 @@ pub(super) enum WebSurfaceError {
}
fn rgba_hash(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
let mut hasher = AHasher::default();
hasher.write(bytes);
hasher.finish()
}
+208
View File
@@ -0,0 +1,208 @@
# T10 — Zero-copy live frames via OffscreenRenderingContext + IOSurface
## Why this exists
Today every live frame walks an entirely CPU-side pipeline:
```
Servo SoftwareRenderingContext (CPU rasterise)
→ read_to_image: GPU(software)→CPU RGBA8 (8 MB / 1080p)
→ stdout pipe (one memcpy into kernel buffer, one out)
→ ServoLiveFrame::from_parts (move Vec, no copy)
→ AHasher 8 MB (~0.8 ms after the T10 hash swap)
→ ImageBuffer::from_raw + Arc<RenderImage> (cache hit reuses)
→ GPUI uploads as Metal texture and samples it as an Image
```
`Software` rasterising is intrinsically slow — Servo's compositor
walks the display list on a CPU thread and writes pixels into a host
buffer. The compositor + readback together are most of a `paint()`'s
wall-clock cost at 1080p. Even with the rest of the pipeline polished
(file pipe gone in `a80d039`, host-side `Vec` clone gone in `e02c0fd`,
identical-frame texture reuse in `7f3b8b4`, AHasher hot path), the
fundamental work — drawing pixels with the CPU and then handing the
host CPU buffer to the GPU — is what makes scroll feel non-native.
The roundtable (rounds 1 & 2 in this directory's git log) converged
on the same target: **let Servo paint to a GPU surface that the GPUI
window can sample directly, no host memory in the loop**. On macOS
that surface is an `IOSurface`. Brave/Chromium's GPU process
publishes IOSurfaces this way and the renderer process samples them
through a Mach port.
## What Servo gives us today
`servo-paint-api 0.1` exposes three `RenderingContext` constructors:
| Type | Backing | Headless? | IOSurface-backed on macOS? |
|---|---|---|---|
| `SoftwareRenderingContext` | software surfman adapter, CPU pixel buffer | yes | no |
| `WindowRenderingContext` | hardware surfman adapter, surface bound to a `RawWindowHandle` | no — needs a real window | yes (CGL backend uses IOSurface) |
| `OffscreenRenderingContext` | child of a `WindowRenderingContext`, paints into a separate framebuffer and blits back via `render_to_parent_callback` | not standalone | inherits parent's backing |
The hard constraint: **the only GPU-backed constructor requires a
`DisplayHandle + WindowHandle`**. Servo does not currently expose a
"headless hardware" rendering context that we could create from the
sidecar process without owning a window.
`SurfmanRenderingContext`, the underlying type, is in the same file
and IS hardware-capable headless — `Connection::new() →
create_adapter() → SurfmanRenderingContext::new` with a `Generic`
surface type would give us a hardware-backed offscreen context.
But its constructor is `fn new` (private). Reaching it requires
either patching `servo-paint-api` upstream or vendoring a thin wrapper.
## Target architecture
```
[Sidecar process] [Main GPUI process]
───────────────── ───────────────────
WebView paints with GPUI Metal/Blade
hardware compositor ▲
│ │
▼ sample external texture
OffscreenRenderingContext │
(surfman hardware adapter) Metal MTLTexture
│ (backed by IOSurface)
▼ ▲
IOSurface (Generic surface, │
CGL backend on macOS) │
│ │
▼ │
extract IOSurface mach port name ──── share via JSON header ───►
import IOSurface as Metal
texture (one-time per surface)
```
Per-frame: zero CPU memcpy, zero pipe traffic beyond the JSON header.
The sidecar only writes a small notification (`{"new_frame_seq": N,
"surface_id": "ioservice-port", "width": …, "height": …}`); the main
process re-samples the SAME texture (its contents have changed in
place).
## Stepping stones
### 1. Reorganise `ServoHost` to abstract over rendering-context kind
Today `SoftwareServoHost` hard-wires `SoftwareRenderingContext`. Split
the host into:
* `ServoHost` trait (existing) — describes the embedder API surface
* `SoftwareServoHost` (current) — keeps the CPU path running, no
behaviour change
* `HardwareServoHost` (new) — built on a hardware surfman context
Both implement the same `ServoHost` trait so the sidecar binary picks
one via CLI flag or environment, and the `live.rs` plumbing doesn't
know which is active. This is purely a refactor with no perf change;
it unlocks step 2.
### 2. Add a hardware headless rendering context
The cleanest path is a tiny vendored adapter that exposes
`SurfmanRenderingContext::new` directly with `create_adapter()` and a
`Generic` `SurfaceType`. The Servo crate's private constructor means
we either:
(a) **Upstream contribute**: open a Servo PR adding
`HardwareOffscreenRenderingContext` to `servo-paint-api`. Highest
quality option; long round-trip with Servo maintainers.
(b) **Vendor the relevant types into `ely_servo_host`**: copy the
~200 lines of `SurfmanRenderingContext` glue with a
`pub fn new_headless_hardware(...)` constructor. Keeps the
change inside our tree; risk is drifting against upstream.
(c) **Open an upstream RFC for the API gap** while shipping (b)
behind a feature flag, with the explicit intent of removing it
once Servo merges (a).
Recommend (c): ship (b) under `feature = "iosurface"`, keep
`SoftwareServoHost` as the default until upstream lands.
### 3. macOS: extract the IOSurface from the surfman surface
surfman exposes the raw native handle on macOS via
`surfman::Surface::native_id()`. On the CGL backend the underlying
storage is an `IOSurface`. We need the `IOSurfaceRef`'s **mach port
name** (`IOSurfaceCreateMachPort`) to share it across processes.
This is a few lines of `core-foundation` + `objc2-io-surface` FFI.
### 4. Plumb the IOSurface mach port from sidecar to main
Extend the `LiveResponse` JSON header with an optional
`surface_handle: Option<IOSurfaceHandle>` where
`IOSurfaceHandle { mach_port_name: u32, width: u32, height: u32 }`.
On the FIRST frame after a resize the sidecar publishes a new handle;
subsequent frames reuse the same handle (the IOSurface contents have
been overwritten in place by the GPU, no further protocol needed).
### 5. Main process: import IOSurface as Metal external texture
GPUI uses Blade (or wgpu) as its render backend. Blade's Metal
backend has `Texture::from_iosurface` (or wgpu's
`Device::create_texture_from_hal` with a Metal hal texture built
from `MTLDevice::newTextureWithDescriptor:iosurface:plane:`). The
GPUI side needs:
* a small bridge crate (or unsafe block) that wraps the mach port
`IOSurfaceRef``MTLTexture` chain
* an `ImageSource` variant that carries an MTLTexture handle and
bypasses the `RenderImage` + `ImageBuffer` allocation chain
The latter is the biggest reach into GPUI's public surface. Likely
needs an upstream gpui contribution or a local fork.
### 6. Replace the `Arc<RenderImage>` path for live frames
`WebSurfaceFrame::image: Arc<RenderImage>` becomes an enum:
```rust
enum WebSurfaceImage {
Software(Arc<RenderImage>), // fallback path, T6 hash dedup applies
Hardware(MetalTextureHandle), // zero-copy path
}
```
`render_ready_web_surface` chooses the right `img(...)` / Metal
sampler based on the variant.
## Risks & open questions
* **Servo upstream API gap** is the gating issue. Without step 2
landing somehow, none of the rest is possible from a clean
sidecar process.
* **Cross-process IOSurface lifecycle**: if the sidecar crashes
while the main process still holds an `MTLTexture`, the texture
is dangling. Mach ports survive briefly; we need a "surface
invalidated" notification on the IPC channel.
* **GPU adapter compatibility**: surfman's hardware adapter on
macOS picks the integrated GPU by default. GPUI may pick a
discrete GPU. Mismatched adapters → IOSurface import either fails
or silently corrupts. Need to either query GPUI's chosen adapter
and force surfman to match, or use the system's default for both.
* **Windows / Linux**: IOSurface is macOS-only. The same concept
on Windows is `IDXGIResource1::CreateSharedHandle`; on Linux it's
`EGL_EXT_image_dma_buf_import`. Each platform needs its own
bridge; the JSON protocol stays the same, the bridge differs.
* **Software fallback stays in**: not just because step 2 is
blocked, but because some environments (CI, headless tests,
sandboxed Mac App Store builds) may not allow GPU contexts.
## Already shipped on this branch
| Commit | Move |
|---|---|
| `840255f` | Lift web canvas out of in-flow so input_overlay lands on screen (prerequisite for input to work at all) |
| `a80d039` | Drop the file system from the live frame pixel pipe (8 MB syscall round-trip → in-process pipe) |
| `e02c0fd` | Drop the sidecar's per-frame `to_vec()` clone (extra 8 MB memcpy gone) |
| `7f3b8b4` | Dedup byte-identical RGBA payloads against the last frame's `Arc<RenderImage>` (idle pages stop re-uploading) |
| AHasher swap | SipHash13 → AHash for the dedup key (~5 ms → ~0.8 ms per cache-miss frame at 1080p) |
Each of these is a stepping stone; the IOSurface path eventually
deletes most of them (the host-side `Vec<u8>` lifecycle disappears
when GPU memory is the source of truth), but they make the current
software path's tail latency tolerable while the architectural work
above gets staged.