- Updated stroke cache to use parallel processing for pixel checks. - Modified benchmark commands in notlar.txt to ensure consistent output and baseline comparisons. - Created a plan for merging criterion directories and optimizing benchmark performance, including pre-allocation of buffers and various optimization strategies. - Removed redundant output directory settings in benchmark files and ensured all benchmarks write to a single criterion directory. - Updated documentation to reflect changes in benchmark paths and configurations.
10 KiB
Criterion Merge & 2x Optimization Plan
Context
Two criterion output directories exist:
- Root
criterion/— has HTML report (report/index.html) + all 8gold_standardbaseline 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
- Merge criterion directories into single root
criterion/ - Fix output path configuration so benchmarks always write to the same place
- Achieve 2x speedup across all 8 benchmark variants vs
gold_standardbaseline - Update
notlar.txtandcriterion.mdwith correct paths
Task List
Phase 1: Directory Merge & Path Fix (config-only, no engine changes)
1.1 Delete hcie-engine-api/criterion/ directory entirely
rm -rf hcie-engine-api/criterion/
1.2 Create .cargo/config.toml at workspace root:
[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:
criterion_group!{
name = benches;
config = Criterion::default().output_directory(std::path::Path::new("criterion"));
targets = bench_xxx
}
To:
criterion_group!(benches, bench_xxx);
criterion_main!(benches);
Files to edit:
hcie-engine-api/benches/stroke_mask_pooling.rs:119-124hcie-engine-api/benches/composite_scratch_pooling.rs:145-150hcie-engine-api/benches/below_cache_reuse.rs:145-150hcie-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.htmlstays correct (now guaranteed by config.toml) - Add note about
.cargo/config.tomlsettingCRITERION_HOME
1.7 Update criterion.md:
- Fix line 40:
./criterion/report/index.htmlstays correct - Add note about
.cargo/config.tomlconfiguration
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:
cargo bench -p hcie-engine-api -- --save-baseline gold_standard
2.2 Run regression comparison to confirm baselines match:
cargo bench -p hcie-engine-api -- --baseline gold_standard
2.3 Run visual regression and performance tests to confirm no breakage:
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:
- Unlock target crate:
./unlock.sh <crate> - Apply optimization
- Build:
cargo build -p hcie-engine-api - Run benchmarks:
cargo bench -p hcie-engine-api -- --baseline gold_standard - Check for regressions:
cargo test -p hcie-engine-api --test visual_regression - If improvement < 2x, continue to next optimization
- 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])— ~33MBactive_stroke_mask = Some(vec![0u8; w * h])— ~8MBbelow_cache = Some(vec![0u8; w * h * 4])— ~33MBstroke_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:
cargo bench -p hcie-engine-api -- --baseline gold_standard
4.2 Run visual regression tests:
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:
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-apimust succeedcargo bench -p hcie-engine-apimust produce output in rootcriterion/onlycargo test -p hcie-engine-api --test visual_regressionmust pass (8/8)cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapturemust not regresscargo bench -p hcie-engine-api -- --baseline gold_standardmust 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 withcfg(target_feature)guards. Prefer portable approaches first.