4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements

- Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs.
- Implemented a performance probe in the egui reference renderer for comparative diagnostics.
- Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis.
- Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources.
- Updated the main application to conditionally log performance diagnostics based on an environment variable.
- Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application.
This commit is contained in:
Your Name
2026-07-23 23:57:03 +03:00
parent e9dad7ef0c
commit a2264b130d
10 changed files with 781 additions and 46 deletions
@@ -6,6 +6,7 @@ use eframe::egui;
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
use std::hash::{Hash, Hasher};
pub mod perf;
pub mod render;
pub mod text_overlay;
@@ -299,6 +300,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
));
return response;
}
let input_at = std::time::Instant::now();
perf::begin_stroke(input_at);
perf::record_input(input_at);
// Send current brush state to engine before stroke
self.doc.engine.set_color(self.state.primary_color);
let mut tip = hcie_engine_api::BrushTip::default();
@@ -487,6 +491,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
self.state.brush_accumulated_dist = 0.0;
let layer_id = self.doc.engine.active_layer_id();
if self.state.active_tool != Tool::Pen {
let engine_start = std::time::Instant::now();
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
// Draw single dab on click (even without movement).
// The spacing check in the drag handler prevents
@@ -497,6 +502,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
uy as f32,
self.state.current_pressure,
);
perf::record_duration("engine_draw", engine_start.elapsed());
}
}
@@ -915,6 +921,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
match self.state.active_tool {
Tool::Pen => {
if let Some((lx, ly)) = self.state.last_draw_pos {
let input_at = std::time::Instant::now();
perf::record_input(input_at);
let engine_start = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
if !self.doc.engine.is_stroke_active() {
self.doc
@@ -929,6 +938,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
uy as f32,
self.state.current_pressure,
);
perf::record_duration("engine_draw", engine_start.elapsed());
self.state.last_draw_pos = Some((ux, uy));
}
}
@@ -947,6 +957,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
}
.max(1.0);
if dx >= spacing {
let input_at = std::time::Instant::now();
perf::record_input(input_at);
let engine_start = std::time::Instant::now();
let layer_id = self.doc.engine.active_layer_id();
self.doc.engine.stroke_to(
layer_id,
@@ -954,6 +967,10 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
uy as f32,
self.state.current_pressure,
);
perf::record_duration(
"engine_draw",
engine_start.elapsed(),
);
self.state.last_draw_pos = Some((ux, uy));
}
}
@@ -1223,6 +1240,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
self.event_bus.push(AppEvent::RenderRequested);
self.event_bus.push(AppEvent::DrawingFinished);
perf::request_finish();
}
Tool::Select => {
if let Some(rect) = self.state.selection_rect {
@@ -0,0 +1,181 @@
//! Opt-in canvas performance probe for the egui reference renderer.
//!
//! **Purpose:** Produces the same stage names as the iced canvas diagnostics so
//! immediate-mode and reactive pipelines can be compared on one machine.
//! **Logic & Workflow:** `HCIE_CANVAS_PERF=1` enables per-stroke duration and byte
//! collection; the first texture update after release emits p50/p95/max values.
//! **Side Effects / Dependencies:** Uses a process-wide mutex and the `log` crate.
use std::collections::BTreeMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Mutable measurements for one egui stroke.
#[derive(Default)]
struct PerfState {
stroke: u64,
active: bool,
finish_pending: bool,
latest_input: Option<Instant>,
samples: BTreeMap<&'static str, Vec<f64>>,
copied_bytes: u64,
uploaded_bytes: u64,
visible_updates: u64,
}
/// Returns whether `HCIE_CANVAS_PERF` enables diagnostics.
pub fn enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("HCIE_CANVAS_PERF")
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false)
})
}
/// Starts a new per-stroke sample window.
pub fn begin_stroke(input_at: Instant) {
if !enabled() {
return;
}
with_state(|state| {
state.stroke = state.stroke.wrapping_add(1);
state.active = true;
state.finish_pending = false;
state.latest_input = Some(input_at);
state.samples.clear();
state.copied_bytes = 0;
state.uploaded_bytes = 0;
state.visible_updates = 0;
});
}
/// Records the latest pixel-changing input and its immediate dispatch delay.
pub fn record_input(input_at: Instant) {
if !enabled() {
return;
}
with_state(|state| {
if state.active {
state.latest_input = Some(input_at);
state
.samples
.entry("input_to_update")
.or_default()
.push(input_at.elapsed().as_secs_f64() * 1000.0);
}
});
}
/// Records one named stage duration.
pub fn record_duration(stage: &'static str, duration: Duration) {
if !enabled() {
return;
}
with_state(|state| {
if state.active {
state
.samples
.entry(stage)
.or_default()
.push(duration.as_secs_f64() * 1000.0);
}
});
}
/// Records CPU copy and texture payload bytes for one visible update.
pub fn record_transfer(copied_bytes: usize, uploaded_bytes: usize) {
if !enabled() {
return;
}
with_state(|state| {
if state.active {
state.copied_bytes = state.copied_bytes.saturating_add(copied_bytes as u64);
state.uploaded_bytes = state.uploaded_bytes.saturating_add(uploaded_bytes as u64);
state.visible_updates += 1;
if let Some(input) = state.latest_input {
state
.samples
.entry("input_to_texture_update_proxy")
.or_default()
.push(input.elapsed().as_secs_f64() * 1000.0);
}
}
});
}
/// Requests summary emission after the final texture update.
pub fn request_finish() {
if enabled() {
with_state(|state| state.finish_pending = state.active);
}
}
/// Emits the pending summary after a render pass has had an opportunity to upload.
pub fn finish_after_render() {
if !enabled() {
return;
}
with_state(|state| {
if !state.active || !state.finish_pending {
return;
}
state.active = false;
state.finish_pending = false;
let mut stages = Vec::new();
for (name, samples) in &mut state.samples {
samples.sort_by(f64::total_cmp);
if let Some(max) = samples.last().copied() {
stages.push(format!(
"{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]",
name,
samples.len(),
percentile(samples, 0.50),
percentile(samples, 0.95),
max
));
}
}
log::info!(
target: "hcie_canvas_perf",
"egui stroke={} {} counters[copied={}B,uploaded={}B,visible_updates={}]",
state.stroke,
stages.join(" "),
state.copied_bytes,
state.uploaded_bytes,
state.visible_updates
);
});
}
/// Selects the nearest sample for a percentile from an already sorted slice.
fn percentile(samples: &[f64], fraction: f64) -> f64 {
if samples.is_empty() {
return 0.0;
}
let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize;
samples[index]
}
/// Runs an operation while holding the diagnostic accumulator lock.
fn with_state<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
static STATE: OnceLock<Mutex<PerfState>> = OnceLock::new();
let mut state = STATE
.get_or_init(|| Mutex::new(PerfState::default()))
.lock()
.unwrap_or_else(|error| error.into_inner());
operation(&mut state)
}
#[cfg(test)]
mod tests {
use super::percentile;
/// Confirms percentile output matches the iced probe for equivalent samples.
#[test]
fn percentile_uses_nearest_ranked_sample() {
let samples = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(percentile(&samples, 0.50), 3.0);
assert_eq!(percentile(&samples, 0.95), 5.0);
}
}
@@ -169,13 +169,17 @@ pub fn render_composition(
if needs_render {
let t_composite_start = std::time::Instant::now();
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
let composite_ms = t_composite_start.elapsed().as_millis();
let composite_elapsed = t_composite_start.elapsed();
let composite_ms = composite_elapsed.as_millis();
super::perf::record_duration("render_composite_region", composite_elapsed);
let copy_start = std::time::Instant::now();
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
unsafe {
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
}
}
super::perf::record_duration("cpu_staging", copy_start.elapsed());
let region = match region_result {
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
@@ -188,6 +192,8 @@ pub fn render_composition(
..Default::default()
};
let texture_start = std::time::Instant::now();
let mut uploaded_bytes = 0usize;
// Partial texture upload for the dirty region only
if let Some(tex) = &mut doc.composite_texture {
let size = tex.size();
@@ -212,6 +218,7 @@ pub fn render_composition(
&region_pixels,
);
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
uploaded_bytes = region_pixels.len();
}
} else {
let color_image = egui::ColorImage::from_rgba_unmultiplied(
@@ -219,6 +226,7 @@ pub fn render_composition(
&doc.composite_buffer,
);
tex.set(color_image, options);
uploaded_bytes = doc.composite_buffer.len();
}
} else {
let color_image = egui::ColorImage::from_rgba_unmultiplied(
@@ -226,7 +234,10 @@ pub fn render_composition(
&doc.composite_buffer,
);
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
uploaded_bytes = doc.composite_buffer.len();
}
super::perf::record_duration("texture_update", texture_start.elapsed());
super::perf::record_transfer(buf_size, uploaded_bytes);
doc.thumbnails_dirty = true;
doc.engine.clear_dirty_flags();
@@ -265,6 +276,7 @@ pub fn render_composition(
}
}
super::perf::finish_after_render();
false
}