From 54e565bee2179d001696168b4066c07c357a5cf6 Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Wed, 12 Aug 2026 14:43:28 +0000 Subject: [PATCH 1/2] ci: add CodSpeed benchmarks for render, quantize and encode paths --- .github/workflows/codspeed.yml | 50 +++++ Cargo.lock | 343 ++++++++++++++++++++++++++++++++- Cargo.toml | 24 +++ README.md | 19 ++ benches/common/mod.rs | 84 ++++++++ benches/pipeline.rs | 77 ++++++++ benches/quantize.rs | 57 ++++++ benches/render.rs | 77 ++++++++ 8 files changed, 723 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/codspeed.yml create mode 100644 benches/common/mod.rs create mode 100644 benches/pipeline.rs create mode 100644 benches/quantize.rs create mode 100644 benches/render.rs diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..e905f5b --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,50 @@ +name: CodSpeed + +on: + push: + branches: + - trunk + - main + # Required to get performance reports on pull requests. + pull_request: + # Lets CodSpeed trigger backtest runs to generate initial data. + workflow_dispatch: + +permissions: + contents: read + # OpenID Connect authentication with CodSpeed. + id-token: write + +env: + CARGO_TERM_COLOR: always + CARGO_TARGET_DIR: target + # `.cargo/config.toml` builds with `-C target-cpu=native`, which on these + # runners emits AVX-512 instructions the CPU simulator cannot execute. Pin a + # fixed baseline instead: AVX2-era codegen that stays comparable across runs. + RUSTFLAGS: -C target-cpu=x86-64-v3 + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-codspeed + run: cargo install cargo-codspeed --locked + + - name: Build the benchmarks + run: cargo codspeed build --profile codspeed --measurement-mode simulation + + - name: Run the benchmarks + uses: CodSpeedHQ/action@v5 + with: + mode: simulation + run: cargo codspeed run diff --git a/Cargo.lock b/Cargo.lock index be3f81d..d6054cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "aligned" version = "0.4.3" @@ -87,7 +96,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -98,7 +107,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -278,6 +287,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.5.53" @@ -298,6 +313,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -318,6 +334,66 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "codspeed" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3" +dependencies = [ + "anyhow", + "cc", + "colored", + "getrandom 0.2.16", + "glob", + "libc", + "nix", + "serde", + "serde_json", + "statrs", +] + +[[package]] +name = "codspeed-divan-compat" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ea79fd0b1f2128cfac6308369013dba92df47baf4a4f66b57d8158224a361d" +dependencies = [ + "clap", + "codspeed", + "codspeed-divan-compat-macros", + "codspeed-divan-compat-walltime", + "regex", +] + +[[package]] +name = "codspeed-divan-compat-macros" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70e4ddd6beedefeb48f59d5f85fc21365a66e7976408c3d39f6cbbc4f03e08c" +dependencies = [ + "divan-macros", + "itertools 0.14.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "codspeed-divan-compat-walltime" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490c04f6076be6eacfafb496b8b237f3efbbed93838f2689115cc6f35fcf81c9" +dependencies = [ + "cfg-if", + "clap", + "codspeed", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -357,6 +433,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "core2" version = "0.4.0" @@ -437,6 +529,17 @@ dependencies = [ "syn", ] +[[package]] +name = "divan-macros" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc51d98e636f5e3b0759a39257458b22619cac7e96d932da6eeb052891bb67c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -469,6 +572,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "exr" version = "1.74.0" @@ -590,6 +703,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "half" version = "2.7.1" @@ -603,9 +722,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -679,9 +798,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -762,9 +881,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -819,6 +938,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -839,6 +964,7 @@ name = "maple" version = "0.2.0" dependencies = [ "clap", + "codspeed-divan-compat", "color-eyre", "eyre", "image", @@ -942,6 +1068,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "8.0.0" @@ -1124,6 +1262,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.104" @@ -1336,6 +1483,41 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rgb" version = "0.8.52" @@ -1354,6 +1536,19 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1478,6 +1673,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1495,6 +1700,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -1538,6 +1753,36 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1718,6 +1963,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1727,6 +1981,79 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 51a513a..f78d475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,16 +20,40 @@ maple-render-core = { path = "crates/maple-render-core", version = "0.2.0" } wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" +[dev-dependencies] +divan = { version = "4.7.0", package = "codspeed-divan-compat" } + [[bin]] name = "maple" path = "src/main.rs" +[[bench]] +name = "render" +harness = false + +[[bench]] +name = "quantize" +harness = false + +[[bench]] +name = "pipeline" +harness = false + [profile.release] lto = "fat" codegen-units = 1 panic = "abort" strip = "symbols" +# Profile for the CodSpeed benchmarks: release-grade codegen, plus debug symbols +# so profiles and flame graphs are readable. `panic = "unwind"` is required +# because benchmark harnesses cannot be built with `panic = "abort"`. +[profile.codspeed] +inherits = "release" +debug = true +strip = "none" +panic = "unwind" + [target.'cfg(not(target_arch = "wasm32"))'.dependencies.mimalloc] version = "0.1" default-features = false diff --git a/README.md b/README.md index 82800dd..065acfd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ image +[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/taskylizard/maple?utm_source=badge) + --- maple allows you to composite images/text with templates into animated GIFs/Videos. `templates/` has some fun ones to try! @@ -85,6 +87,23 @@ maple --zip templates/book.zip --in front.png back.png --vid book.mp4 back-tattoo, billboard-cityscape, book, circuitboard, flag, flag2, fortune-cookie, heart-locket, rubiks, toaster, valentine +## Benchmarks + +Template parsing, frame compositing, color quantization and GIF encoding are +benchmarked with [divan](https://github.com/nvzqz/divan) in `benches/` and +tracked on every push and pull request by +[CodSpeed](https://app.codspeed.io/taskylizard/maple). + +```bash +# Wall-clock numbers +cargo bench + +# Same benchmarks, measured the way CI measures them +cargo install cargo-codspeed +cargo codspeed build --profile codspeed --measurement-mode simulation +cargo codspeed run +``` + ## License MIT diff --git a/benches/common/mod.rs b/benches/common/mod.rs new file mode 100644 index 0000000..98e9e40 --- /dev/null +++ b/benches/common/mod.rs @@ -0,0 +1,84 @@ +//! Shared asset helpers for the maple benchmarks. +//! +//! Benchmarks live in the binary crate because that is where the shipped +//! templates, example images and fonts are: `maple-render-core` deliberately +//! bundles none of them. +#![allow(dead_code)] + +use std::path::PathBuf; + +use maple::{Input, Inputs, Render, Renders, Repository, mapping::Mapping, render::RenderQuality}; + +/// Prepare the process before handing over to divan. +/// +/// The compositor parallelizes rows with rayon. Left to itself, the number of +/// worker threads and the way work is split vary from machine to machine, which +/// shows up as noise in the measurements, so benchmarks default to a single +/// thread (override with `RAYON_NUM_THREADS`). One throwaway render then warms +/// the thread pool up, keeping its lazy initialization out of the first +/// measured iteration. +pub fn init() { + if std::env::var_os("RAYON_NUM_THREADS").is_none() { + std::env::set_var("RAYON_NUM_THREADS", "1"); + } + + let mut warmup = render("toaster", 0, RenderQuality::Sampled); + warmup.apply(&inputs("frog.jpg")).expect("warmup render"); +} + +/// Path of an asset relative to the repository root. +pub fn asset(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) +} + +/// Raw bytes of a shipped template archive. +pub fn template_bytes(name: &str) -> Vec { + let path = asset(&format!("templates/{}.zip", name)); + std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)) +} + +/// Open a template archive from memory, so that benchmarks measure parsing and +/// not the file system. +pub fn repository(name: &str) -> Repository { + Repository::load_from_bytes(template_bytes(name)).expect("load template") +} + +/// Decode the five PNG layers of a single template frame. +pub fn mapping(template: &str, frame: i32) -> Mapping { + repository(template).take_mapping(frame).expect("take mapping") +} + +/// Load one of the shipped example photos as a render input. +pub fn input(image: &str) -> Input { + Input::load(asset(&format!("examples/{}", image))).expect("load input") +} + +pub fn inputs(image: &str) -> Inputs { + let mut inputs = Inputs::new(); + inputs.push(input(image)); + inputs +} + +/// A render with the frame's mapping already attached, ready to `apply`. +pub fn render(template: &str, frame: i32, quality: RenderQuality) -> Render { + let mut render = Render::new(quality); + render.attach_mapping(mapping(template, frame)); + render +} + +/// A render holding a composited frame, the input every color quantization +/// step works on. Call [`Render::get`] to reach the image. +pub fn composited_frame(template: &str, frame: i32, image: &str) -> Render { + let mut render = render(template, frame, RenderQuality::Sampled); + render.apply(&inputs(image)).expect("apply render"); + render +} + +/// The full animation pipeline for a template and an input image. +pub fn renders(template: &str, image: &str, quality: RenderQuality) -> Renders { + Renders::new(repository(template), inputs(image), quality) +} + +pub fn font() -> Vec { + std::fs::read(asset("fonts/DejaVuSans-Bold.ttf")).expect("read font") +} diff --git a/benches/pipeline.rs b/benches/pipeline.rs new file mode 100644 index 0000000..59ee73e --- /dev/null +++ b/benches/pipeline.rs @@ -0,0 +1,77 @@ +//! End-to-end benchmarks: the work a single `maple` invocation does. + +mod common; + +use divan::Bencher; +use maple::{GifAnim, Input, TextOptions, render::RenderQuality}; + +fn main() { + common::init(); + divan::main(); +} + +fn gif_anim(template: &str, image: &str) -> GifAnim { + let repo = common::repository(template); + let period = repo.get_period(); + let hold = repo.get_hold(); + let palette = repo.get_palette(); + + let mut anim = GifAnim::new(common::renders(template, image, RenderQuality::Sampled)); + anim.set_timing(period, hold); + anim.set_palette_frames(palette); + anim +} + +/// Full animated GIF: every frame is decoded, composited, quantized and +/// LZW-compressed. This is what `maple --zip ... --gif out.gif` runs. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn gif_animation(bencher: Bencher) { + bencher.with_inputs(|| gif_anim("book", "frog.jpg")).bench_local_refs(|anim| { + anim.apply().expect("gif apply"); + anim.encode().expect("gif encode") + }); +} + +/// The same animation with Floyd-Steinberg dithering enabled. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn gif_animation_dithered(bencher: Bencher) { + bencher + .with_inputs(|| { + let mut anim = gif_anim("book", "frog.jpg"); + anim.set_dither(true); + anim + }) + .bench_local_refs(|anim| { + anim.apply().expect("gif apply"); + anim.encode().expect("gif encode") + }); +} + +/// GIF serialization on its own: palette table plus LZW compression of the +/// frame blocks produced by `apply`. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn gif_serialize(bencher: Bencher) { + let mut anim = gif_anim("book", "frog.jpg"); + anim.apply().expect("gif apply"); + + bencher.bench_local(|| anim.encode().expect("gif encode")); +} + +/// Decoding a user photo into a render input. +#[divan::bench] +fn load_photo(bencher: Bencher) { + let path = common::asset("examples/monkey.jpg"); + + bencher.bench_local(|| Input::load(&path).expect("load input")); +} + +/// Rasterizing a text layer, the `--in "text:..."` path. +#[divan::bench] +fn text_layer(bencher: Bencher) { + let font = common::font(); + let options = TextOptions::default(); + + bencher.bench_local(|| { + Input::from_text("Hello from maple", 1, &options, &font).expect("render text") + }); +} diff --git a/benches/quantize.rs b/benches/quantize.rs new file mode 100644 index 0000000..99dd4ad --- /dev/null +++ b/benches/quantize.rs @@ -0,0 +1,57 @@ +//! Benchmarks for the median-cut quantizer used by the GIF encoder. +//! +//! Every GIF frame goes through `Quantizer::quantize`, and the palette is +//! rebuilt for each palette frame of a template. + +mod common; + +use divan::Bencher; +use maple::quantize::Quantizer; + +fn main() { + common::init(); + divan::main(); +} + +/// Histogram prescan plus median-cut selection of the 256 palette entries. +#[divan::bench] +fn build_palette(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + + bencher.bench_local(|| Quantizer::new(frame)); +} + +/// Mapping a frame onto the palette with a cold inverse colormap cache. +#[divan::bench] +fn map_frame_cold_cache(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + + bencher + .with_inputs(|| Quantizer::new(frame)) + .bench_local_refs(|quantizer| quantizer.quantize_no_dither(frame)); +} + +/// Mapping a frame once the colormap cache is warm, which is what every frame +/// after the first one of an animation hits. +#[divan::bench] +fn map_frame_warm_cache(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + let mut quantizer = Quantizer::new(frame); + quantizer.quantize_no_dither(frame); + + bencher.bench_local(|| quantizer.quantize_no_dither(frame)); +} + +/// Floyd-Steinberg dithering, enabled with `--dither`. +#[divan::bench] +fn map_frame_dithered(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + let mut quantizer = Quantizer::new(frame); + quantizer.quantize_fs_dither(frame); + + bencher.bench_local(|| quantizer.quantize_fs_dither(frame)); +} diff --git a/benches/render.rs b/benches/render.rs new file mode 100644 index 0000000..2f63d85 --- /dev/null +++ b/benches/render.rs @@ -0,0 +1,77 @@ +//! Benchmarks for template loading and frame compositing. +//! +//! `Render::apply` is the hot path of every maple output: it is run once per +//! animation frame, for every input layer. + +mod common; + +use divan::Bencher; +use maple::{Repository, render::RenderQuality}; + +fn main() { + common::init(); + divan::main(); +} + +/// Parsing a template archive: zip central directory plus `template.json`. +#[divan::bench] +fn open_template(bencher: Bencher) { + let bytes = common::template_bytes("toaster"); + + bencher + .with_inputs(|| bytes.clone()) + .bench_local_values(|bytes| Repository::load_from_bytes(bytes).expect("load template")); +} + +/// Decoding the five PNG layers (light, dark, map, sel, transparent) of a frame. +#[divan::bench] +fn decode_frame_layers(bencher: Bencher) { + bencher + .with_inputs(|| common::repository("toaster")) + .bench_local_refs(|repo| repo.take_mapping(11).expect("take mapping")); +} + +/// Compositing a frame with the default 9-tap antialiased sampler. +#[divan::bench] +fn composite_sampled(bencher: Bencher) { + let inputs = common::inputs("frog.jpg"); + let mut render = common::render("toaster", 11, RenderQuality::Sampled); + + bencher.bench_local(|| render.apply(&inputs).expect("apply render")); +} + +/// Compositing the same frame with the cheaper nearest-neighbour sampler. +#[divan::bench] +fn composite_simple(bencher: Bencher) { + let inputs = common::inputs("frog.jpg"); + let mut render = common::render("toaster", 11, RenderQuality::Simple); + + bencher.bench_local(|| render.apply(&inputs).expect("apply render")); +} + +/// A template whose frames use the edge-smoothing post-process. +#[divan::bench] +fn composite_multi_layer(bencher: Bencher) { + let inputs = common::inputs("monkey.jpg"); + let mut render = common::render("book", 6, RenderQuality::Sampled); + + bencher.bench_local(|| render.apply(&inputs).expect("apply render")); +} + +/// Compositing plus the downscale/letterbox pass used by `-w`/`-H`. +#[divan::bench] +fn composite_scaled(bencher: Bencher) { + let inputs = common::inputs("frog.jpg"); + let mut render = common::render("toaster", 11, RenderQuality::Sampled); + + bencher.bench_local(|| render.apply_scaled(&inputs, 800, 600).expect("apply render")); +} + +/// The point cloud scan backing `--auto_zoom` and layer detection. +#[divan::bench] +fn frame_cloud(bencher: Bencher) { + let input = common::input("frog.jpg"); + let render = common::render("toaster", 11, RenderQuality::Sampled); + + bencher.bench_local(|| render.get_cloud(&input).expect("get cloud")); +} From 62a8973043a01735eb90b7224b0722077596df6c Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Wed, 12 Aug 2026 15:23:53 +0000 Subject: [PATCH 2/2] ci: benchmark the WebP encoding paths Adds benches/webp.rs covering the --webp and --webp_single paths: the full animated WebP encode, libwebp serialization of already composited frames, and single-frame lossy and lossless encoding. --- Cargo.toml | 4 +++ README.md | 4 +-- benches/common/mod.rs | 13 +++++++ benches/webp.rs | 84 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 benches/webp.rs diff --git a/Cargo.toml b/Cargo.toml index f78d475..808ac4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,10 @@ harness = false name = "pipeline" harness = false +[[bench]] +name = "webp" +harness = false + [profile.release] lto = "fat" codegen-units = 1 diff --git a/README.md b/README.md index 065acfd..18b2c41 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,8 @@ back-tattoo, billboard-cityscape, book, circuitboard, flag, flag2, fortune-cooki ## Benchmarks -Template parsing, frame compositing, color quantization and GIF encoding are -benchmarked with [divan](https://github.com/nvzqz/divan) in `benches/` and +Template parsing, frame compositing, color quantization and GIF/WebP encoding +are benchmarked with [divan](https://github.com/nvzqz/divan) in `benches/` and tracked on every push and pull request by [CodSpeed](https://app.codspeed.io/taskylizard/maple). diff --git a/benches/common/mod.rs b/benches/common/mod.rs index 98e9e40..5393276 100644 --- a/benches/common/mod.rs +++ b/benches/common/mod.rs @@ -79,6 +79,19 @@ pub fn renders(template: &str, image: &str, quality: RenderQuality) -> Renders { Renders::new(repository(template), inputs(image), quality) } +/// The same pipeline with every frame composited up front, so that a benchmark +/// can measure serialization on its own. [`Renders`] composites frames lazily +/// on first access, which would otherwise be counted with the encoding. +pub fn warm_renders(template: &str, image: &str, quality: RenderQuality) -> Renders { + let mut renders = renders(template, image, quality); + + for frame in 0..renders.length() as i32 { + renders.get_render(frame).expect("composite frame"); + } + + renders +} + pub fn font() -> Vec { std::fs::read(asset("fonts/DejaVuSans-Bold.ttf")).expect("read font") } diff --git a/benches/webp.rs b/benches/webp.rs new file mode 100644 index 0000000..948e73c --- /dev/null +++ b/benches/webp.rs @@ -0,0 +1,84 @@ +//! Benchmarks for animated WebP output, the `--webp` and `--webp_single` paths. +//! +//! WebP keeps full 24-bit color with alpha, so unlike GIF it skips color +//! quantization entirely: the cost is compositing plus libwebp. `webp_anim` is +//! native-only and therefore not re-exported by `maple`, so these benchmarks +//! reach it through `maple_render_core` directly. + +mod common; + +use divan::Bencher; +use maple::render::RenderQuality; +use maple_render_core::webp_anim::{WebpAnim, WebpOptions}; + +fn main() { + common::init(); + divan::main(); +} + +/// Default lossy settings: quality 95, method 4. +fn lossy() -> WebpOptions { + WebpOptions::default() +} + +fn lossless() -> WebpOptions { + WebpOptions { lossless: true, ..WebpOptions::default() } +} + +fn anim(template: &str, image: &str, options: WebpOptions) -> WebpAnim { + let mut anim = WebpAnim::new(common::renders(template, image, RenderQuality::Sampled)); + let repo = common::repository(template); + anim.set_timing(repo.get_period(), repo.get_hold()); + anim.set_options(options); + anim +} + +/// The same animation with every frame composited up front, so that only the +/// libwebp encoding is measured. +fn warm_anim(template: &str, image: &str, options: WebpOptions) -> WebpAnim { + let mut anim = WebpAnim::new(common::warm_renders(template, image, RenderQuality::Sampled)); + let repo = common::repository(template); + anim.set_timing(repo.get_period(), repo.get_hold()); + anim.set_options(options); + anim +} + +/// Full animated WebP: every frame is composited and handed to libwebp. This is +/// what `maple --zip ... --webp out.webp` runs. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn webp_animation(bencher: Bencher) { + bencher + .with_inputs(|| anim("book", "frog.jpg", lossy())) + .bench_local_refs(|anim| anim.encode().expect("webp encode")); +} + +/// WebP serialization on its own: frame prediction and entropy coding of +/// already composited frames, the counterpart of `gif_serialize`. +#[divan::bench(sample_count = 3, sample_size = 1)] +fn webp_serialize(bencher: Bencher) { + bencher + .with_inputs(|| warm_anim("book", "frog.jpg", lossy())) + .bench_local_refs(|anim| anim.encode().expect("webp encode")); +} + +/// A single still frame through libwebp, the `--webp_single` path. +#[divan::bench] +fn webp_single_frame(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + let options = lossy(); + + bencher.bench_local(|| WebpAnim::encode_single(frame, &options).expect("webp encode")); +} + +/// The same still frame encoded losslessly, the `--webp_lossless` path. A whole +/// lossless animation is left out on purpose: it takes minutes under the CPU +/// simulator, and one frame already covers the lossless encoder. +#[divan::bench] +fn webp_single_frame_lossless(bencher: Bencher) { + let composited = common::composited_frame("toaster", 11, "frog.jpg"); + let frame = composited.get(); + let options = lossless(); + + bencher.bench_local(|| WebpAnim::encode_single(frame, &options).expect("webp encode")); +}