diff --git a/.~lock.hcie_features.ods# b/.~lock.hcie_features.ods# new file mode 100644 index 0000000..4885a72 --- /dev/null +++ b/.~lock.hcie_features.ods# @@ -0,0 +1 @@ +,dev-user,hc-20w1s30u2j,24.07.2026 16:44,file:///home/dev-user/.config/libreoffice/4; \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1e4cbd4..2bb0407 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -759,6 +765,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.65" @@ -798,6 +810,58 @@ dependencies = [ "libc", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1027,6 +1091,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -2651,6 +2751,7 @@ name = "hcie-engine-api" version = "0.1.0" dependencies = [ "approx", + "criterion", "dirs 6.0.0", "hcie-blend", "hcie-brush-engine", @@ -3553,12 +3654,32 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -4619,6 +4740,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.81" @@ -4953,6 +5080,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -5333,7 +5488,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -6519,6 +6674,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/hcie-engine-api/Cargo.toml b/hcie-engine-api/Cargo.toml index b40b3a4..d8a911d 100644 --- a/hcie-engine-api/Cargo.toml +++ b/hcie-engine-api/Cargo.toml @@ -40,3 +40,20 @@ rstest = "0.23" proptest = "1.5" approx = "0.5" sha2 = "0.10" +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "stroke_mask_pooling" +harness = false + +[[bench]] +name = "composite_scratch_pooling" +harness = false + +[[bench]] +name = "below_cache_reuse" +harness = false + +[[bench]] +name = "effects_skip" +harness = false diff --git a/hcie-engine-api/benches/below_cache_reuse.rs b/hcie-engine-api/benches/below_cache_reuse.rs new file mode 100644 index 0000000..7ee9a25 --- /dev/null +++ b/hcie-engine-api/benches/below_cache_reuse.rs @@ -0,0 +1,146 @@ +//! Criterion benchmark: `below_cache` reuse across consecutive strokes. +//! +//! ## Purpose +//! Verifies that painting multiple strokes on the **same layer** reuses the +//! pre-built `below_cache` (composite of all layers below the active one) +//! instead of rebuilding it from scratch on every `begin_stroke()`. +//! +//! On a 4K 10-layer document the below-layer composite costs ~15–25 ms. +//! If `below_cache_dirty` is incorrectly set to `true` after every +//! `end_stroke()`, each subsequent stroke pays that cost again. +//! +//! ## Logic & Workflow +//! 1. Creates a 3840×2160, 10-layer document (all filled). +//! 2. **first_stroke**: Benchmarks a full stroke including below_cache build. +//! 3. **second_stroke_same_layer**: Benchmarks a stroke on the same layer +//! where `below_cache` should be reused (much cheaper `begin_stroke`). +//! +//! When the cache works correctly, the second stroke's `begin_stroke()` is +//! essentially free (just a dirty-flag check), while the first one must +//! composite 9 below-layers into a 33 MB buffer. +//! +//! ## Arguments & Returns +//! Standard criterion benchmark — run with: +//! ```bash +//! cargo bench -p hcie-engine-api --bench below_cache_reuse +//! ``` +//! +//! ## Side Effects / Dependencies +//! Allocates ~400 MB (10 layers × 4K RGBA + below_cache + composite_scratch). + +use criterion::{criterion_group, criterion_main, Criterion}; +use hcie_engine_api::{BrushStyle, BrushTip, Engine}; + +/// Canvas dimensions — 4K UHD. +const W: u32 = 3840; +const H: u32 = 2160; +/// Number of raster layers. +const LAYERS: usize = 10; + +/// Builds a 10-layer 4K document. The top layer is the active drawing target. +/// +/// **Purpose:** Creates a worst-case scenario where the below-layer composite +/// is expensive (9 filled layers below the active one). +/// **Returns:** `(Engine, top_layer_id)`. +fn setup_10layer_engine() -> (Engine, u64) { + let mut engine = Engine::new(W, H); + + let bg_id = engine.active_layer_id(); + engine.set_active_layer(bg_id); + engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]); + + let mut layer_ids = vec![bg_id]; + for i in 1..LAYERS { + let id = engine.add_layer(&format!("Layer {}", i)); + engine.set_active_layer(id); + let color = match i % 4 { + 0 => [180, 80, 80, 255], + 1 => [80, 180, 80, 255], + 2 => [80, 80, 180, 255], + _ => [160, 140, 100, 255], + }; + engine.draw_filled_rect_rgba(0, 0, W, H, color); + layer_ids.push(id); + } + + let top_id = *layer_ids.last().unwrap(); + engine.set_active_layer(top_id); + + let mut tip = BrushTip::default(); + tip.style = BrushStyle::Round; + tip.size = 24.0; + tip.opacity = 1.0; + tip.hardness = 0.85; + tip.spacing = 0.1; + engine.set_brush_tip(tip); + engine.set_color([220, 60, 60, 255]); + engine.set_eraser(false); + + (engine, top_id) +} + +/// Performs a short stroke: begin → 5 segments → composite → end. +/// +/// **Purpose:** Exercises the full `begin_stroke()` (below_cache build/reuse) +/// through `end_stroke()` lifecycle. +/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer, +/// `offset` — slight position offset to avoid degenerate repeat painting. +/// **Side Effects:** Mutates layer pixels, triggers composite, commits history. +fn do_stroke(engine: &mut Engine, layer_id: u64, offset: f32) { + let cx = W as f32 / 2.0 + offset; + let cy = H as f32 / 2.0 + offset; + engine.begin_stroke(layer_id, cx, cy); + for i in 1..=5 { + let d = i as f32 * 8.0; + engine.stroke_to(layer_id, cx + d, cy + d, 0.8); + } + let (_region, ptr, _size) = engine.render_composite_region(); + assert!(!ptr.is_null()); + engine.end_stroke(layer_id); + std::thread::sleep(std::time::Duration::from_millis(10)); + engine.commit_pending_history(); +} + +/// Benchmark group: below_cache build (first stroke) vs reuse (second stroke). +/// +/// **Purpose:** Detects regressions where `below_cache_dirty` is incorrectly +/// set to `true` in `end_stroke()` or where `below_cache` is dropped to `None`, +/// forcing a full 9-layer re-composite on every stroke. +/// +/// **Logic & Workflow:** +/// - `first_stroke_cache_build`: On a fresh engine, `begin_stroke()` must +/// build the below_cache from scratch (composite 9 layers → 33 MB buffer). +/// - `second_stroke_cache_reuse`: After the first stroke on the same layer, +/// `begin_stroke()` should find `below_cache_dirty == false` and +/// `below_cache_active_idx == current`, skipping the entire composite. +fn bench_below_cache_reuse(c: &mut Criterion) { + let mut group = c.benchmark_group("below_cache_reuse"); + + // ── First stroke: cache must be built from scratch ── + group.bench_function("first_stroke_cache_build", |b| { + b.iter_with_setup( + || setup_10layer_engine(), + |(mut engine, layer_id)| { + do_stroke(&mut engine, layer_id, 0.0); + }, + ); + }); + + // ── Second stroke on same layer: cache should be reused ── + group.bench_function("second_stroke_cache_reuse", |b| { + let (mut engine, layer_id) = setup_10layer_engine(); + // Build the cache with the first stroke + do_stroke(&mut engine, layer_id, 0.0); + + let mut offset = 0.0_f32; + b.iter(|| { + offset += 5.0; + do_stroke(&mut engine, layer_id, offset); + }); + }); + + group.finish(); +} + +criterion_group!(benches, bench_below_cache_reuse); +criterion_main!(benches); diff --git a/hcie-engine-api/benches/composite_scratch_pooling.rs b/hcie-engine-api/benches/composite_scratch_pooling.rs new file mode 100644 index 0000000..fbb8bb8 --- /dev/null +++ b/hcie-engine-api/benches/composite_scratch_pooling.rs @@ -0,0 +1,146 @@ +//! Criterion benchmark: `composite_scratch` buffer pooling guard. +//! +//! ## Purpose +//! Verifies that the ~33 MB `composite_scratch` buffer inside +//! `render_composite_region()` is allocated once and reused across calls. +//! If a regression replaces the pooled `Option>` with a fresh +//! `Vec::new()` on every call, this benchmark detects the allocation storm. +//! +//! ## Logic & Workflow +//! 1. Creates a 3840×2160, 10-layer document with all layers filled. +//! 2. Performs a stroke + composite to warm the scratch buffer. +//! 3. **cold_composite**: Measures a `render_composite_region()` call on a +//! fresh engine where `composite_scratch` is `None`. +//! 4. **warm_composite**: Measures the same call after the buffer has been +//! allocated (should be faster — no allocation, just memset + composite). +//! +//! ## Arguments & Returns +//! Standard criterion benchmark — run with: +//! ```bash +//! cargo bench -p hcie-engine-api --bench composite_scratch_pooling +//! ``` +//! +//! ## Side Effects / Dependencies +//! Allocates ~400 MB (10 layers × 4K RGBA + scratch + tile caches). + +use criterion::{criterion_group, criterion_main, Criterion}; +use hcie_engine_api::{BrushStyle, BrushTip, Engine}; + +/// Canvas dimensions — 4K UHD. +const W: u32 = 3840; +const H: u32 = 2160; +/// Number of raster layers to create (worst-case workload). +const LAYERS: usize = 10; + +/// Builds a multi-layer 4K document ready for compositing. +/// +/// **Purpose:** Creates the worst-case composite scenario with every layer +/// filled so tile and pixel compositing operate on real data. +/// **Returns:** `(Engine, top_layer_id)`. +fn setup_multilayer_engine() -> (Engine, u64) { + let mut engine = Engine::new(W, H); + + let bg_id = engine.active_layer_id(); + engine.set_active_layer(bg_id); + engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]); + + let mut layer_ids = vec![bg_id]; + for i in 1..LAYERS { + let id = engine.add_layer(&format!("Layer {}", i)); + engine.set_active_layer(id); + let color = match i % 4 { + 0 => [180, 80, 80, 255], + 1 => [80, 180, 80, 255], + 2 => [80, 80, 180, 255], + _ => [160, 140, 100, 255], + }; + engine.draw_filled_rect_rgba(0, 0, W, H, color); + layer_ids.push(id); + } + + let top_id = *layer_ids.last().unwrap(); + engine.set_active_layer(top_id); + + let mut tip = BrushTip::default(); + tip.style = BrushStyle::Round; + tip.size = 24.0; + tip.opacity = 1.0; + tip.hardness = 0.85; + tip.spacing = 0.1; + engine.set_brush_tip(tip); + engine.set_color([220, 60, 60, 255]); + engine.set_eraser(false); + + (engine, top_id) +} + +/// Forces a dirty region and composites it. +/// +/// **Purpose:** Exercises the `render_composite_region()` path including +/// scratch buffer allocation/reuse, tile compositing, and dirty rect handling. +/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer. +/// **Side Effects:** Paints a short stroke segment to create a dirty region, +/// then calls `render_composite_region()`. +fn stroke_and_composite(engine: &mut Engine, layer_id: u64) { + let cx = W as f32 / 2.0; + let cy = H as f32 / 2.0; + engine.begin_stroke(layer_id, cx, cy); + engine.stroke_to(layer_id, cx + 50.0, cy + 50.0, 0.8); + let (region, ptr, _size) = engine.render_composite_region(); + assert!(!ptr.is_null()); + let _ = region; + engine.end_stroke(layer_id); + std::thread::sleep(std::time::Duration::from_millis(10)); + engine.commit_pending_history(); +} + +/// Benchmark group: cold vs warm `composite_scratch` allocation. +/// +/// **Purpose:** Detects regressions where `composite_scratch` is dropped or +/// reallocated on every `render_composite_region()` call. When pooling works, +/// the warm benchmark avoids a ~33 MB allocation and should be measurably faster. +/// +/// **Logic & Workflow:** +/// - `cold_composite`: Fresh engine, `composite_scratch = None`. First call +/// allocates the buffer. +/// - `warm_composite`: Engine with pre-allocated scratch buffer. Measures +/// pure composite cost without allocation overhead. +fn bench_composite_scratch_pooling(c: &mut Criterion) { + let mut group = c.benchmark_group("composite_scratch_pooling"); + + // ── Cold: composite on a fresh engine (scratch allocation included) ── + group.bench_function("cold_composite", |b| { + b.iter_with_setup( + || setup_multilayer_engine(), + |(mut engine, layer_id)| { + stroke_and_composite(&mut engine, layer_id); + }, + ); + }); + + // ── Warm: composite on a pre-warmed engine (scratch already pooled) ── + group.bench_function("warm_composite", |b| { + let (mut engine, layer_id) = setup_multilayer_engine(); + // Warm up: allocate the scratch buffer + stroke_and_composite(&mut engine, layer_id); + + b.iter(|| { + // Paint a new segment and composite — scratch should be reused + let cx = W as f32 / 2.0; + let cy = H as f32 / 2.0; + engine.begin_stroke(layer_id, cx, cy); + engine.stroke_to(layer_id, cx + 30.0, cy - 30.0, 0.7); + let (region, ptr, _size) = engine.render_composite_region(); + assert!(!ptr.is_null()); + let _ = region; + engine.end_stroke(layer_id); + std::thread::sleep(std::time::Duration::from_millis(5)); + engine.commit_pending_history(); + }); + }); + + group.finish(); +} + +criterion_group!(benches, bench_composite_scratch_pooling); +criterion_main!(benches); diff --git a/hcie-engine-api/benches/effects_skip.rs b/hcie-engine-api/benches/effects_skip.rs new file mode 100644 index 0000000..fae7203 --- /dev/null +++ b/hcie-engine-api/benches/effects_skip.rs @@ -0,0 +1,166 @@ +//! Criterion benchmark: conditional `effects_dirty` skip guard. +//! +//! ## Purpose +//! Verifies that `render_composite_region()` / `apply_effects_and_sync_tiles()` +//! skips the expensive effects pipeline for layers that have **no effects or +//! styles**. If a regression removes the `effects.is_empty() && styles.is_empty()` +//! early-exit or unconditionally sets `effects_dirty = true`, composite cost +//! on effect-free documents will increase dramatically. +//! +//! ## Logic & Workflow +//! 1. Creates a 3840×2160, 10-layer document (all filled, no effects). +//! 2. **no_effects**: Benchmarks `render_composite_region()` where every layer +//! has empty `effects` and `styles` vectors → the effects pipeline is +//! completely skipped. +//! 3. **with_one_effect**: Same document but one layer has a DropShadow style +//! → the effects pipeline runs for that single layer. +//! +//! When the conditional skip works, `no_effects` should be measurably faster +//! than `with_one_effect` because no `apply_layer_effects` calls are made. +//! +//! ## Arguments & Returns +//! Standard criterion benchmark — run with: +//! ```bash +//! cargo bench -p hcie-engine-api --bench effects_skip +//! ``` +//! +//! ## Side Effects / Dependencies +//! Allocates ~400 MB (10 layers × 4K RGBA + composite buffers). + +use criterion::{criterion_group, criterion_main, Criterion}; +use hcie_engine_api::{BrushStyle, BrushTip, Engine, LayerStyle}; + +/// Canvas dimensions — 4K UHD. +const W: u32 = 3840; +const H: u32 = 2160; +/// Number of raster layers. +const LAYERS: usize = 10; + +/// Builds a 10-layer 4K document with NO effects on any layer. +/// +/// **Purpose:** Creates the baseline scenario where the effects pipeline +/// should be completely skipped during compositing. +/// **Returns:** `(Engine, top_layer_id, all_layer_ids)`. +fn setup_no_effects_engine() -> (Engine, u64, Vec) { + let mut engine = Engine::new(W, H); + + let bg_id = engine.active_layer_id(); + engine.set_active_layer(bg_id); + engine.draw_filled_rect_rgba(0, 0, W, H, [240, 240, 240, 255]); + + let mut layer_ids = vec![bg_id]; + for i in 1..LAYERS { + let id = engine.add_layer(&format!("Layer {}", i)); + engine.set_active_layer(id); + let color = match i % 4 { + 0 => [180, 80, 80, 255], + 1 => [80, 180, 80, 255], + 2 => [80, 80, 180, 255], + _ => [160, 140, 100, 255], + }; + engine.draw_filled_rect_rgba(0, 0, W, H, color); + layer_ids.push(id); + } + + let top_id = *layer_ids.last().unwrap(); + engine.set_active_layer(top_id); + + let mut tip = BrushTip::default(); + tip.style = BrushStyle::Round; + tip.size = 24.0; + tip.opacity = 1.0; + tip.hardness = 0.85; + tip.spacing = 0.1; + engine.set_brush_tip(tip); + engine.set_color([220, 60, 60, 255]); + engine.set_eraser(false); + + (engine, top_id, layer_ids) +} + +/// Performs a stroke + composite cycle and returns. +/// +/// **Purpose:** Creates a dirty region and composites it, exercising the +/// effects pipeline path. +/// **Arguments:** `engine` — mutable engine, `layer_id` — target layer, +/// `offset` — position variation. +/// **Side Effects:** Mutates layer pixels, triggers composite. +fn stroke_and_composite(engine: &mut Engine, layer_id: u64, offset: f32) { + let cx = W as f32 / 2.0 + offset; + let cy = H as f32 / 2.0 + offset; + engine.begin_stroke(layer_id, cx, cy); + for i in 1..=5 { + let d = i as f32 * 8.0; + engine.stroke_to(layer_id, cx + d, cy + d, 0.8); + } + let (_region, ptr, _size) = engine.render_composite_region(); + assert!(!ptr.is_null()); + engine.end_stroke(layer_id); + std::thread::sleep(std::time::Duration::from_millis(10)); + engine.commit_pending_history(); +} + +/// Benchmark group: effects-free composite vs single-effect composite. +/// +/// **Purpose:** Detects regressions where the effects pipeline runs +/// unnecessarily on layers with no effects, causing wasted CPU cycles +/// (apply_layer_effects + 33 MB buffer clone per affected layer). +/// +/// **Logic & Workflow:** +/// - `no_effects_composite`: All 10 layers have empty effects/styles. +/// `apply_effects_and_sync_tiles()` should skip the effects pass entirely. +/// - `with_one_dropshadow`: Layer 5 has a DropShadow style enabled. +/// The effects pipeline runs for that single layer but skips the other 9. +/// This should be slightly slower than `no_effects_composite` but not 10× +/// slower (which would indicate the skip logic is broken). +fn bench_effects_skip(c: &mut Criterion) { + let mut group = c.benchmark_group("effects_skip"); + + // ── No effects on any layer ── + group.bench_function("no_effects_composite", |b| { + let (mut engine, layer_id, _ids) = setup_no_effects_engine(); + // Warm up composite scratch + stroke_and_composite(&mut engine, layer_id, 0.0); + + let mut offset = 0.0_f32; + b.iter(|| { + offset += 3.0; + stroke_and_composite(&mut engine, layer_id, offset); + }); + }); + + // ── One layer has a DropShadow effect ── + group.bench_function("with_one_dropshadow", |b| { + let (mut engine, layer_id, layer_ids) = setup_no_effects_engine(); + + // Add a DropShadow style to layer 5 (middle of the stack) + let target_layer_id = layer_ids[5]; + engine.update_layer_style( + target_layer_id, + LayerStyle::DropShadow { + enabled: true, + opacity: 0.6, + angle: 135.0, + distance: 5.0, + spread: 0.0, + size: 10.0, + color: [0, 0, 0, 255], + blend_mode: hcie_engine_api::BlendMode::Normal, + }, + ); + + // Warm up + stroke_and_composite(&mut engine, layer_id, 0.0); + + let mut offset = 0.0_f32; + b.iter(|| { + offset += 3.0; + stroke_and_composite(&mut engine, layer_id, offset); + }); + }); + + group.finish(); +} + +criterion_group!(benches, bench_effects_skip); +criterion_main!(benches); diff --git a/hcie-engine-api/benches/stroke_mask_pooling.rs b/hcie-engine-api/benches/stroke_mask_pooling.rs new file mode 100644 index 0000000..2808d91 --- /dev/null +++ b/hcie-engine-api/benches/stroke_mask_pooling.rs @@ -0,0 +1,120 @@ +//! Criterion benchmark: `active_stroke_mask` buffer pooling guard. +//! +//! ## Purpose +//! Verifies that the `active_stroke_mask` (~8 MB on a 4K canvas) is **pooled** +//! across consecutive strokes rather than re-allocated on every +//! `begin_stroke()` / `end_stroke()` cycle. If a regression drops the mask +//! with `self.active_stroke_mask = None` inside `end_stroke()`, this benchmark +//! will show a measurable increase in allocation overhead. +//! +//! ## Logic & Workflow +//! 1. Creates a 3840×2160 document with one raster layer. +//! 2. **cold** group: a single begin/end stroke pair (first allocation). +//! 3. **warm** group: the 10th consecutive stroke on the same engine (buffer +//! should already be allocated and simply zeroed with `fill(0)`). +//! +//! When pooling works correctly, the warm benchmark should be significantly +//! faster than the cold one because no allocation occurs — only a memset. +//! +//! ## Arguments & Returns +//! Standard criterion benchmark — run with: +//! ```bash +//! cargo bench -p hcie-engine-api --bench stroke_mask_pooling +//! ``` +//! +//! ## Side Effects / Dependencies +//! Allocates ~140 MB (4K RGBA layer + mask + before buffer). Uses +//! `criterion::Criterion` for statistical measurement. + +use criterion::{criterion_group, criterion_main, Criterion}; +use hcie_engine_api::{BrushStyle, BrushTip, Engine}; + +/// Canvas dimensions — 4K UHD as requested. +const W: u32 = 3840; +const H: u32 = 2160; + +/// Creates a pre-configured engine with a filled background layer and a +/// standard round brush tip. +/// +/// **Purpose:** Shared setup for all mask pooling benchmark variants. +/// **Returns:** `(Engine, layer_id)` ready for `begin_stroke()`. +fn setup_engine() -> (Engine, u64) { + let mut engine = Engine::new(W, H); + let layer_id = engine.active_layer_id(); + engine.set_active_layer(layer_id); + engine.draw_filled_rect_rgba(0, 0, W, H, [200, 200, 200, 255]); + + let mut tip = BrushTip::default(); + tip.style = BrushStyle::Round; + tip.size = 24.0; + tip.opacity = 1.0; + tip.hardness = 0.85; + tip.spacing = 0.1; + engine.set_brush_tip(tip); + engine.set_color([60, 60, 220, 255]); + engine.set_eraser(false); + + (engine, layer_id) +} + +/// Performs a single begin_stroke → short paint → end_stroke cycle. +/// +/// **Purpose:** Exercises the full mask allocation / reuse path. +/// **Arguments:** `engine` — mutable engine reference, `layer_id` — target layer. +/// **Side Effects:** Mutates layer pixels and engine caches. +fn do_one_stroke(engine: &mut Engine, layer_id: u64) { + let cx = W as f32 / 2.0; + let cy = H as f32 / 2.0; + engine.begin_stroke(layer_id, cx, cy); + // Paint 5 short segments to exercise the mask + for i in 1..=5 { + let offset = i as f32 * 10.0; + engine.stroke_to(layer_id, cx + offset, cy + offset, 0.8); + } + engine.end_stroke(layer_id); + // Commit pending history so background thread completes + std::thread::sleep(std::time::Duration::from_millis(10)); + engine.commit_pending_history(); +} + +/// Benchmark group: cold vs warm `active_stroke_mask` allocation. +/// +/// **Purpose:** Detects pooling regressions by comparing the first stroke +/// (cold allocation) against a subsequent stroke (warm / reused buffer). +/// +/// **Logic & Workflow:** +/// - `cold_first_stroke`: Measures `begin_stroke` + short paint + `end_stroke` +/// on a fresh engine where `active_stroke_mask` is `None`. +/// - `warm_10th_stroke`: Same operation but after 9 warm-up strokes have already +/// populated the pooled mask buffer. If pooling works, this should be faster. +fn bench_mask_pooling(c: &mut Criterion) { + let mut group = c.benchmark_group("stroke_mask_pooling"); + + // ── Cold: first stroke on a fresh engine (allocation happens) ── + group.bench_function("cold_first_stroke", |b| { + b.iter_with_setup( + || setup_engine(), + |(mut engine, layer_id)| { + do_one_stroke(&mut engine, layer_id); + }, + ); + }); + + // ── Warm: 10th stroke on a warmed-up engine (buffer reuse) ── + group.bench_function("warm_10th_stroke", |b| { + // Pre-warm the engine outside the measured loop + let (mut engine, layer_id) = setup_engine(); + for _ in 0..9 { + do_one_stroke(&mut engine, layer_id); + } + + b.iter(|| { + do_one_stroke(&mut engine, layer_id); + }); + }); + + group.finish(); +} + +criterion_group!(benches, bench_mask_pooling); +criterion_main!(benches); diff --git a/hcie_features.ods b/hcie_features.ods index 7efd0d6..cdd8f1e 100644 Binary files a/hcie_features.ods and b/hcie_features.ods differ