diff --git a/.github/workflows/gpui-app-bench.yml b/.github/workflows/gpui-app-bench.yml new file mode 100644 index 00000000000..8e414de31c6 --- /dev/null +++ b/.github/workflows/gpui-app-bench.yml @@ -0,0 +1,142 @@ +name: GPUI app benchmark + +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: "5" + +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) + # Benchmarks build GPUI twice and run for up to two hours, so pull requests + # from forks do not start them; maintainers can run them with workflow_dispatch. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + 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 || '5' }} + 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/.github/workflows/gpui-bench.yml b/.github/workflows/gpui-bench.yml new file mode 100644 index 00000000000..024f986805f --- /dev/null +++ b/.github/workflows/gpui-bench.yml @@ -0,0 +1,254 @@ +name: GPUI frame benchmarks + +on: + pull_request: + paths: + - "apps/desktop-gpui/patches/**" + - "apps/desktop-gpui/gpui-benches/**" + - "scripts/prepare-gpui-dependency.sh" + - "scripts/gpui-bench-*" + - "scripts/gpui-scene-digest.py" + - ".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 }}) + # Benchmarks build GPUI twice and run for up to two hours, so pull requests + # from forks do not start them; maintainers can run them with workflow_dispatch. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + 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 }}- + + # Both sides build from the same checkout path, swapped in turn. Paths + # end up in symbol hashes and panic locations, so building them from two + # paths gives two code layouts that differ by a few percent with + # identical sources. Cargo judges path dependencies by mtime, so the + # swapped-in sources are touched to force the rebuild. + - name: Build benchmarks + shell: bash + run: | + set -euo pipefail + build() { + find "$ZED_HEAD/crates" -type f -name '*.rs' -exec touch {} + + ./scripts/gpui-bench-build.sh "$ZED_HEAD" "$RUNNER_TEMP/bench/$1" + } + mv "$ZED_HEAD" "$ZED_HEAD.head" + mv "$ZED_BASE" "$ZED_HEAD" + build base + mv "$ZED_HEAD" "$ZED_BASE" + mv "$ZED_HEAD.head" "$ZED_HEAD" + build head + if cmp -s "$RUNNER_TEMP/bench/base/frame"* "$RUNNER_TEMP/bench/head/frame"*; then + echo "Base and head benchmark builds are identical." | tee -a "$GITHUB_STEP_SUMMARY" + fi + + - 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" + + # Only macOS has a headless renderer that sees submitted frames; the + # step reports a skip elsewhere. + - name: Scene equivalence + shell: bash + run: | + python scripts/gpui-scene-digest.py \ + --base "$RUNNER_TEMP/bench/base" \ + --head "$RUNNER_TEMP/bench/head" \ + --out "$RUNNER_TEMP/bench/digests" \ + --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 }}) + # Same as the benchmarks: GPUI's test builds are long, so forks do not start them. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + 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/.github/workflows/gpui-profile.yml b/.github/workflows/gpui-profile.yml new file mode 100644 index 00000000000..e2551ae8309 --- /dev/null +++ b/.github/workflows/gpui-profile.yml @@ -0,0 +1,79 @@ +name: GPUI frame profile + +on: + push: + branches: + - "gpui-profile/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + profile: + name: GPUI callgrind profile + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 120 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/../gpui-bench-target + CARGO_PROFILE_BENCH_DEBUG: "line-tables-only" + CARGO_PROFILE_BENCH_LTO: "false" + CARGO_PROFILE_BENCH_CODEGEN_UNITS: "16" + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install dependencies + 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 valgrind + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + + - name: Prepare GPUI dependency + run: ./scripts/prepare-gpui-dependency.sh + + - name: Build benchmarks + run: ./scripts/gpui-bench-build.sh ../zed-cap "$RUNNER_TEMP/bench" + + - name: Profile + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/profile" + cd "$RUNNER_TEMP/profile" + for bench in "cards_1024" "text_rows_static_200" \ + "text_rows_changing_200" "paragraphs_48" \ + "recordings_scroll" "hover_cards" "cached_panels" "primitives_8192"; do + name="$(echo "$bench" | tr '/' '_')" + CRITERION_HOME="$RUNNER_TEMP/profile/criterion" valgrind --tool=callgrind \ + --callgrind-out-file="$name.callgrind" --dump-instr=no \ + "$RUNNER_TEMP/bench/frame" --bench --profile-time 15 "^${bench}\$" > "$name.log" 2>&1 \ + || { tail -50 "$name.log"; exit 1; } + callgrind_annotate --inclusive=yes --threshold=99 "$name.callgrind" > "$name.inclusive.txt" 2>/dev/null || true + callgrind_annotate --inclusive=no --threshold=99 "$name.callgrind" > "$name.self.txt" 2>/dev/null || true + { + echo "## $bench" + echo + echo '### Self' + echo '```' + awk 'NR > 20 && NR <= 80' "$name.self.txt" | cut -c1-220 + echo '```' + } > "$name.md" + cat "$name.md" + done + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: gpui-profile + path: | + ${{ runner.temp }}/profile/*.md + ${{ runner.temp }}/profile/*.txt + ${{ runner.temp }}/profile/*.callgrind + if-no-files-found: ignore diff --git a/apps/desktop-gpui/gpui-benches/Cargo.toml b/apps/desktop-gpui/gpui-benches/Cargo.toml new file mode 100644 index 00000000000..2c21077849a --- /dev/null +++ b/apps/desktop-gpui/gpui-benches/Cargo.toml @@ -0,0 +1,24 @@ +# 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] +anyhow.workspace = true +criterion.workspace = true +gpui = { workspace = true, features = ["bench"] } +gpui_platform = { workspace = true, features = ["font-kit", "test-support", "wayland", "x11"] } +image.workspace = true + +[[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..d94721059a0 --- /dev/null +++ b/apps/desktop-gpui/gpui-benches/benches/frame.rs @@ -0,0 +1,758 @@ +//! `GPUI_SCENE_DIGEST=` appends one hash per submitted frame (primitives in +//! renderer order plus the atlas pixels their sprites show) for +//! `scripts/gpui-scene-digest.py` to compare between builds. + +use std::{ + borrow::Cow, + collections::{HashMap, hash_map::DefaultHasher}, + fs::OpenOptions, + hash::{Hash, Hasher}, + io::Write, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use criterion::Criterion; +use gpui::{ + AnyView, AtlasKey, AtlasTextureId, AtlasTile, BenchAppContext, BenchReport, BorderStyle, + Bounds, BoxShadow, Corners, DevicePixels, Entity, MouseMoveEvent, PathBuilder, PlatformAtlas, + PlatformHeadlessRenderer, PlatformInput, Scene, SharedString, StreamFrame, StreamingImageId, + 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)) + } +} + +fn cards(count: usize, cx: &mut BenchAppContext) { + load_fonts(cx); + 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()]), + ) + })) + } +} + +fn text_rows_static(rows: usize, cx: &mut BenchAppContext) { + load_fonts(cx); + 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()); +} + +fn text_rows_changing(rows: usize, cx: &mut BenchAppContext) { + load_fonts(cx); + 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)) + })) + } +} + +fn paragraphs(count: usize, cx: &mut BenchAppContext) { + load_fonts(cx); + 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(), + ) + } +} + +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(); + }); +} + +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; + 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.))) + })) + } +} + +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() + } +} + +fn primitives(quads: usize, cx: &mut BenchAppContext) { + 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()); +} + +// One benchmark per size: GPUI's frame report (the `Window::draw` histogram) +// covers one benchmark function, so separate functions keep each size's frame +// cost apart. + +fn cards_64(cx: &mut BenchAppContext) { + cards(64, cx); +} + +fn cards_256(cx: &mut BenchAppContext) { + cards(256, cx); +} + +fn cards_1024(cx: &mut BenchAppContext) { + cards(1024, cx); +} + +fn text_rows_static_50(cx: &mut BenchAppContext) { + text_rows_static(50, cx); +} + +fn text_rows_static_200(cx: &mut BenchAppContext) { + text_rows_static(200, cx); +} + +fn text_rows_changing_50(cx: &mut BenchAppContext) { + text_rows_changing(50, cx); +} + +fn text_rows_changing_200(cx: &mut BenchAppContext) { + text_rows_changing(200, cx); +} + +fn paragraphs_12(cx: &mut BenchAppContext) { + paragraphs(12, cx); +} + +fn paragraphs_48(cx: &mut BenchAppContext) { + paragraphs(48, cx); +} + +fn primitives_1024(cx: &mut BenchAppContext) { + primitives(1024, cx); +} + +fn primitives_8192(cx: &mut BenchAppContext) { + primitives(8192, cx); +} + +const DIGEST_FRAMES: usize = 64; + +// Sprites only reference tiles by id; without the uploaded pixels a +// rasterization change would leave the digest unchanged. +struct DigestAtlas { + inner: Arc, + contents: Mutex>, +} + +impl DigestAtlas { + fn content(&self, tile: &AtlasTile) -> u64 { + self.contents + .lock() + .expect("digest atlas lock") + .get(&(tile.texture_id, tile.tile_id.0)) + .copied() + .unwrap_or(0) + } +} + +impl PlatformAtlas for DigestAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() + -> anyhow::Result, Cow<'a, [u8]>)>>, + ) -> anyhow::Result> { + let mut uploaded = None; + let tile = self.inner.get_or_insert_with(key, &mut || { + let built = build()?; + if let Some((size, bytes)) = &built { + let mut hasher = DefaultHasher::new(); + format!("{size:?}").hash(&mut hasher); + bytes.as_ref().hash(&mut hasher); + uploaded = Some(hasher.finish()); + } + Ok(built) + })?; + // A tile id can be reused after `remove`, so the latest upload wins. + if let (Some(tile), Some(content)) = (&tile, uploaded) { + self.contents + .lock() + .expect("digest atlas lock") + .insert((tile.texture_id, tile.tile_id.0), content); + } + Ok(tile) + } + + fn remove(&self, key: &AtlasKey) { + self.inner.remove(key); + } + + fn get_or_update_stream( + &self, + id: StreamingImageId, + frame: &StreamFrame, + ) -> anyhow::Result> { + self.inner.get_or_update_stream(id, frame) + } + + fn remove_stream(&self, id: StreamingImageId) { + self.inner.remove_stream(id); + } +} + +struct SceneDigestRenderer { + inner: Box, + atlas: Arc, + benchmark: &'static str, + path: PathBuf, + frames: usize, +} + +impl SceneDigestRenderer { + fn new( + inner: Box, + benchmark: &'static str, + path: PathBuf, + ) -> Self { + let atlas = Arc::new(DigestAtlas { + inner: inner.sprite_atlas(), + contents: Mutex::default(), + }); + Self { + inner, + atlas, + benchmark, + path, + frames: 0, + } + } + + fn digest(&self, scene: &Scene) -> u64 { + let mut hasher = DefaultHasher::new(); + for list in [ + format!("{:?}", scene.shadows), + format!("{:?}", scene.quads), + format!("{:?}", scene.paths), + format!("{:?}", scene.underlines), + format!("{:?}", scene.monochrome_sprites), + format!("{:?}", scene.subpixel_sprites), + format!("{:?}", scene.polychrome_sprites), + format!("{:?}", scene.surfaces), + format!("{:?}", scene.backdrop_blurs), + ] { + list.hash(&mut hasher); + } + let tiles = scene + .monochrome_sprites + .iter() + .map(|sprite| &sprite.tile) + .chain(scene.subpixel_sprites.iter().map(|sprite| &sprite.tile)) + .chain(scene.polychrome_sprites.iter().map(|sprite| &sprite.tile)); + for tile in tiles { + self.atlas.content(tile).hash(&mut hasher); + } + hasher.finish() + } +} + +impl PlatformHeadlessRenderer for SceneDigestRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: gpui::Size, + ) -> anyhow::Result { + self.inner.render_scene_to_image(scene, size) + } + + fn render_scene( + &mut self, + scene: &Scene, + size: gpui::Size, + ) -> anyhow::Result<()> { + if self.frames < DIGEST_FRAMES { + let line = format!( + "{} {:>3} {:016x}\n", + self.benchmark, + self.frames, + self.digest(scene) + ); + OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .expect("failed to append to GPUI_SCENE_DIGEST"); + } + self.frames += 1; + self.inner.render_scene(scene, size) + } + + fn sprite_atlas(&self) -> Arc { + self.atlas.clone() + } +} + +fn run(criterion: &mut Criterion, name: &'static str, benchmark: fn(&mut BenchAppContext)) { + let report = BenchReport::default(); + let digest = std::env::var_os("GPUI_SCENE_DIGEST").map(PathBuf::from); + criterion.bench_function(name, |bencher| { + let digest = digest.clone(); + let factory: Box Option>> = + Box::new(move || { + let inner = gpui_platform::current_headless_renderer()?; + Some(match &digest { + Some(path) => Box::new(SceneDigestRenderer::new(inner, name, path.clone())) + as Box, + None => inner, + }) + }); + let mut cx = BenchAppContext::new_with_platform_and_report( + gpui::bench_platform( + Some(factory), + gpui_platform::current_platform(true).text_system(), + ), + Some(name), + bencher, + report.clone(), + ); + benchmark(&mut cx); + cx.teardown(); + }); + report.print(Some(name)); +} + +fn benches(criterion: &mut Criterion) { + run(criterion, "cards_64", cards_64); + run(criterion, "cards_256", cards_256); + run(criterion, "cards_1024", cards_1024); + run(criterion, "text_rows_static_50", text_rows_static_50); + run(criterion, "text_rows_static_200", text_rows_static_200); + run(criterion, "text_rows_changing_50", text_rows_changing_50); + run(criterion, "text_rows_changing_200", text_rows_changing_200); + run(criterion, "paragraphs_12", paragraphs_12); + run(criterion, "paragraphs_48", paragraphs_48); + run(criterion, "recordings_scroll", recordings_scroll); + run(criterion, "hover_cards", hover_cards); + run(criterion, "cached_panels", cached_panels); + run(criterion, "primitives_1024", primitives_1024); + run(criterion, "primitives_8192", primitives_8192); +} + +criterion::criterion_group!(frame_benches, benches); +criterion::criterion_main!(frame_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..e69de29bb2d diff --git a/scripts/gpui-app-bench.py b/scripts/gpui-app-bench.py new file mode 100755 index 00000000000..f51f941c89f --- /dev/null +++ b/scripts/gpui-app-bench.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Usage: gpui-app-bench.py --base --head --fixture --out + +Medians are compared rather than means: the editor decodes video while it +plays, so means and tails swing with the decoder. +""" + +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