Compare commits
6 Commits
dev-260718
...
1dabd9c31e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dabd9c31e | |||
| 1cc8a3f373 | |||
| 8563a44211 | |||
| 844fd4a026 | |||
| ee6ad2dc20 | |||
| 6b02f19624 |
@@ -1,6 +1,7 @@
|
||||
[env]
|
||||
PKG_CONFIG_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu/pkgconfig"
|
||||
LIBRARY_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu"
|
||||
CRITERION_HOME = { value = "criterion", relative = true }
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
@@ -51,6 +51,10 @@ Thumbs.db
|
||||
_images
|
||||
_tmp
|
||||
.history
|
||||
|
||||
# Criterion benchmark output (generated, gold_standard baseline kept manually)
|
||||
/criterion/
|
||||
|
||||
# Custom dev environment
|
||||
/.rustup/
|
||||
/logs/last_semantic_audit.txt
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Fix Iced PlainSlider Visual Artifacts and Boundary Handling
|
||||
|
||||
## Goal
|
||||
Clean up the horizontal rendering artifacts on the Iced `PlainSlider` track and ensure the slider can be dragged to the absolute minimum (0 %) and maximum (100 %) values even when the mouse cursor moves slightly outside the widget bounds.
|
||||
|
||||
## Scope
|
||||
Only the Iced GUI slider implementation is in scope:
|
||||
- `hcie-iced-app/crates/hcie-iced-gui/src/widgets/plain_slider.rs`
|
||||
- Re-exported by `hcie-iced-app/crates/iced-panel-adapter/src/lib.rs`
|
||||
|
||||
The egui `PlainSlider` is explicitly out of scope (user chose option A).
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. Horizontal lines / visual artifacts
|
||||
The value fill is currently drawn as 4 horizontal bands:
|
||||
```rust
|
||||
let bands = 4;
|
||||
for band in 0..bands {
|
||||
let t = band as f32 / (bands - 1) as f32;
|
||||
let mut fill = mix_color(...);
|
||||
frame.fill_rectangle(
|
||||
Point::new(0.0, bounds.height * band as f32 / bands as f32),
|
||||
Size::new(fill_width, bounds.height / bands as f32 + 0.5),
|
||||
fill,
|
||||
);
|
||||
}
|
||||
```
|
||||
The `+ 0.5` height overlap and sub-pixel band edges create visible horizontal seams/aliasing, especially on high-DPI displays or when the widget height is small (22 px). The vertical handle line drawn on top also adds to the perceived noise.
|
||||
|
||||
### 2. Cannot reach 0 % / 100 % with the mouse
|
||||
The current drag handler only processes `CursorMoved` while `local` (cursor position in bounds) is `Some`:
|
||||
```rust
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = local {
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
...
|
||||
}
|
||||
(canvas::event::Status::Captured, None)
|
||||
}
|
||||
```
|
||||
When the user drags past the left or right edge of the slider, Iced stops reporting a local position, so the value is never clamped to the endpoint. The slider gets stuck just short of 0 % or 100 %.
|
||||
|
||||
In addition, `value_at` normalizes against `track_width = width - SPINNER_WIDTH`, so the effective draggable area is narrower than the full widget. The rightmost spinner region is reserved for step buttons, but the boundary between track and spinner is sensitive to small mouse movements.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Change A: Remove band-based fill artifact
|
||||
Replace the 4-band gradient loop with a single rounded rectangle fill.
|
||||
|
||||
Suggested draw code:
|
||||
```rust
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(0.0);
|
||||
let fill_width = track_width * self.fraction();
|
||||
if fill_width > 0.0 {
|
||||
let fill = self.colors.accent;
|
||||
let fill_rect = Path::rounded_rectangle(
|
||||
Point::new(0.0, 0.0),
|
||||
Size::new(fill_width, bounds.height),
|
||||
radius,
|
||||
);
|
||||
frame.fill(&fill_rect, fill);
|
||||
}
|
||||
```
|
||||
This eliminates:
|
||||
- The `+ 0.5` band overlap
|
||||
- Sub-pixel horizontal edges
|
||||
- The separate vertical handle line (the filled rounded rectangle itself acts as the value indicator)
|
||||
|
||||
If a gradient is still desired, use `canvas::Gradient` or a single linear gradient path instead of multiple overlapping rectangles.
|
||||
|
||||
### Change B: Clamp to endpoints when dragging outside bounds
|
||||
Use the global cursor position during an active drag so the slider value can continue updating even when the cursor leaves the widget.
|
||||
|
||||
In `update`:
|
||||
1. Store `dragging` state as already done.
|
||||
2. In the `CursorMoved` branch, if `state.dragging`:
|
||||
- If `local` is `Some`, use `position.x`.
|
||||
- If `local` is `None`, fall back to `cursor.position()` (global) and convert it to widget-local X by subtracting `bounds.x`.
|
||||
- Clamp the resulting X to `[0.0, track_width]`.
|
||||
- Compute the value with `value_at` and emit `on_change`.
|
||||
|
||||
Suggested helper:
|
||||
```rust
|
||||
fn drag_value(&self, cursor: mouse::Cursor, bounds: Rectangle) -> f32 {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(1.0);
|
||||
let x = match cursor.position_in(bounds) {
|
||||
Some(local) => local.x,
|
||||
None => cursor.position().map_or(0.0, |p| p.x - bounds.x),
|
||||
}
|
||||
.clamp(0.0, track_width);
|
||||
self.value_at(x, bounds.width)
|
||||
}
|
||||
```
|
||||
|
||||
This ensures:
|
||||
- Dragging left of the widget clamps to `range.start()` (0 %).
|
||||
- Dragging right of the widget clamps to `range.end()` (100 %).
|
||||
- Normal in-bounds dragging still works exactly as before.
|
||||
|
||||
### Change C: Preserve spinner step behavior while improving boundary feel
|
||||
Do **not** remove the spinner buttons. Keep the existing step-button logic in `ButtonPressed`:
|
||||
```rust
|
||||
if position.x >= bounds.width - SPINNER_WIDTH {
|
||||
// up/down step
|
||||
}
|
||||
```
|
||||
The boundary fix in Change B applies only during active drags. A click in the spinner area still performs a single step; a drag that started in the track area can now overshoot the track/spinner boundary and clamp correctly.
|
||||
|
||||
### Change D: Update or add unit tests
|
||||
The existing test `pointer_mapping_respects_endpoints_and_step` already covers in-bounds endpoint mapping. Extend it with out-of-bounds cases:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn pointer_mapping_clamps_outside_track() {
|
||||
let slider = test_slider("");
|
||||
assert_eq!(slider.value_at(-10.0, 124.0), 0.0);
|
||||
assert_eq!(slider.value_at(120.0, 124.0), 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
Add a test for the global-to-local conversion logic if it is extracted into a testable helper.
|
||||
|
||||
## Validation Steps
|
||||
1. Run the existing widget tests:
|
||||
```bash
|
||||
cargo test -p hcie-iced-gui widgets::plain_slider
|
||||
```
|
||||
2. Build the iced GUI crate:
|
||||
```bash
|
||||
cargo check -p hcie-iced-gui
|
||||
cargo check -p iced-panel-adapter
|
||||
```
|
||||
3. Manual visual check (if running the app):
|
||||
- Open a panel with a slider such as Opacity / Alpha.
|
||||
- Verify no horizontal streaks across the slider fill.
|
||||
- Drag the mouse left past the widget edge: value should snap to 0 %.
|
||||
- Drag the mouse right past the widget edge: value should snap to 100 %.
|
||||
- Verify spinner arrows still increment/decrement the value by one step.
|
||||
|
||||
## Risks and Mitigations
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Removing the gradient makes the slider look too flat | Use a single `canvas::Gradient` linear fill if the design requires a gradient; do not reintroduce overlapping bands. |
|
||||
| Global cursor fallback reports incorrect position on multi-window / scaled displays | Iced's `cursor.position()` is in logical window coordinates; subtracting `bounds.x` (also logical) is safe. Test on the target platform. |
|
||||
| Spinner clicks accidentally interpreted as drags | The `ButtonPressed` branch already returns `Captured` immediately for spinner clicks. Drags are only detected via `CursorMoved` after a non-spinner press, so the distinction remains. |
|
||||
|
||||
## Open Questions
|
||||
None — the user confirmed Iced-only scope (option A) and the design direction is straightforward.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Plan: Relocate Criterion Results + Optimize to 2× gold_standard
|
||||
|
||||
## Context
|
||||
|
||||
Criterion benchmarks exist under `target/criterion/` with gold-standard baselines
|
||||
already saved. The `criterion-test` branch introduced benchmarks; the current
|
||||
`performance-optimization` branch added `bench = false` and reverted `blend_mode`.
|
||||
|
||||
**Current gold_standard timings (mean):**
|
||||
|
||||
| Benchmark | ns (mean) | ~seconds |
|
||||
|---|---|---|
|
||||
| `below_cache_reuse/first_stroke_cache_build` | 807,729,115 | 0.81 s |
|
||||
| `below_cache_reuse/second_stroke_cache_reuse` | 14,573,274 | 0.015 s |
|
||||
| `composite_scratch_pooling/cold_composite` | 1,048,196,885 | 1.05 s |
|
||||
| `composite_scratch_pooling/warm_composite` | 10,073,839 | 0.010 s |
|
||||
| `effects_skip/no_effects_composite` | 14,340,411 | 0.014 s |
|
||||
| `effects_skip/with_one_dropshadow` | 14,780,582 | 0.015 s |
|
||||
| `stroke_mask_pooling/cold_first_stroke` | 46,427,571 | 0.046 s |
|
||||
| `stroke_mask_pooling/warm_10th_stroke` | 16,749,902 | 0.017 s |
|
||||
|
||||
Dominant costs: **first_stroke_cache_build** (0.81 s) and **cold_composite** (1.05 s).
|
||||
|
||||
**Target:** 2× faster on cold/first-stroke paths; zero regressions on warm paths.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Relocate `target/criterion` to project root
|
||||
|
||||
### 1.1: Move the results directory
|
||||
```bash
|
||||
mv target/criterion ./criterion
|
||||
```
|
||||
|
||||
### 1.2: Update `.gitignore`
|
||||
Add `!/criterion` exception. The current rule `/target/` won't affect root-level `criterion/` but `/target/` plus `**/target/` should be reviewed. Actually `/target/` only matches the root `target/` dir, and `**/target/` matches nested ones, so `criterion/` at root is fine. No `.gitignore` change needed.
|
||||
|
||||
### 1.3: Configure criterion output directory
|
||||
Criterion 0.5 uses `CRITERION_HOME` env var (defaults to `target/criterion`). After move, either:
|
||||
|
||||
**Option A (preferred):** Set `CRITERION_HOME` via `.cargo/config.toml`:
|
||||
```toml
|
||||
[env]
|
||||
CRITERION_HOME = "criterion"
|
||||
```
|
||||
|
||||
**Option B:** Export before benching:
|
||||
```bash
|
||||
CRITERION_HOME=criterion cargo bench -p hcie-engine-api
|
||||
```
|
||||
|
||||
### 1.4: Update `criterion.md`
|
||||
Replace `./target/criterion/report/index.html` → `./criterion/report/index.html`.
|
||||
|
||||
### 1.5: Update `notlar.txt`
|
||||
Update the bench command section to reflect `CRITERION_HOME=criterion` usage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Fix benchmark correctness
|
||||
|
||||
### 2.1: Remove `bench = false` from `hcie-engine-api/Cargo.toml`
|
||||
Line 37: `bench = false` must be removed for `[[bench]]` targets to work correctly.
|
||||
This was a workaround from commit `ee6ad2d`.
|
||||
|
||||
### 2.2: Verify `effects_skip.rs` blend_mode
|
||||
Current `"Normal".to_string()` matches `LayerStyle::DropShadow { blend_mode: String }` — correct. The `criterion-test` branch had `hcie_engine_api::BlendMode::Normal` which is a type mismatch. No change needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Optimization loop targeting 2× on cold paths
|
||||
|
||||
**Prerequisite:** Engine crates are locked (`chmod 444`). Use `./unlock.sh <crate>` to modify, `./lock.sh <crate>` after.
|
||||
|
||||
### 3.0: Save current state as gold_standard baseline
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
### 3.1: Optimize `below_cache` construction (first_stroke_cache_build: 0.81 s → ≤0.40 s)
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-engine-api`
|
||||
**File:** `hcie-engine-api/src/stroke_cache.rs:513-608` → `rebuild_below_cache_if_needed()`
|
||||
|
||||
The function calls `tiled::composite_tiled_into()` for the full canvas (3840×2160). Optimization candidates:
|
||||
|
||||
1. **Parallelize the below-cache tile build loop** (line 547-561). Currently iterates layers sequentially to build tiles. Use `par_iter` for tile construction.
|
||||
|
||||
2. **Skip fully invisible layers in below-cache.** Check `tl.visible` before compositing.
|
||||
|
||||
3. **Pre-fill with topmost opaque Normal-blend layer.** Walk bottom→top, find the first fully opaque Normal-blend layer, fill everything below it at once.
|
||||
|
||||
4. **Reduce zero-fill cost.** The 33 MB `cache.fill(0)` at line 564 is sequential. Consider `unsafe { std::ptr::write_bytes }` for SIMD-accelerated zeroing.
|
||||
|
||||
**Lock:** `./lock.sh hcie-engine-api`
|
||||
|
||||
### 3.2: Optimize cold composite (cold_composite: 1.05 s → ≤0.52 s)
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-engine-api`
|
||||
**File:** `hcie-engine-api/src/partial_composite.rs:90-228` → `render_composite_region()` cold path (lines 194-222)
|
||||
|
||||
The cold path zeroes the scratch buffer then composites all layers. Optimization candidates:
|
||||
|
||||
1. **Parallel scratch buffer zeroing.** Replace row-by-row `buf[start..end].fill(0)` with `par_chunks_exact_mut`.
|
||||
|
||||
2. **Merge zero-fill with first-layer composite.** Instead of fill(0) then composite, start the first layer's composite directly (it overwrites anyway if using Normal blend).
|
||||
|
||||
3. **Skip tile sync for unchanged layers.** In `apply_effects_and_sync_tiles()`, the `sync_dirty_tiles()` re-tiles every dirty layer even if pixels haven't changed since last sync.
|
||||
|
||||
4. **Cache the tile compositing results.** If the same layer stack was composited before, reuse intermediate results.
|
||||
|
||||
**Lock:** `./lock.sh hcie-engine-api`
|
||||
|
||||
### 3.3: Optimize tiled compositing inner loop
|
||||
|
||||
**Unlock:** `./unlock.sh hcie-composite`
|
||||
**File:** `hcie-composite/src/tiled.rs`
|
||||
|
||||
The `composite_tiled_into()` function handles small-region vs full-canvas compositing. The AGENTS.md note says small regions use sequential, large regions use `par_chunks_exact_mut`. Verify the full-canvas path (below-cache build and cold composite) actually uses parallel compositing.
|
||||
|
||||
Optimization candidates:
|
||||
1. **Ensure `par_chunks_exact_mut` is used for full-canvas compositing** in tiled.rs.
|
||||
2. **Reduce per-tile overhead.** Check if tile lookups, bounds checks, or branching in the hot loop can be eliminated.
|
||||
|
||||
**Lock:** `./lock.sh hcie-composite`
|
||||
|
||||
### 3.4: Validate after each optimization
|
||||
|
||||
After EACH change:
|
||||
```bash
|
||||
# Pixel-perfect correctness (MUST pass)
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
|
||||
# Regression check vs gold_standard (MUST show improvement, no regressions)
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
|
||||
# Performance regression test
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
### 3.5: Re-save gold_standard after achieving target
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Re-lock all engine crates
|
||||
```bash
|
||||
./lock.sh hcie-engine-api
|
||||
./lock.sh hcie-composite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `target/criterion/` → `./criterion/` | Directory move |
|
||||
| `.cargo/config.toml` | Add `CRITERION_HOME = "criterion"` |
|
||||
| `criterion.md` | Update output path |
|
||||
| `notlar.txt` | Update bench instructions |
|
||||
| `hcie-engine-api/Cargo.toml` | Remove `bench = false` |
|
||||
| `hcie-engine-api/src/stroke_cache.rs` | below_cache optimization |
|
||||
| `hcie-engine-api/src/partial_composite.rs` | Cold composite optimization |
|
||||
| `hcie-composite/src/tiled.rs` | Tiled compositing optimization |
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `CRITERION_HOME` doesn't work with criterion 0.5 | Fallback: use `--output-dir criterion` flag or `.cargo/config.toml` `[env]` section |
|
||||
| Engine optimizations break pixel-perfect rendering | Run `visual_regression` test after every change |
|
||||
| Changes to hot path break AGENTS.md-protected mechanisms | Verify all protected mechanisms still pass; no changes to pooling logic correctness |
|
||||
| 2× target not achievable with these optimizations | Profile further; consider SIMD, parallelism in deeper layers |
|
||||
|
||||
---
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
cargo bench -p hcie-engine-api --no-run
|
||||
```
|
||||
@@ -0,0 +1,218 @@
|
||||
# Criterion Merge & 2x Optimization Plan
|
||||
|
||||
## Context
|
||||
|
||||
Two criterion output directories exist:
|
||||
- **Root `criterion/`** — has HTML report (`report/index.html`) + all 8 `gold_standard` baseline variants
|
||||
- **`hcie-engine-api/criterion/`** — partial duplicate (4 benchmark dirs, no report, no gold_standard)
|
||||
|
||||
Root cause: each benchmark file hardcodes `Criterion::default().output_directory(Path::new("criterion"))` — a relative path that resolves differently depending on CWD. No `.cargo/config.toml` exists to set `CRITERION_HOME`.
|
||||
|
||||
Gold standard timings (from root `criterion/`):
|
||||
|
||||
| Benchmark | Mean | Dominated by |
|
||||
|-----------|------|--------------|
|
||||
| `below_cache_reuse/first_stroke_cache_build` | 0.81 s | Full 9-layer below-cache composite |
|
||||
| `below_cache_reuse/second_stroke_cache_reuse` | 0.015 s | Cache hit — fast |
|
||||
| `composite_scratch_pooling/cold_composite` | 1.05 s | Full 10-layer composite + scratch alloc |
|
||||
| `composite_scratch_pooling/warm_composite` | 0.010 s | Pooled scratch — fast |
|
||||
| `effects_skip/no_effects_composite` | 0.014 s | Effects skipped — fast |
|
||||
| `effects_skip/with_one_dropshadow` | 0.015 s | Single effect — fast |
|
||||
| `stroke_mask_pooling/cold_first_stroke` | 0.046 s | Mask alloc + first stroke |
|
||||
| `stroke_mask_pooling/warm_10th_stroke` | 0.017 s | Pooled mask — fast |
|
||||
|
||||
## Goal
|
||||
|
||||
1. Merge criterion directories into single root `criterion/`
|
||||
2. Fix output path configuration so benchmarks always write to the same place
|
||||
3. Achieve 2x speedup across all 8 benchmark variants vs `gold_standard` baseline
|
||||
4. Update `notlar.txt` and `criterion.md` with correct paths
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1: Directory Merge & Path Fix (config-only, no engine changes)
|
||||
|
||||
**1.1** Delete `hcie-engine-api/criterion/` directory entirely
|
||||
```bash
|
||||
rm -rf hcie-engine-api/criterion/
|
||||
```
|
||||
|
||||
**1.2** Create `.cargo/config.toml` at workspace root:
|
||||
```toml
|
||||
[env]
|
||||
CRITERION_HOME = { value = "criterion", relative = true }
|
||||
```
|
||||
This ensures all `cargo bench` invocations write to `./criterion/` relative to workspace root, regardless of CWD.
|
||||
|
||||
**1.3** Remove hardcoded `output_directory` from all 4 benchmark files. Change:
|
||||
```rust
|
||||
criterion_group!{
|
||||
name = benches;
|
||||
config = Criterion::default().output_directory(std::path::Path::new("criterion"));
|
||||
targets = bench_xxx
|
||||
}
|
||||
```
|
||||
To:
|
||||
```rust
|
||||
criterion_group!(benches, bench_xxx);
|
||||
criterion_main!(benches);
|
||||
```
|
||||
Files to edit:
|
||||
- `hcie-engine-api/benches/stroke_mask_pooling.rs:119-124`
|
||||
- `hcie-engine-api/benches/composite_scratch_pooling.rs:145-150`
|
||||
- `hcie-engine-api/benches/below_cache_reuse.rs:145-150`
|
||||
- `hcie-engine-api/benches/effects_skip.rs:165-170`
|
||||
|
||||
**1.4** Remove `bench = false` from `hcie-engine-api/Cargo.toml` `[lib]` section (line 37). This was a workaround; with explicit `[[bench]]` targets it's not needed.
|
||||
|
||||
**1.5** Update `.gitignore` — add `criterion/` to prevent benchmark output from being committed (the gold_standard baseline should be kept but the report and run data are generated).
|
||||
|
||||
**1.6** Update `notlar.txt`:
|
||||
- Fix line 19: `CRITERION_HOME=criterion` → reference `.cargo/config.toml`
|
||||
- Fix line 20: `./criterion/report/index.html` stays correct (now guaranteed by config.toml)
|
||||
- Add note about `.cargo/config.toml` setting `CRITERION_HOME`
|
||||
|
||||
**1.7** Update `criterion.md`:
|
||||
- Fix line 40: `./criterion/report/index.html` stays correct
|
||||
- Add note about `.cargo/config.toml` configuration
|
||||
|
||||
**1.8** Verify: run `cargo bench -p hcie-engine-api` and confirm output goes to root `criterion/` only.
|
||||
|
||||
### Phase 2: Save Fresh Baseline & Verify
|
||||
|
||||
**2.1** Save a fresh `gold_standard` baseline after path fixes:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
**2.2** Run regression comparison to confirm baselines match:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
```
|
||||
|
||||
**2.3** Run visual regression and performance tests to confirm no breakage:
|
||||
```bash
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
### Phase 3: Optimization Loop
|
||||
|
||||
For each optimization, follow this cycle:
|
||||
1. Unlock target crate: `./unlock.sh <crate>`
|
||||
2. Apply optimization
|
||||
3. Build: `cargo build -p hcie-engine-api`
|
||||
4. Run benchmarks: `cargo bench -p hcie-engine-api -- --baseline gold_standard`
|
||||
5. Check for regressions: `cargo test -p hcie-engine-api --test visual_regression`
|
||||
6. If improvement < 2x, continue to next optimization
|
||||
7. Lock crate: `./lock.sh <crate>`
|
||||
|
||||
**Optimization targets (in priority order):**
|
||||
|
||||
#### 3.1 Pre-allocate pooled buffers in `Engine::new()` (hcie-engine-api)
|
||||
|
||||
Currently `composite_scratch`, `active_stroke_mask`, `below_cache`, and `stroke_before_buf` are all `None` at construction and allocated lazily on first use. The cold benchmarks pay this allocation cost.
|
||||
|
||||
Change `Engine::new_with_options()` in `hcie-engine-api/src/lib.rs` to pre-allocate:
|
||||
- `composite_scratch = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
- `active_stroke_mask = Some(vec![0u8; w * h])` — ~8MB
|
||||
- `below_cache = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
- `stroke_before_buf = Some(vec![0u8; w * h * 4])` — ~33MB
|
||||
|
||||
This moves allocation cost from first-stroke time to engine-creation time (which is excluded from benchmark measurement via `iter_with_setup`). Expected impact: cold benchmarks drop significantly because they no longer include allocation.
|
||||
|
||||
#### 3.2 Opaque-layer early-exit in composite (hcie-composite, hcie-engine-api)
|
||||
|
||||
In `composite_tiled_into` (`hcie-composite/src/tiled.rs`), when compositing layers bottom-to-top, if a layer is:
|
||||
- Fully opaque (alpha=255 everywhere in the dirty region)
|
||||
- Normal blend mode
|
||||
- 100% opacity
|
||||
|
||||
...then all layers below it are completely occluded and can be skipped.
|
||||
|
||||
Implementation: Before compositing each layer, check if the output buffer is already fully opaque in the dirty region. If so, skip remaining below-layers. This is most impactful for the `first_stroke_cache_build` and `cold_composite` benchmarks where all 10 layers are filled with opaque colors.
|
||||
|
||||
#### 3.3 `copy_from_slice` fast-path for opaque Normal layers (hcie-composite)
|
||||
|
||||
In the per-pixel blend loop, add a fast-path: if the source pixel is fully opaque (alpha=255) and blend mode is Normal, use `copy_from_slice` for the entire row instead of per-pixel blending. This avoids the blend math for the common case of opaque layers.
|
||||
|
||||
#### 3.4 Reduce `Vec::clone()` in hot paths (hcie-engine-api)
|
||||
|
||||
In `draw_filled_rect_rgba` (`stroke_brush.rs:582`): `layer.pixels.clone()` creates a full ~33MB copy for undo. Replace with `copy_from_slice` into a pooled buffer.
|
||||
|
||||
In `begin_stroke` (`stroke_cache.rs:200-208`): `layer.effects.clone()` and `layer.styles.clone()` are small but unnecessary — effects/styles are typically empty. Add an early-return if both are empty.
|
||||
|
||||
#### 3.5 Parallelize `rebuild_below_cache_if_needed` (hcie-engine-api)
|
||||
|
||||
The below-cache rebuild in `stroke_cache.rs:514-611` composites all layers below the active one. This is currently sequential. Use Rayon to parallelize the tile compositing within this function, similar to how `render_composite_region` uses `par_chunks_mut`.
|
||||
|
||||
#### 3.6 SIMD blend operations (hcie-blend, if needed)
|
||||
|
||||
If the above optimizations don't reach 2x, explore SIMD-accelerated blend operations in `hcie-blend`. The `blend_pixel` function is called millions of times per composite. Using `std::simd` or explicit SSE/AVX intrinsics could yield 4-8x speedup on blend operations.
|
||||
|
||||
### Phase 4: Final Verification
|
||||
|
||||
**4.1** Run full benchmark suite and compare against original gold_standard:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --baseline gold_standard
|
||||
```
|
||||
|
||||
**4.2** Run visual regression tests:
|
||||
```bash
|
||||
cargo test -p hcie-engine-api --test visual_regression
|
||||
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture
|
||||
```
|
||||
|
||||
**4.3** Save final optimized baseline:
|
||||
```bash
|
||||
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
|
||||
```
|
||||
|
||||
**4.4** Update `notlar.txt` with final benchmark results.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `.cargo/config.toml` | **Create** — set `CRITERION_HOME` |
|
||||
| `.gitignore` | Add `criterion/` entry |
|
||||
| `hcie-engine-api/Cargo.toml` | Remove `bench = false` from `[lib]` |
|
||||
| `hcie-engine-api/benches/stroke_mask_pooling.rs` | Remove `output_directory`, simplify `criterion_group!` |
|
||||
| `hcie-engine-api/benches/composite_scratch_pooling.rs` | Same |
|
||||
| `hcie-engine-api/benches/below_cache_reuse.rs` | Same |
|
||||
| `hcie-engine-api/benches/effects_skip.rs` | Same |
|
||||
| `notlar.txt` | Update paths and commands |
|
||||
| `criterion.md` | Update paths |
|
||||
| `hcie-engine-api/src/lib.rs` | Pre-allocate buffers in `new_with_options()` |
|
||||
| `hcie-engine-api/src/stroke_cache.rs` | Optimize `begin_stroke`, `rebuild_below_cache_if_needed` |
|
||||
| `hcie-engine-api/src/stroke_brush.rs` | Optimize `draw_filled_rect_rgba` clone |
|
||||
| `hcie-composite/src/tiled.rs` | Opaque-layer skip, `copy_from_slice` fast-path |
|
||||
|
||||
## Files to Delete
|
||||
|
||||
| Path | Reason |
|
||||
|------|--------|
|
||||
| `hcie-engine-api/criterion/` | Duplicate — root `criterion/` is canonical |
|
||||
|
||||
## Locked Crates That Need Unlocking
|
||||
|
||||
| Crate | Reason |
|
||||
|-------|--------|
|
||||
| `hcie-engine-api` | Pre-allocation, hot-path optimizations |
|
||||
| `hcie-composite` | Opaque-layer skip, copy_from_slice fast-path |
|
||||
| `hcie-blend` | SIMD blend (only if Phase 3.1-3.5 insufficient) |
|
||||
|
||||
## Validation
|
||||
|
||||
After each phase:
|
||||
- `cargo build -p hcie-engine-api` must succeed
|
||||
- `cargo bench -p hcie-engine-api` must produce output in root `criterion/` only
|
||||
- `cargo test -p hcie-engine-api --test visual_regression` must pass (8/8)
|
||||
- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` must not regress
|
||||
- `cargo bench -p hcie-engine-api -- --baseline gold_standard` must show improvement (not regression)
|
||||
|
||||
## Risks
|
||||
|
||||
- **Pre-allocation increases engine memory footprint** by ~107MB (33+8+33+33). This is acceptable for a 4K image editor where the document itself is already ~33MB per layer.
|
||||
- **Opaque-layer skip changes composite behavior** if a layer has non-Normal blend mode but appears opaque. The check must verify both alpha=255 AND blend mode=Normal.
|
||||
- **SIMD requires `std::simd` (unstable)** or explicit intrinsics with `cfg(target_feature)` guards. Prefer portable approaches first.
|
||||
@@ -0,0 +1 @@
|
||||
,dev-user,hc-20w1s30u2j,24.07.2026 16:44,file:///home/dev-user/.config/libreoffice/4;
|
||||
Generated
+166
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Canvas Performance Regression Protection — Walkthrough
|
||||
|
||||
HCIE v4 engine API'si üzerindeki 4K tuval optimizasyonlarının yanlışlıkla bozulmasını (regresyon) engellemek için `criterion` kütüphanesi entegre edildi ve dört kritik mekanizma için kalıcı benchmark testleri yazıldı.
|
||||
|
||||
## Neler Yapıldı?
|
||||
|
||||
1. **`hcie-engine-api/Cargo.toml` Güncellemesi**
|
||||
- `criterion` kütüphanesi dev-dependency olarak eklendi.
|
||||
- Her benchmark için özel `[[bench]]` kayıtları tanımlandı.
|
||||
|
||||
2. **`active_stroke_mask` Pooling Koruması**
|
||||
- [benches/stroke_mask_pooling.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/stroke_mask_pooling.rs)
|
||||
- Brush engine'in kullandığı ~8 MB mask buffer'ın `end_stroke()` sırasında serbest bırakılıp bırakılmadığını kontrol eder.
|
||||
- İlk tahsis (cold) ile sonraki ardışık strokların (warm) sürelerini karşılaştırır.
|
||||
|
||||
3. **`composite_scratch` Pooling Koruması**
|
||||
- [benches/composite_scratch_pooling.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/composite_scratch_pooling.rs)
|
||||
- ~33 MB composite render buffer'ın `render_composite_region()` içinde yeniden kullanıldığını doğrular.
|
||||
|
||||
4. **`below_cache` Ardışık Stroke Reuse Koruması**
|
||||
- [benches/below_cache_reuse.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/below_cache_reuse.rs)
|
||||
- Aynı katmanda birden çok stroke atıldığında alttaki katmanların yeniden derlenmek yerine (cache rebuild) mevcut cache'in kullanıldığını test eder.
|
||||
- 4K çözünürlükte, 10 katmanlı senaryoda cache build (ilk vuruş) ve cache reuse (ikinci vuruş) maliyetlerini ayırır.
|
||||
|
||||
5. **`effects_dirty` Conditional Skip Koruması**
|
||||
- [benches/effects_skip.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-engine-api/benches/effects_skip.rs)
|
||||
- Efekt/stili olmayan katmanlarda pahalı effects pipeline (ve 33 MB buffer kopyası) adımının bypass edildiğinden emin olur.
|
||||
|
||||
## Doğrulama Sonuçları
|
||||
|
||||
- Yeni criterion testlerinin API erişimleri (özellikle `update_layer_style` fonksiyon imzası) ve bağımlılıkları derlenip onaylandı.
|
||||
- Eski golden testler olan `visual_regression.rs` çalıştırılarak yeni performans testlerinin deterministik render sonuçlarını (pixel-perfect) bozmadığı **doğrulandı** (8/8 başarılı).
|
||||
- Eski performans testi `performance_stroke_4k.rs` de sorunsuz çalıştırıldı.
|
||||
|
||||
> [!TIP]
|
||||
> **Tüm Benchmark'ları Çalıştırmak İçin:**
|
||||
> Terminal üzerinden engine dizinine giderek şu komutu kullanabilirsiniz:
|
||||
> ```bash
|
||||
> cargo bench -p hcie-engine-api
|
||||
> ```
|
||||
> _Not: Bu testler 4K ve çok katmanlı benchmarklar içerdiğinden belleği yoğun kullanır. Sonuçlar `./criterion/report/index.html` olarak dökülecektir. CRITERION_HOME, `.cargo/config.toml` içinde `relative = true` olarak ayarlanmıştır; tüm `cargo bench` çağrıları workspace root'taki `criterion/` dizinine yazar._
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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<Vec<u8>>` 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);
|
||||
@@ -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<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, 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: "Normal".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// 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);
|
||||
@@ -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);
|
||||
@@ -25,6 +25,7 @@ use crate::dynamic_loader::vector::render_vector_shapes;
|
||||
use crate::dynamic_loader::{composite_layers, tiled};
|
||||
use crate::Engine;
|
||||
use hcie_tile::TiledLayer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
impl Engine {
|
||||
/// **Purpose:**
|
||||
@@ -196,10 +197,15 @@ impl Engine {
|
||||
"[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]",
|
||||
self.document.layers.len(), x0, y0, x1, y1
|
||||
);
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
buf[start..end].fill(0);
|
||||
let is_full_canvas = x0 == 0 && y0 == 0 && x1 == w && y1 == h;
|
||||
if is_full_canvas {
|
||||
buf.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0));
|
||||
} else {
|
||||
for y in y0..y1 {
|
||||
let start = (y as usize * wu + x0u) * 4;
|
||||
let end = start + ((x1 - x0) as usize) * 4;
|
||||
buf[start..end].fill(0);
|
||||
}
|
||||
}
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers,
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::dynamic_loader::tiled;
|
||||
use crate::Engine;
|
||||
use hcie_protocol::LayerData;
|
||||
use hcie_tile::TiledLayer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// Wrapper to send a raw pixel pointer to a background thread as a `usize`.
|
||||
///
|
||||
@@ -543,62 +544,64 @@ impl Engine {
|
||||
if self.tile_layers.len() < lcount {
|
||||
self.tile_layers.resize_with(lcount, || None);
|
||||
}
|
||||
let mut tiles_built = 0usize;
|
||||
for (ti, tl) in self.document.layers.iter().enumerate() {
|
||||
if ti >= active_idx {
|
||||
break;
|
||||
}
|
||||
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
||||
self.tile_layers[ti] =
|
||||
Some(TiledLayer::from_dense(&tl.pixels, tl.width, tl.height));
|
||||
tiles_built += 1;
|
||||
log::trace!(
|
||||
"[begin_stroke] built tile for below layer[{}] id={}",
|
||||
ti,
|
||||
tl.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.fill(0);
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| (i, l.visible))
|
||||
|
||||
let layers_needing_tiles: Vec<usize> = (0..active_idx)
|
||||
.filter(|&ti| !self.document.layers[ti].pixels.is_empty() && self.tile_layers[ti].is_none())
|
||||
.collect();
|
||||
log::trace!(
|
||||
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
||||
active_idx, tile_slice_len, visible_below, tiles_built
|
||||
);
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw,
|
||||
ch,
|
||||
0,
|
||||
0,
|
||||
cw,
|
||||
ch,
|
||||
&mut cache,
|
||||
);
|
||||
if log::log_enabled!(log::Level::Trace) {
|
||||
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
||||
log::trace!(
|
||||
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
||||
cache.len(),
|
||||
non_zero,
|
||||
active_idx
|
||||
);
|
||||
if !layers_needing_tiles.is_empty() {
|
||||
let tile_data: Vec<(usize, TiledLayer)> = layers_needing_tiles
|
||||
.par_iter()
|
||||
.map(|&ti| {
|
||||
let l = &self.document.layers[ti];
|
||||
(ti, TiledLayer::from_dense(&l.pixels, l.width, l.height))
|
||||
})
|
||||
.collect();
|
||||
for (ti, tl) in tile_data {
|
||||
self.tile_layers[ti] = Some(tl);
|
||||
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, self.document.layers[ti].id);
|
||||
}
|
||||
}
|
||||
|
||||
let top_below = &self.document.layers[active_idx - 1];
|
||||
let top_is_opaque_normal = top_below.visible
|
||||
&& top_below.blend_mode == hcie_protocol::BlendMode::Normal
|
||||
&& (top_below.opacity - 1.0).abs() < f32::EPSILON
|
||||
&& top_below.adjustment.is_none()
|
||||
&& top_below.effects.is_empty()
|
||||
&& top_below.styles.is_empty()
|
||||
&& !top_below.clipping_mask
|
||||
&& top_below.width == cw
|
||||
&& top_below.height == ch
|
||||
&& top_below.pixels.len() == buf_size
|
||||
&& top_below.pixels.par_chunks_exact(4).all(|c| c[3] == 255);
|
||||
|
||||
if top_is_opaque_normal {
|
||||
let cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => { b.copy_from_slice(&top_below.pixels); b }
|
||||
_ => top_below.pixels.clone(),
|
||||
};
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
log::trace!("[begin_stroke] below_cache fast-path: topmost below layer[{}] opaque Normal, skip compositing", active_idx - 1);
|
||||
} else {
|
||||
let mut cache = match self.below_cache.take() {
|
||||
Some(mut b) if b.len() == buf_size => {
|
||||
b.par_chunks_mut(65536).for_each(|chunk| chunk.fill(0));
|
||||
b
|
||||
}
|
||||
_ => vec![0u8; buf_size],
|
||||
};
|
||||
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
||||
tiled::composite_tiled_into(
|
||||
&self.document.layers[..active_idx],
|
||||
&self.tile_layers[..tile_slice_len],
|
||||
cw, ch, 0, 0, cw, ch, &mut cache,
|
||||
);
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
}
|
||||
self.below_cache = Some(cache);
|
||||
self.below_cache_active_idx = Some(active_idx);
|
||||
self.below_cache_dirty = false;
|
||||
} else {
|
||||
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
||||
self.below_cache = None;
|
||||
|
||||
@@ -110,6 +110,25 @@ impl<Message> PlainSlider<Message> {
|
||||
self.snap(*self.range.start() + (*self.range.end() - *self.range.start()) * fraction)
|
||||
}
|
||||
|
||||
/// Computes the slider value for the current cursor position while dragging.
|
||||
///
|
||||
/// **Purpose:** Allow an active drag to continue updating even when the cursor moves
|
||||
/// slightly outside the widget bounds, so 0 % and 100 % can always be reached.
|
||||
/// **Logic & Workflow:** If the cursor is inside the widget bounds, use its local X.
|
||||
/// Otherwise fall back to the global cursor position and convert it to widget-local
|
||||
/// X by subtracting `bounds.x`. Clamp the resulting X to the draggable track width.
|
||||
/// **Arguments:** `cursor` is the current mouse cursor; `bounds` is the widget rectangle.
|
||||
/// **Returns:** The stepped value at the clamped cursor position.
|
||||
fn drag_value(&self, cursor: mouse::Cursor, bounds: Rectangle) -> f32 {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(1.0);
|
||||
let x = match cursor.position_in(bounds) {
|
||||
Some(local) => local.x,
|
||||
None => cursor.position().map_or(0.0, |p| p.x - bounds.x),
|
||||
}
|
||||
.clamp(0.0, track_width);
|
||||
self.value_at(x, bounds.width)
|
||||
}
|
||||
|
||||
/// Returns the normalized fill fraction for the current value.
|
||||
fn fraction(&self) -> f32 {
|
||||
let span = *self.range.end() - *self.range.start();
|
||||
@@ -170,22 +189,10 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
let track_width = (bounds.width - SPINNER_WIDTH).max(0.0);
|
||||
let fill_width = track_width * self.fraction();
|
||||
if fill_width > 0.0 {
|
||||
let bands = 4;
|
||||
for band in 0..bands {
|
||||
let t = band as f32 / (bands - 1) as f32;
|
||||
let mut fill = mix_color(self.colors.accent, self.colors.accent_hover, t * 0.55);
|
||||
fill.a = if self.colors.is_light { 0.30 } else { 0.25 };
|
||||
frame.fill_rectangle(
|
||||
Point::new(0.0, bounds.height * band as f32 / bands as f32),
|
||||
Size::new(fill_width, bounds.height / bands as f32 + 0.5),
|
||||
fill,
|
||||
);
|
||||
}
|
||||
frame.fill_rectangle(
|
||||
Point::new((fill_width - 1.5).max(0.0), 1.0),
|
||||
Size::new(3.0_f32.min(fill_width), (bounds.height - 2.0).max(0.0)),
|
||||
self.colors.accent,
|
||||
);
|
||||
let mut fill = self.colors.accent;
|
||||
fill.a = if self.colors.is_light { 0.30 } else { 0.25 };
|
||||
let fill_rect = Path::rounded_rectangle(Point::new(0.0, 0.0), Size::new(fill_width, bounds.height), radius);
|
||||
frame.fill(&fill_rect, fill);
|
||||
}
|
||||
|
||||
frame.stroke(
|
||||
@@ -298,14 +305,11 @@ impl<Message> canvas::Program<Message> for PlainSlider<Message> {
|
||||
(canvas::event::Status::Ignored, None)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||||
if let Some(position) = local {
|
||||
let value = self.value_at(position.x, bounds.width);
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some((self.on_change)(value)),
|
||||
);
|
||||
}
|
||||
(canvas::event::Status::Captured, None)
|
||||
let value = self.drag_value(cursor, bounds);
|
||||
return (
|
||||
canvas::event::Status::Captured,
|
||||
Some((self.on_change)(value)),
|
||||
);
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if state.dragging {
|
||||
@@ -409,17 +413,6 @@ fn is_numeric_fragment(text: &str) -> bool {
|
||||
.all(|character| character.is_ascii_digit() || matches!(character, '.' | ',' | '-'))
|
||||
}
|
||||
|
||||
/// Blends two theme colors while preserving alpha interpolation.
|
||||
fn mix_color(from: iced::Color, to: iced::Color, amount: f32) -> iced::Color {
|
||||
let amount = amount.clamp(0.0, 1.0);
|
||||
iced::Color::new(
|
||||
from.r + (to.r - from.r) * amount,
|
||||
from.g + (to.g - from.g) * amount,
|
||||
from.b + (to.b - from.b) * amount,
|
||||
from.a + (to.a - from.a) * amount,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_numeric_fragment, PlainSlider, State};
|
||||
@@ -463,4 +456,11 @@ mod tests {
|
||||
assert_eq!(slider.value_at(100.0, 124.0), 1.0);
|
||||
assert!((slider.value_at(33.0, 124.0) - 0.33).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_mapping_clamps_outside_track() {
|
||||
let slider = test_slider("");
|
||||
assert_eq!(slider.value_at(-10.0, 124.0), 0.0);
|
||||
assert_eq!(slider.value_at(120.0, 124.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
+19
-19
@@ -2,7 +2,7 @@
|
||||
SATIR SAYISI RAPORU
|
||||
Proje: HCIE-Rust v4
|
||||
Konum: /mnt/extra/00_PROJECTS/hcie-rust-v3.05
|
||||
Tarih: 2026-07-21 05:08:37
|
||||
Tarih: 2026-07-24 20:30:00
|
||||
=========================================
|
||||
|
||||
-----------------------------------------
|
||||
@@ -10,24 +10,24 @@ Tarih: 2026-07-21 05:08:37
|
||||
-----------------------------------------
|
||||
hcie-ai 2402 satır
|
||||
hcie-blend 530 satır
|
||||
hcie-brush-engine 4845 satır
|
||||
hcie-brush-engine 6190 satır
|
||||
hcie-build-info 93 satır
|
||||
hcie-color 85 satır
|
||||
hcie-composite 1456 satır
|
||||
hcie-document 761 satır
|
||||
hcie-composite 1482 satır
|
||||
hcie-document 879 satır
|
||||
hcie-draw 619 satır
|
||||
hcie-egui-app 37434 satır
|
||||
hcie-engine-api 8366 satır
|
||||
hcie-egui-app 37645 satır
|
||||
hcie-engine-api 9782 satır
|
||||
hcie-engine-api-orig 3874 satır
|
||||
hcie-filter 3238 satır
|
||||
hcie-fx 5535 satır
|
||||
hcie-history 139 satır
|
||||
hcie-iced-app 38968 satır
|
||||
hcie-iced-app 42998 satır
|
||||
hcie-io 12122 satır
|
||||
hcie-kra 1398 satır
|
||||
hcie-native 153 satır
|
||||
hcie-kra 1716 satır
|
||||
hcie-native 218 satır
|
||||
hcie-protocol 3756 satır
|
||||
hcie-psd 5120 satır
|
||||
hcie-psd 5136 satır
|
||||
hcie-psd-saver 1277 satır
|
||||
hcie-selection 392 satır
|
||||
hcie-text 1010 satır
|
||||
@@ -35,7 +35,7 @@ Tarih: 2026-07-21 05:08:37
|
||||
hcie-vector 2921 satır
|
||||
hcie-vision 3131 satır
|
||||
|
||||
TOPLAM RUST: 139986 satır
|
||||
TOPLAM RUST: 147531 satır
|
||||
|
||||
-----------------------------------------
|
||||
C++ / HEADER (.cpp, .h)
|
||||
@@ -86,10 +86,10 @@ Tarih: 2026-07-21 05:08:37
|
||||
-----------------------------------------
|
||||
SHELL SCRIPT (.sh)
|
||||
-----------------------------------------
|
||||
(kök dizin) 1306 satır
|
||||
(kök dizin) 1689 satır
|
||||
logs/ 220 satır
|
||||
|
||||
TOPLAM SHELL: 1526 satır
|
||||
TOPLAM SHELL: 1909 satır
|
||||
|
||||
-----------------------------------------
|
||||
DOKÜMANTASYON / VERİ DOSYALARI
|
||||
@@ -103,14 +103,14 @@ Tarih: 2026-07-21 05:08:37
|
||||
|
||||
MARKDOWN (.md)
|
||||
--------------------------------
|
||||
(kök dizin) 17606 satır
|
||||
(kök dizin) 20147 satır
|
||||
|
||||
TOPLAM MARKDOWN: 17606 satır
|
||||
TOPLAM MARKDOWN: 20147 satır
|
||||
|
||||
=========================================
|
||||
KOD SATIRLARI TOPLAMI (GRAND TOTAL)
|
||||
=========================================
|
||||
Rust: 139986 satır
|
||||
Rust: 147531 satır
|
||||
C++ / Header: 5031 satır
|
||||
JavaScript: 0 satır
|
||||
Svelte: 0 satır
|
||||
@@ -118,11 +118,11 @@ Tarih: 2026-07-21 05:08:37
|
||||
Python: 2267 satır
|
||||
HTML: 0 satır
|
||||
CSS: 0 satır
|
||||
Shell Script: 1526 satır
|
||||
Shell Script: 1909 satır
|
||||
-----------------------------------------
|
||||
KOD TOPLAMI: 148810 satır
|
||||
KOD TOPLAMI: 156738 satır
|
||||
|
||||
RUST + C++/H TOPLAMI: 145017 satır
|
||||
RUST + C++/H TOPLAMI: 152562 satır
|
||||
|
||||
(JSON ve Markdown dosyaları yukarıda ayrı olarak gösterilmiştir)
|
||||
=========================================
|
||||
|
||||
+33
@@ -4,6 +4,38 @@ cargo run --example gui
|
||||
cargo run -p hcie-iced-gui
|
||||
cargo build -p hcie-iced-gui --bin hcie-iced
|
||||
scripts/cargo-with-build-id.sh run -p hcie-iced-gui
|
||||
#######
|
||||
criterion Benchmark
|
||||
cargo bench -p hcie-engine-api
|
||||
|
||||
# Mevcut mükemmel performansı kalıcı bir baseline (referans) olarak kaydetmek için:
|
||||
# (Not: bench = false kaldırıldı, [[bench]] target'ları doğrudan çalışıyor)
|
||||
cargo bench -p hcie-engine-api --bench stroke_mask_pooling -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench composite_scratch_pooling -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench below_cache_reuse -- --save-baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench effects_skip -- --save-baseline gold_standard
|
||||
|
||||
# Kaydedilen 'gold_standard' baseline'ı ile karşılaştırma (regresyon testi) yapmak için:
|
||||
cargo bench -p hcie-engine-api --bench stroke_mask_pooling -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench composite_scratch_pooling -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench below_cache_reuse -- --baseline gold_standard
|
||||
cargo bench -p hcie-engine-api --bench effects_skip -- --baseline gold_standard
|
||||
|
||||
# Criterion sonuçları ./criterion/ dizinine kaydedilir
|
||||
# CRITERION_HOME, .cargo/config.toml içinde ayarlanmıştır (relative = true)
|
||||
# Rapor: ./criterion/report/index.html
|
||||
#criterion açıklamaları
|
||||
criterion.md
|
||||
|
||||
# Gold Standard Baseline (2026-07-25, par_chunks_exact optimization):
|
||||
# stroke_mask_pooling/cold_first_stroke: 39.74 ms
|
||||
# stroke_mask_pooling/warm_10th_stroke: 17.34 ms
|
||||
# composite_scratch_pooling/cold_composite: 770.86 ms
|
||||
# composite_scratch_pooling/warm_composite: 10.43 ms
|
||||
# below_cache_reuse/first_stroke_cache_build: 781.66 ms
|
||||
# below_cache_reuse/second_stroke_cache_reuse: 16.33 ms
|
||||
# effects_skip/no_effects_composite: 17.15 ms
|
||||
# effects_skip/with_one_dropshadow: 15.16 ms
|
||||
|
||||
##test
|
||||
Running the Tests Automatically
|
||||
@@ -13,6 +45,7 @@ cargo test -p hcie-iced-gui -- proximate
|
||||
To run all cycle_one_ux_tests (including the new ones):
|
||||
cargo test -p hcie-iced-gui -- cycle_one_ux_tests
|
||||
|
||||
###########
|
||||
To run the FULL test suite for the iced GUI crate:
|
||||
cargo test -p hcie-iced-gui
|
||||
|
||||
|
||||
Reference in New Issue
Block a user