From b128989a3eed60471f25c03cba28f5a3a8741458 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:08:58 +0000 Subject: [PATCH 01/13] Add GPUI frame benchmarks with interleaved A/B runs on macOS, Windows and Linux Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gpui-bench.yml | 228 ++++++++ apps/desktop-gpui/gpui-benches/Cargo.toml | 22 + .../gpui-benches/benches/frame.rs | 538 ++++++++++++++++++ apps/desktop-gpui/gpui-benches/src/lib.rs | 1 + scripts/gpui-bench-build.sh | 62 ++ scripts/gpui-bench-compare.py | 152 +++++ 6 files changed, 1003 insertions(+) create mode 100644 .github/workflows/gpui-bench.yml create mode 100644 apps/desktop-gpui/gpui-benches/Cargo.toml create mode 100644 apps/desktop-gpui/gpui-benches/benches/frame.rs create mode 100644 apps/desktop-gpui/gpui-benches/src/lib.rs create mode 100755 scripts/gpui-bench-build.sh create mode 100755 scripts/gpui-bench-compare.py diff --git a/.github/workflows/gpui-bench.yml b/.github/workflows/gpui-bench.yml new file mode 100644 index 00000000000..e347d1bf155 --- /dev/null +++ b/.github/workflows/gpui-bench.yml @@ -0,0 +1,228 @@ +name: GPUI frame benchmarks + +# Builds Cap's GPUI frame benchmarks (apps/desktop-gpui/gpui-benches) twice on +# each platform — against the base patch stack and against this commit's — and +# runs the two builds interleaved on the same machine. A change to the GPUI +# patch stack shows up as a per-benchmark head/base ratio in the job summary. +# The same jobs run gpui's own test suites against this commit's stack. + +on: + pull_request: + paths: + - "apps/desktop-gpui/patches/**" + - "apps/desktop-gpui/gpui-benches/**" + - "scripts/prepare-gpui-dependency.sh" + - "scripts/gpui-bench-*" + - ".github/workflows/gpui-bench.yml" + push: + branches: + - "gpui-perf/**" + workflow_dispatch: + inputs: + base: + description: "Ref whose patch stack is the baseline (default: the merge base with main)" + default: "" + rounds: + description: "Interleaved rounds per side" + default: "5" + +concurrency: + group: gpui-bench-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + bench: + name: GPUI benchmarks (${{ matrix.settings.name }}) + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + slug: macos + runner: blacksmith-6vcpu-macos-15 + - name: Windows + slug: windows + runner: blacksmith-8vcpu-windows-2025 + - name: Linux + slug: linux + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/../gpui-bench-target + CARGO_PROFILE_BENCH_DEBUG: "false" + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Linux dependencies + if: ${{ runner.os == 'Linux' }} + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential pkg-config clang libclang-dev \ + libx11-dev libx11-xcb-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers libssl-dev libasound2-dev \ + libzstd-dev fonts-dejavu-core + + - name: Allow long paths in Zed's git dependencies + if: ${{ runner.os == 'Windows' }} + run: git config --global core.longpaths true + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Prepare head and base GPUI checkouts + shell: bash + env: + BASE_INPUT: ${{ inputs.base }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + ./scripts/prepare-gpui-dependency.sh + base="${BASE_INPUT:-}" + if [[ -z "$base" ]]; then + base="$(git merge-base HEAD "${PR_BASE:-origin/main}")" + fi + echo "Head $(git rev-parse --short HEAD), base patch stack from $(git rev-parse --short "$base")" | tee -a "$GITHUB_STEP_SUMMARY" + git worktree add --detach "$RUNNER_TEMP/base/cap" "$base" + (cd "$RUNNER_TEMP/base/cap" && ./scripts/prepare-gpui-dependency.sh) + echo "ZED_HEAD=$(cd ../zed-cap && pwd)" >> "$GITHUB_ENV" + echo "ZED_LOCK_HASH=$(python -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' ../zed-cap/Cargo.lock)" >> "$GITHUB_ENV" + echo "ZED_BASE=$RUNNER_TEMP/base/zed-cap" >> "$GITHUB_ENV" + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/../gpui-bench-target + key: gpui-bench-v1-${{ runner.os }}-${{ env.ZED_LOCK_HASH }}-${{ github.run_id }} + restore-keys: | + gpui-bench-v1-${{ runner.os }}-${{ env.ZED_LOCK_HASH }}- + gpui-bench-v1-${{ runner.os }}- + + - name: Build benchmarks (base) + shell: bash + run: ./scripts/gpui-bench-build.sh "$ZED_BASE" "$RUNNER_TEMP/bench/base" + + - name: Build benchmarks (head) + shell: bash + run: ./scripts/gpui-bench-build.sh "$ZED_HEAD" "$RUNNER_TEMP/bench/head" + + - name: Measure + shell: bash + env: + ROUNDS: ${{ inputs.rounds || '5' }} + run: | + python scripts/gpui-bench-compare.py \ + --base "$RUNNER_TEMP/bench/base" \ + --head "$RUNNER_TEMP/bench/head" \ + --out "$RUNNER_TEMP/bench/runs" \ + --rounds "$ROUNDS" \ + --label "${{ matrix.settings.name }}" \ + --summary "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: gpui-bench-${{ matrix.settings.slug }} + path: | + ${{ runner.temp }}/bench/runs/*.md + ${{ runner.temp }}/bench/runs/*.json + ${{ runner.temp }}/bench/runs/*.log + if-no-files-found: ignore + + test: + name: GPUI tests (${{ matrix.settings.name }}) + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + runner: blacksmith-6vcpu-macos-15 + - name: Windows + runner: blacksmith-8vcpu-windows-2025 + - name: Linux + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Linux dependencies + if: ${{ runner.os == 'Linux' }} + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential pkg-config clang libclang-dev \ + libx11-dev libx11-xcb-dev libxcb1-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers libegl1 libegl-mesa0 \ + libgl1-mesa-dri libssl-dev libasound2-dev libzstd-dev fonts-dejavu-core + + - name: Allow long paths in Zed's git dependencies + if: ${{ runner.os == 'Windows' }} + run: git config --global core.longpaths true + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Prepare GPUI dependency + shell: bash + run: | + ./scripts/prepare-gpui-dependency.sh + echo "ZED_LOCK_HASH=$(python -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' ../zed-cap/Cargo.lock)" >> "$GITHUB_ENV" + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ${{ github.workspace }}/../zed-cap/target + key: gpui-test-v1-${{ runner.os }}-${{ env.ZED_LOCK_HASH }}-${{ github.run_id }} + restore-keys: | + gpui-test-v1-${{ runner.os }}-${{ env.ZED_LOCK_HASH }}- + gpui-test-v1-${{ runner.os }}- + + - name: gpui tests + shell: bash + working-directory: ../zed-cap + run: cargo +1.95.0 test -p gpui --features test-support --lib --tests --no-fail-fast + + - name: Platform crate tests + shell: bash + working-directory: ../zed-cap + env: + GPUI_REQUIRE_TEST_DEVICE: "1" + run: | + set -euo pipefail + if [[ "$RUNNER_OS" == "Linux" ]]; then + WGPU_BACKEND=vulkan cargo +1.95.0 test -p gpui_wgpu --lib --no-fail-fast + elif [[ "$RUNNER_OS" == "macOS" ]]; then + cargo +1.95.0 test -p gpui_macos --lib --no-fail-fast + else + cargo +1.95.0 test -p gpui_windows --lib --no-fail-fast + fi diff --git a/apps/desktop-gpui/gpui-benches/Cargo.toml b/apps/desktop-gpui/gpui-benches/Cargo.toml new file mode 100644 index 00000000000..31a0d5d9163 --- /dev/null +++ b/apps/desktop-gpui/gpui-benches/Cargo.toml @@ -0,0 +1,22 @@ +# Frame benchmarks for Cap's GPUI fork. This crate is not part of the +# desktop-gpui workspace: `scripts/gpui-bench.sh` copies it into the prepared +# zed checkout (`crates/gpui_cap_benches`) and runs it there, so it builds +# against exactly the patched gpui the app ships and against Zed's lockfile. +[package] +name = "gpui_cap_benches" +version = "0.1.0" +edition.workspace = true +publish = false +license = "Apache-2.0" + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +criterion.workspace = true +gpui = { workspace = true, features = ["bench"] } +gpui_platform = { workspace = true, features = ["font-kit", "test-support", "wayland", "x11"] } + +[[bench]] +name = "frame" +harness = false diff --git a/apps/desktop-gpui/gpui-benches/benches/frame.rs b/apps/desktop-gpui/gpui-benches/benches/frame.rs new file mode 100644 index 00000000000..52acfdec733 --- /dev/null +++ b/apps/desktop-gpui/gpui-benches/benches/frame.rs @@ -0,0 +1,538 @@ +//! Whole-frame benchmarks for Cap's GPUI fork. +//! +//! Every benchmark drives real frames through `Window::draw` (render, layout, +//! prepaint, paint, scene finish) on the platform's own text system; on macOS +//! the frame is also submitted to Metal through the headless renderer. The +//! workloads mirror the shapes Cap's windows draw: cards with borders and +//! shadows, label-heavy rows, a recordings list that scrolls, a timer whose +//! text changes every frame, hover over many hitboxes, and cached panels where +//! only one view changes per frame. + +use std::borrow::Cow; + +use gpui::{ + AnyView, BenchAppContext, BorderStyle, Bounds, BoxShadow, Corners, Entity, MouseMoveEvent, + PathBuilder, PlatformInput, SharedString, StyleRefinement, UniformListScrollHandle, Window, + canvas, div, point, prelude::*, px, quad, rgb, size, uniform_list, +}; + +const IBM_PLEX: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); +const FONT: &str = "IBM Plex Sans"; + +const TITLES: &[&str] = &[ + "Product demo", + "Bug report: export stalls at 99%", + "Weekly sync", + "Onboarding walkthrough", + "Design review — timeline", + "Customer call", + "Quick note", + "Release checklist", +]; + +const DETAILS: &[&str] = &[ + "2 minutes ago · 1920×1080 · 60 fps", + "Yesterday · 3840×2160 · 30 fps", + "Mon 14:02 · Studio mode", + "Shared with 4 people · 12 views", + "Uploading… 48%", + "Last edited by you", +]; + +const PARAGRAPH: &str = "Cap records your screen, camera and microphone, then lets you trim, \ +zoom, add captions and share a link in seconds. The editor keeps every clip, segment and \ +caption in sync with the timeline so that a scrub, a cut or a zoom lands exactly where you \ +expect it to, on every platform, at every frame rate."; + +const PALETTE: [u32; 4] = [0x4785ff, 0xff6b6b, 0x2ecc71, 0xf5a623]; + +fn load_fonts(cx: &mut BenchAppContext) { + cx.update(|cx| { + cx.text_system() + .add_fonts(vec![Cow::Borrowed(IBM_PLEX)]) + .expect("failed to load benchmark font"); + }); +} + +fn card(ix: usize) -> impl IntoElement { + div() + .id(ix) + .w(px(180.)) + .h(px(64.)) + .flex() + .flex_col() + .gap_1() + .p_2() + .rounded_md() + .border_1() + .border_color(rgb(0x2a2a33)) + .bg(rgb(0x1b1b22)) + .shadow_sm() + .hover(|style| style.bg(rgb(0x24242e))) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .size(px(10.)) + .rounded_full() + .bg(rgb(PALETTE[ix % PALETTE.len()])), + ) + .child(TITLES[ix % TITLES.len()]), + ) + .child( + div() + .text_color(rgb(0x8a8a99)) + .text_size(px(11.)) + .child(DETAILS[ix % DETAILS.len()]), + ) +} + +struct Cards { + count: usize, +} + +impl Render for Cards { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_wrap() + .gap_2() + .p_4() + .bg(rgb(0x101014)) + .text_color(rgb(0xf0f0f5)) + .font_family(FONT) + .text_size(px(13.)) + .children((0..self.count).map(card)) + } +} + +/// Full re-render of a wrapping grid of styled cards (borders, rounded corners, +/// shadows, two labels each): layout-, style- and scene-heavy. +#[gpui::bench(inputs = [64usize, 256, 1024], group = "cards")] +fn cards(count: &usize, cx: &mut BenchAppContext) { + load_fonts(cx); + let count = *count; + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| Cards { count }) + .expect("root view"); + window.run_until_idle(); + window + .app_context() + .bench_renderer(view, |_, _, cx| cx.notify()); +} + +struct TextRows { + rows: usize, + changing: bool, + frame: u64, +} + +impl Render for TextRows { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let frame = self.frame; + let changing = self.changing; + div() + .size_full() + .flex() + .flex_col() + .bg(rgb(0x101014)) + .text_color(rgb(0xf0f0f5)) + .font_family(FONT) + .text_size(px(13.)) + .children((0..self.rows).map(move |ix| { + let stamp: SharedString = if changing { + let ms = frame * 16 + ix as u64 * 37; + format!( + "{:02}:{:02}.{:03}", + ms / 60_000, + (ms / 1000) % 60, + ms % 1000 + ) + .into() + } else { + SharedString::new_static("00:42.016") + }; + div() + .flex() + .flex_row() + .gap_4() + .h(px(20.)) + .px_2() + .child(div().w(px(72.)).child(stamp)) + .child(div().w(px(260.)).child(TITLES[ix % TITLES.len()])) + .child( + div() + .flex_1() + .text_color(rgb(0x8a8a99)) + .child(DETAILS[ix % DETAILS.len()]), + ) + })) + } +} + +/// Label-heavy rows whose text is identical every frame: exercises the line +/// layout cache hit path and glyph painting. +#[gpui::bench(inputs = [50usize, 200], group = "text_rows_static")] +fn text_rows_static(rows: &usize, cx: &mut BenchAppContext) { + load_fonts(cx); + let rows = *rows; + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| TextRows { + rows, + changing: false, + frame: 0, + }) + .expect("root view"); + window.run_until_idle(); + window + .app_context() + .bench_renderer(view, |_, _, cx| cx.notify()); +} + +/// The same rows with a timestamp column that changes every frame, like a +/// recording timer or a timeline ruler: one fresh shaping per row per frame. +#[gpui::bench(inputs = [50usize, 200], group = "text_rows_changing")] +fn text_rows_changing(rows: &usize, cx: &mut BenchAppContext) { + load_fonts(cx); + let rows = *rows; + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| TextRows { + rows, + changing: true, + frame: 0, + }) + .expect("root view"); + window.run_until_idle(); + window.app_context().bench_renderer(view, |view, _, cx| { + view.frame += 1; + cx.notify(); + }); +} + +struct Paragraphs { + count: usize, +} + +impl Render for Paragraphs { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_wrap() + .gap_4() + .p_4() + .bg(rgb(0x101014)) + .text_color(rgb(0xf0f0f5)) + .font_family(FONT) + .text_size(px(14.)) + .children((0..self.count).map(|ix| { + div() + .w(px(300. + (ix % 5) as f32 * 20.)) + .child(SharedString::new_static(PARAGRAPH)) + })) + } +} + +/// Wrapped paragraphs in columns of several widths: line wrapping and +/// multi-line text layout. +#[gpui::bench(inputs = [12usize, 48], group = "paragraphs")] +fn paragraphs(count: &usize, cx: &mut BenchAppContext) { + load_fonts(cx); + let count = *count; + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| Paragraphs { count }) + .expect("root view"); + window.run_until_idle(); + window + .app_context() + .bench_renderer(view, |_, _, cx| cx.notify()); +} + +struct Recordings { + scroll: UniformListScrollHandle, + frame: u64, +} + +impl Render for Recordings { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .bg(rgb(0x101014)) + .text_color(rgb(0xf0f0f5)) + .font_family(FONT) + .text_size(px(13.)) + .child( + uniform_list("recordings", 10_000, |range, _, _| { + range + .map(|ix| { + div() + .id(ix) + .h(px(28.)) + .px_2() + .flex() + .items_center() + .gap_2() + .hover(|style| style.bg(rgb(0x1b1b22))) + .child( + div() + .size(px(16.)) + .rounded_sm() + .bg(rgb(PALETTE[ix % PALETTE.len()])), + ) + .child(SharedString::from(format!("Recording {ix:05}.cap"))) + .child( + div() + .text_color(rgb(0x8a8a99)) + .child(DETAILS[ix % DETAILS.len()]), + ) + }) + .collect::>() + }) + .track_scroll(&self.scroll) + .size_full(), + ) + } +} + +/// A 10 000-row virtualized list scrolled a few pixels every frame. +#[gpui::bench] +fn recordings_scroll(cx: &mut BenchAppContext) { + load_fonts(cx); + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| Recordings { + scroll: UniformListScrollHandle::new(), + frame: 0, + }) + .expect("root view"); + window.run_until_idle(); + window.app_context().bench_renderer(view, |view, _, cx| { + view.frame += 1; + let y = (view.frame * 7 % 200_000) as f32; + view.scroll + .0 + .borrow() + .base_handle + .set_offset(point(px(0.), px(-y))); + cx.notify(); + }); +} + +/// Pointer moves across a grid of hoverable cards; every move changes the +/// hovered card, so each one dispatches through every mouse listener, hit +/// tests every hitbox and redraws. +#[gpui::bench] +fn hover_cards(cx: &mut BenchAppContext) { + load_fonts(cx); + let mut window = cx.add_empty_window(); + window + .replace_root_view(|_, _| Cards { count: 256 }) + .expect("root view"); + window.run_until_idle(); + let handle = window.window_handle(); + let mut step = 0usize; + window.app_context().bench_iter(move |cx| { + step += 1; + // Alternate between the centres of the first cards of two rows. + let x = 16. + 90. + (step % 8) as f32 * 188.; + let y = 16. + 32. + (step % 2) as f32 * 72.; + cx.update(|cx| { + handle + .update(cx, |_, window, cx| { + window.dispatch_event( + PlatformInput::MouseMove(MouseMoveEvent { + position: point(px(x), px(y)), + pressed_button: None, + modifiers: Default::default(), + }), + cx, + ); + }) + .ok(); + }); + cx.update(|cx| { + handle + .update(cx, |_, window, _| window.present_if_needed()) + .ok(); + }); + }); +} + +struct Panel { + ix: usize, + value: u64, +} + +impl Render for Panel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .p_2() + .flex() + .flex_col() + .gap_1() + .rounded_md() + .border_1() + .border_color(rgb(0x2a2a33)) + .bg(rgb(0x1b1b22)) + .child(TITLES[self.ix % TITLES.len()]) + .children((0..5).map(|row| { + div() + .flex() + .gap_2() + .child(div().w(px(80.)).child(DETAILS[(self.ix + row) % DETAILS.len()])) + .child(SharedString::from(format!("{}", self.value + row as u64))) + })) + } +} + +struct Panels { + panels: Vec>, +} + +impl Render for Panels { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_wrap() + .gap_2() + .p_4() + .bg(rgb(0x101014)) + .text_color(rgb(0xf0f0f5)) + .font_family(FONT) + .text_size(px(12.)) + .children(self.panels.iter().map(|panel| { + AnyView::from(panel.clone()) + .cached(StyleRefinement::default().w(px(220.)).h(px(140.))) + })) + } +} + +/// Forty cached panels where one changes per frame: the view-cache reuse path +/// (`reuse_prepaint` / `reuse_paint`) that most interactive frames take. +#[gpui::bench] +fn cached_panels(cx: &mut BenchAppContext) { + load_fonts(cx); + let mut window = cx.add_empty_window(); + let panels: Vec> = (0..40) + .map(|ix| window.new(|_| Panel { ix, value: 0 })) + .collect(); + window + .replace_root_view({ + let panels = panels.clone(); + move |_, _| Panels { panels } + }) + .expect("root view"); + window.run_until_idle(); + let mut step = 0usize; + let handle = window.window_handle(); + window.app_context().bench_iter(move |cx| { + step += 1; + let panel = panels[step % panels.len()].clone(); + cx.update(|cx| { + panel.update(cx, |panel, cx| { + panel.value += 1; + cx.notify(); + }); + }); + cx.update(|cx| { + handle + .update(cx, |_, window, _| window.present_if_needed()) + .ok(); + }); + }); +} + +struct Primitives { + quads: usize, +} + +impl Render for Primitives { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let quads = self.quads; + canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let columns = 64usize; + let cell = size( + bounds.size.width / columns as f32, + bounds.size.height / (quads / columns).max(1) as f32, + ); + let shadow = [BoxShadow { + color: gpui::hsla(0., 0., 0., 0.3), + offset: point(px(0.), px(1.)), + blur_radius: px(3.), + spread_radius: px(0.), + inset: false, + }]; + for ix in 0..quads { + let origin = point( + bounds.origin.x + cell.width * (ix % columns) as f32, + bounds.origin.y + cell.height * (ix / columns) as f32, + ); + let cell_bounds = Bounds::new(origin, cell); + if ix % 8 == 0 { + window.paint_drop_shadows(cell_bounds, Corners::all(px(4.)), &shadow); + } + window.paint_quad(quad( + cell_bounds, + px(4.), + rgb(PALETTE[ix % PALETTE.len()]), + px(1.), + rgb(0x2a2a33), + BorderStyle::Solid, + )); + if ix % 16 == 0 { + let mut path = PathBuilder::fill(); + path.move_to(origin); + path.line_to(point(origin.x + cell.width, origin.y)); + path.line_to(point(origin.x, origin.y + cell.height)); + path.close(); + if let Ok(path) = path.build() { + window.paint_path(path, rgb(0xffffff)); + } + } + } + }, + ) + .size_full() + } +} + +/// Thousands of quads, shadows and paths painted straight from a canvas: +/// scene insertion, sorting, batching and (on macOS) GPU submission. +#[gpui::bench(inputs = [1024usize, 8192], group = "primitives")] +fn primitives(quads: &usize, cx: &mut BenchAppContext) { + let quads = *quads; + let mut window = cx.add_empty_window(); + let view = window + .replace_root_view(|_, _| Primitives { quads }) + .expect("root view"); + window.run_until_idle(); + window + .app_context() + .bench_renderer(view, |_, _, cx| cx.notify()); +} + +gpui::bench_group!( + benches, + cards, + text_rows_static, + text_rows_changing, + paragraphs, + recordings_scroll, + hover_cards, + cached_panels, + primitives +); +gpui::bench_main!(benches); diff --git a/apps/desktop-gpui/gpui-benches/src/lib.rs b/apps/desktop-gpui/gpui-benches/src/lib.rs new file mode 100644 index 00000000000..5017ee2f699 --- /dev/null +++ b/apps/desktop-gpui/gpui-benches/src/lib.rs @@ -0,0 +1 @@ +//! Frame benchmarks for Cap's GPUI fork live in `benches/`. diff --git a/scripts/gpui-bench-build.sh b/scripts/gpui-bench-build.sh new file mode 100755 index 00000000000..f2e79369512 --- /dev/null +++ b/scripts/gpui-bench-build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Builds Cap's GPUI frame benchmarks (apps/desktop-gpui/gpui-benches) against +# a prepared zed checkout and copies the benchmark executable to . +# +# scripts/gpui-bench-build.sh +# +# The bench crate is copied into /crates/gpui_cap_benches and added to +# that workspace, so it compiles against exactly the patched gpui in +# and Zed's own lockfile. Set CARGO_TARGET_DIR to share dependency builds +# between checkouts. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +zed_dir="$(cd "$1" && pwd)" +out_dir="$2" +bench_src="$repo_root/apps/desktop-gpui/gpui-benches" +bench_dst="$zed_dir/crates/gpui_cap_benches" + +mkdir -p "$bench_dst" "$out_dir" +cp -R "$bench_src/." "$bench_dst/" + +python_bin="$(command -v python3 || command -v python)" +"$python_bin" - "$zed_dir/Cargo.toml" <<'PY' +import sys +path = sys.argv[1] +text = open(path, encoding="utf-8").read() +member = ' "crates/gpui_cap_benches",\n' +if member not in text: + anchor = ' "crates/gpui_windows",\n' + if anchor not in text: + sys.exit("error: could not find the gpui_windows workspace member to anchor on") + text = text.replace(anchor, anchor + member, 1) + open(path, "w", encoding="utf-8", newline="\n").write(text) +PY + +cd "$zed_dir" +messages="$out_dir/cargo-messages.json" +cargo +1.95.0 bench -p gpui_cap_benches --bench frame --no-run --message-format json > "$messages" +executable="$("$python_bin" - "$messages" <<'PY' +import json, sys +found = None +for line in open(sys.argv[1], encoding="utf-8"): + try: + message = json.loads(line) + except ValueError: + continue + if ( + message.get("reason") == "compiler-artifact" + and message.get("target", {}).get("name") == "frame" + and message.get("executable") + ): + found = message["executable"] +if not found: + sys.exit("error: cargo reported no executable for the frame benchmark") +print(found) +PY +)" +case "$executable" in + *.exe) cp "$executable" "$out_dir/frame.exe" ;; + *) cp "$executable" "$out_dir/frame" ;; +esac +echo "Built $(basename "$executable") from $zed_dir into $out_dir" diff --git a/scripts/gpui-bench-compare.py b/scripts/gpui-bench-compare.py new file mode 100755 index 00000000000..43cabc4865f --- /dev/null +++ b/scripts/gpui-bench-compare.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Interleaved A/B run of two builds of Cap's GPUI frame benchmarks. + + python scripts/gpui-bench-compare.py --base --head --out + [--rounds 5] [--measurement-time 3] [--filter REGEX] [--summary FILE] + + holds the `frame` (or `frame.exe`) executable built by +scripts/gpui-bench-build.sh. Both executables run on the same machine in +alternating order (base, head, head, base, ...) so drift in machine state lands +on both sides. Every run writes Criterion's estimates under its own +CRITERION_HOME; each benchmark is compared by the per-round ratio of Criterion +median times (head / base), paired by round. +""" + +import argparse +import json +import os +import statistics +import subprocess +import sys +from pathlib import Path + + +def executable(directory: Path) -> Path: + for name in ("frame.exe", "frame"): + candidate = directory / name + if candidate.exists(): + return candidate + sys.exit(f"error: no frame benchmark executable in {directory}") + + +def run(binary: Path, home: Path, args: argparse.Namespace, log: Path) -> dict: + home.mkdir(parents=True, exist_ok=True) + command = [ + str(binary), + "--bench", + "--noplot", + "--warm-up-time", + str(args.warm_up_time), + "--measurement-time", + str(args.measurement_time), + ] + if args.filter: + command.append(args.filter) + env = dict(os.environ, CRITERION_HOME=str(home)) + with open(log, "w", encoding="utf-8") as output: + result = subprocess.run( + command, env=env, stdout=output, stderr=subprocess.STDOUT, cwd=home + ) + if result.returncode != 0: + sys.stdout.write(log.read_text(encoding="utf-8", errors="replace")[-8000:]) + sys.exit(f"error: {binary} exited with {result.returncode}; log: {log}") + estimates = {} + for benchmark_json in home.rglob("new/benchmark.json"): + full_id = json.loads(benchmark_json.read_text(encoding="utf-8"))["full_id"] + data = json.loads( + (benchmark_json.parent / "estimates.json").read_text(encoding="utf-8") + ) + estimates[full_id] = data["median"]["point_estimate"] + if not estimates: + sys.exit(f"error: {binary} wrote no Criterion estimates under {home}") + return estimates + + +def format_time(nanos: float) -> str: + if nanos >= 1e6: + return f"{nanos / 1e6:.2f} ms" + if nanos >= 1e3: + return f"{nanos / 1e3:.1f} µs" + return f"{nanos:.0f} ns" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--base", type=Path, required=True) + parser.add_argument("--head", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--rounds", type=int, default=5) + parser.add_argument("--warm-up-time", type=float, default=1.0) + parser.add_argument("--measurement-time", type=float, default=3.0) + parser.add_argument("--filter", default="") + parser.add_argument("--label", default="") + parser.add_argument("--summary", type=Path) + args = parser.parse_args() + + binaries = {"base": executable(args.base), "head": executable(args.head)} + samples = {"base": [], "head": []} + args.out.mkdir(parents=True, exist_ok=True) + for round_ix in range(args.rounds): + order = ["base", "head"] if round_ix % 2 == 0 else ["head", "base"] + for side in order: + home = args.out / side / f"round-{round_ix}" + log = args.out / f"{side}-round-{round_ix}.log" + print(f"round {round_ix + 1}/{args.rounds}: {side}", flush=True) + samples[side].append(run(binaries[side], home, args, log)) + + names = sorted(set(samples["base"][0]) & set(samples["head"][0])) + rows = [] + for name in names: + base = [estimates[name] for estimates in samples["base"]] + head = [estimates[name] for estimates in samples["head"]] + ratios = [h / b for b, h in zip(base, head)] + median_ratio = statistics.median(ratios) + if max(ratios) < 1.0: + verdict = "faster" + elif min(ratios) > 1.0: + verdict = "slower" + else: + verdict = "~" + rows.append( + { + "benchmark": name, + "base_ns": statistics.median(base), + "head_ns": statistics.median(head), + "median_ratio": median_ratio, + "min_ratio": min(ratios), + "max_ratio": max(ratios), + "verdict": verdict, + "base_samples": base, + "head_samples": head, + } + ) + + (args.out / "comparison.json").write_text(json.dumps(rows, indent=2), encoding="utf-8") + title = f"GPUI frame benchmarks{f' ({args.label})' if args.label else ''}" + lines = [ + f"### {title}", + "", + f"{args.rounds} interleaved rounds; Criterion median per round, head/base paired by round. " + "**faster**/**slower** only when every round agrees.", + "", + "| Benchmark | Base | Head | Change | Round range | |", + "|---|---:|---:|---:|---:|---|", + ] + for row in rows: + change = (row["median_ratio"] - 1.0) * 100.0 + spread = f"{(row['min_ratio'] - 1) * 100:+.1f}% … {(row['max_ratio'] - 1) * 100:+.1f}%" + verdict = {"faster": "**faster**", "slower": "**slower**"}.get(row["verdict"], "") + lines.append( + f"| `{row['benchmark']}` | {format_time(row['base_ns'])} | {format_time(row['head_ns'])} " + f"| {change:+.1f}% | {spread} | {verdict} |" + ) + report = "\n".join(lines) + "\n" + print(report) + (args.out / "comparison.md").write_text(report, encoding="utf-8") + if args.summary: + with open(args.summary, "a", encoding="utf-8") as summary: + summary.write(report + "\n") + + +if __name__ == "__main__": + main() From 944b5ebe2a0bfe6d9ef903c622c7514e70349c4b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:27:03 +0000 Subject: [PATCH 02/13] Bound Criterion samples per benchmark round Co-Authored-By: Claude Opus 5.5 --- scripts/gpui-bench-compare.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/gpui-bench-compare.py b/scripts/gpui-bench-compare.py index 43cabc4865f..5cc6c3d12e0 100755 --- a/scripts/gpui-bench-compare.py +++ b/scripts/gpui-bench-compare.py @@ -2,7 +2,8 @@ """Interleaved A/B run of two builds of Cap's GPUI frame benchmarks. python scripts/gpui-bench-compare.py --base --head --out - [--rounds 5] [--measurement-time 3] [--filter REGEX] [--summary FILE] + [--rounds 5] [--measurement-time 2] [--sample-size 20] [--filter REGEX] + [--summary FILE] holds the `frame` (or `frame.exe`) executable built by scripts/gpui-bench-build.sh. Both executables run on the same machine in @@ -39,6 +40,8 @@ def run(binary: Path, home: Path, args: argparse.Namespace, log: Path) -> dict: str(args.warm_up_time), "--measurement-time", str(args.measurement_time), + "--sample-size", + str(args.sample_size), ] if args.filter: command.append(args.filter) @@ -77,7 +80,12 @@ def main() -> None: parser.add_argument("--out", type=Path, required=True) parser.add_argument("--rounds", type=int, default=5) parser.add_argument("--warm-up-time", type=float, default=1.0) - parser.add_argument("--measurement-time", type=float, default=3.0) + parser.add_argument("--measurement-time", type=float, default=2.0) + # Criterion's default of 100 linearly growing samples runs a 10 ms frame + # 5 050 times per benchmark regardless of --measurement-time; 20 samples + # keep a round to about a minute while the paired rounds carry the + # statistics. + parser.add_argument("--sample-size", type=int, default=20) parser.add_argument("--filter", default="") parser.add_argument("--label", default="") parser.add_argument("--summary", type=Path) From 7c8eb20d5a20310564f0f2f090434da6a0f2b341 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:27:03 +0000 Subject: [PATCH 03/13] Add an interleaved A/B run of the Cap editor's draw benchmark Co-Authored-By: Claude Opus 5.5 --- .github/workflows/gpui-app-bench.yml | 146 ++++++++++++++++++++++++ scripts/gpui-app-bench.py | 163 +++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 .github/workflows/gpui-app-bench.yml create mode 100755 scripts/gpui-app-bench.py diff --git a/.github/workflows/gpui-app-bench.yml b/.github/workflows/gpui-app-bench.yml new file mode 100644 index 00000000000..c4393b6274d --- /dev/null +++ b/.github/workflows/gpui-app-bench.yml @@ -0,0 +1,146 @@ +name: GPUI app benchmark + +# Builds the real Cap app (cap-gpui) twice on macOS — against the base GPUI +# patch stack and against this commit's — and runs the editor's own timeline +# benchmark (src/editor_bench.rs) with each build, interleaved, on the +# reference recording from CapSoftware/cap-performance-fixtures. A change to the +# GPUI patch stack shows up as a per-scenario `Window::draw` ratio in the job +# summary. + +on: + pull_request: + paths: + - "apps/desktop-gpui/patches/**" + - "scripts/gpui-app-bench.py" + - ".github/workflows/gpui-app-bench.yml" + push: + branches: + - "gpui-perf/**" + workflow_dispatch: + inputs: + base: + description: "Ref whose patch stack is the baseline (default: the merge base with main)" + default: "" + rounds: + description: "Interleaved rounds per side" + default: "3" + +concurrency: + group: gpui-app-bench-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + editor: + name: Editor draw benchmark (macOS) + runs-on: blacksmith-6vcpu-macos-15 + timeout-minutes: 150 + env: + CARGO_PROFILE_DEV_DEBUG: "0" + TARGET: aarch64-apple-darwin + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout the reference recording + uses: actions/checkout@v4 + with: + repository: CapSoftware/cap-performance-fixtures + path: performance-fixtures + persist-credentials: false + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ env.TARGET }} + + - uses: ./.github/actions/setup-rust-cache + with: + target: ${{ env.TARGET }} + prefix-key: v1-rust-gpui + save-cache: "false" + workspaces: apps/desktop-gpui -> target + + - name: Run setup + shell: bash + run: node scripts/setup.js + env: + RUST_TARGET_TRIPLE: ${{ env.TARGET }} + + - name: Prepare head and base GPUI checkouts + shell: bash + env: + BASE_INPUT: ${{ inputs.base }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + node scripts/sync-desktop-versions.mjs + ./scripts/prepare-gpui-dependency.sh + base="${BASE_INPUT:-}" + if [[ -z "$base" ]]; then + base="$(git merge-base HEAD "${PR_BASE:-origin/main}")" + fi + echo "Head $(git rev-parse --short HEAD), base patch stack from $(git rev-parse --short "$base")" | tee -a "$GITHUB_STEP_SUMMARY" + git worktree add --detach "$RUNNER_TEMP/base/cap" "$base" + (cd "$RUNNER_TEMP/base/cap" && ./scripts/prepare-gpui-dependency.sh) + + - name: Build head and base + shell: bash + run: | + set -euo pipefail + zed="$(cd .. && pwd)/zed-cap" + bin_dir="$GITHUB_WORKSPACE/apps/desktop-gpui/target/$TARGET/debug" + build() { + # The app patches gpui to ../zed-cap, so each build swaps the + # checkout in. Cargo decides freshness of path dependencies by + # mtime, so touch the swapped-in sources to force a rebuild. + find "$zed/crates" -type f -name '*.rs' -exec touch {} + + cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked \ + --target "$TARGET" --bin cap-gpui + cp "$bin_dir/cap-gpui" "$bin_dir/cap-gpui-$1" + } + build head + mv "$zed" "$zed-head" + mv "$RUNNER_TEMP/base/zed-cap" "$zed" + build base + mv "$zed" "$RUNNER_TEMP/base/zed-cap" + mv "$zed-head" "$zed" + for frameworks in "$bin_dir/Frameworks" "$GITHUB_WORKSPACE/apps/desktop-gpui/target/$TARGET/Frameworks"; do + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + done + if cmp -s "$bin_dir/cap-gpui-head" "$bin_dir/cap-gpui-base"; then + echo "Head and base builds are identical (no GPUI change)." | tee -a "$GITHUB_STEP_SUMMARY" + fi + + - name: Keep the display awake + run: | + sudo pmset -a displaysleep 0 sleep 0 || true + caffeinate -u -t 2 + + - name: Measure + shell: bash + env: + ROUNDS: ${{ inputs.rounds || '3' }} + run: | + bin_dir="$GITHUB_WORKSPACE/apps/desktop-gpui/target/$TARGET/debug" + python3 scripts/gpui-app-bench.py \ + --base "$bin_dir/cap-gpui-base" \ + --head "$bin_dir/cap-gpui-head" \ + --fixture "$GITHUB_WORKSPACE/performance-fixtures/reference-recording.cap" \ + --out "$RUNNER_TEMP/app-bench" \ + --rounds "$ROUNDS" \ + --label macOS \ + --summary "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: gpui-app-bench-macos + path: ${{ runner.temp }}/app-bench + if-no-files-found: ignore diff --git a/scripts/gpui-app-bench.py b/scripts/gpui-app-bench.py new file mode 100755 index 00000000000..a4ff3e530a3 --- /dev/null +++ b/scripts/gpui-app-bench.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Interleaved A/B run of Cap's in-app editor timeline benchmark. + + python scripts/gpui-app-bench.py --base --head \ + --fixture --out [--rounds 3] [--summary FILE] + +Each run launches the real app on a fresh profile and a fresh copy of the +fixture project with CAP_GPUI_AUTO_TIMELINE_BENCH=1 (src/editor_bench.rs), +which times `Window::draw` across the editor's scenarios and exits. Base and +head alternate (base, head, head, base, ...) on the same machine; scenarios +are compared by the per-round ratio of mean draw times (head / base). +""" + +import argparse +import json +import os +import re +import shutil +import statistics +import subprocess +import sys +import tempfile +from pathlib import Path + +LINE = re.compile( + r"^timeline-bench: (?P