a2264b130d
- 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.
297 lines
12 KiB
Rust
297 lines
12 KiB
Rust
//! CPU-side staging for persistent canvas texture updates.
|
|
//!
|
|
//! **Purpose:** Keeps the shader's recovery image in stable shared storage while carrying ordinary
|
|
//! frame updates as tightly packed dirty rectangles. **Logic & Workflow:** Engine pixels are copied
|
|
//! into the shared full image by row, then the final per-frame dirty union is packed once for wgpu.
|
|
//! **Side Effects / Dependencies:** Uses a standard-library read/write lock because iced may retain
|
|
//! shader primitives across application updates; no lock is held during normal partial GPU uploads.
|
|
|
|
use std::sync::{Arc, RwLock, RwLockReadGuard};
|
|
|
|
/// Stable full-canvas pixels used only for pipeline creation, resize, and renderer recovery.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SharedCompositePixels {
|
|
pixels: Arc<RwLock<Vec<u8>>>,
|
|
}
|
|
|
|
impl SharedCompositePixels {
|
|
/// Creates shared storage from one complete RGBA image.
|
|
///
|
|
/// **Arguments:** `pixels` is a row-major RGBA image. **Returns:** Shared stable storage.
|
|
/// **Side Effects / Dependencies:** Allocates once and initializes an `RwLock`.
|
|
pub fn new(pixels: Vec<u8>) -> Self {
|
|
Self {
|
|
pixels: Arc::new(RwLock::new(pixels)),
|
|
}
|
|
}
|
|
|
|
/// Replaces the complete recovery image after a document or canvas-size change.
|
|
///
|
|
/// **Arguments:** `pixels` is the new complete RGBA image. **Returns:** Nothing.
|
|
/// **Side Effects / Dependencies:** Takes the write lock and may resize the backing allocation.
|
|
pub fn replace(&self, pixels: &[u8]) {
|
|
let mut target = self
|
|
.pixels
|
|
.write()
|
|
.unwrap_or_else(|error| error.into_inner());
|
|
target.clear();
|
|
target.extend_from_slice(pixels);
|
|
}
|
|
|
|
/// Copies one RGBA rectangle from a complete source image into the recovery image.
|
|
///
|
|
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` is its pixel width, and
|
|
/// `region` is an exclusive `[x0, y0, x1, y1]` rectangle. **Returns:** Copied byte count, or an
|
|
/// error for malformed dimensions/bounds. **Side Effects / Dependencies:** Takes the write lock
|
|
/// only while copying the selected rows.
|
|
pub fn copy_region_from(
|
|
&self,
|
|
source: &[u8],
|
|
canvas_w: u32,
|
|
region: [u32; 4],
|
|
) -> Result<usize, &'static str> {
|
|
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
|
|
let mut target = self
|
|
.pixels
|
|
.write()
|
|
.unwrap_or_else(|error| error.into_inner());
|
|
if target.len() != source.len() {
|
|
return Err("shared composite length does not match source");
|
|
}
|
|
for row in 0..validated.height {
|
|
let offset = validated.source_offset(row);
|
|
let end = offset + validated.row_bytes;
|
|
target[offset..end].copy_from_slice(&source[offset..end]);
|
|
}
|
|
Ok(validated.row_bytes * validated.height)
|
|
}
|
|
|
|
/// Borrows the complete image for a rare full GPU upload.
|
|
///
|
|
/// **Arguments:** None. **Returns:** A read guard exposing the full RGBA bytes.
|
|
/// **Side Effects / Dependencies:** Takes a read lock; callers must keep its scope short.
|
|
pub fn read(&self) -> RwLockReadGuard<'_, Vec<u8>> {
|
|
self.pixels
|
|
.read()
|
|
.unwrap_or_else(|error| error.into_inner())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn allocation_identity(&self) -> *const RwLock<Vec<u8>> {
|
|
Arc::as_ptr(&self.pixels)
|
|
}
|
|
}
|
|
|
|
/// One tightly packed RGBA rectangle ready for `wgpu::Queue::write_texture`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct TextureUpdate {
|
|
/// Exclusive canvas-space bounds `[x0, y0, x1, y1]`.
|
|
pub region: [u32; 4],
|
|
/// Number of bytes in one packed row.
|
|
pub bytes_per_row: u32,
|
|
/// Immutable packed RGBA rows.
|
|
pub pixels: Arc<[u8]>,
|
|
/// Latest pixel-changing input represented by this upload, for diagnostics.
|
|
pub input_at: Option<std::time::Instant>,
|
|
}
|
|
|
|
impl TextureUpdate {
|
|
/// Packs one dirty RGBA rectangle from a complete image.
|
|
///
|
|
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` its width, and `region`
|
|
/// exclusive canvas bounds. **Returns:** A tightly packed upload payload or a validation error.
|
|
/// **Side Effects / Dependencies:** Allocates exactly `dirty_width * dirty_height * 4` bytes.
|
|
pub fn pack(source: &[u8], canvas_w: u32, region: [u32; 4]) -> Result<Self, &'static str> {
|
|
let started = std::time::Instant::now();
|
|
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
|
|
let mut pixels = vec![0; validated.row_bytes * validated.height];
|
|
for row in 0..validated.height {
|
|
let source_offset = validated.source_offset(row);
|
|
let target_offset = row * validated.row_bytes;
|
|
pixels[target_offset..target_offset + validated.row_bytes]
|
|
.copy_from_slice(&source[source_offset..source_offset + validated.row_bytes]);
|
|
}
|
|
let update = Self {
|
|
region,
|
|
bytes_per_row: validated.row_bytes as u32,
|
|
pixels: pixels.into(),
|
|
input_at: super::perf::latest_render_input(),
|
|
};
|
|
super::perf::record_duration("dirty_pack", started.elapsed());
|
|
Ok(update)
|
|
}
|
|
|
|
/// Returns the packed rectangle width.
|
|
///
|
|
/// **Arguments:** None. **Returns:** Width in pixels. **Side Effects / Dependencies:** None.
|
|
pub fn width(&self) -> u32 {
|
|
self.region[2] - self.region[0]
|
|
}
|
|
|
|
/// Returns the packed rectangle height.
|
|
///
|
|
/// **Arguments:** None. **Returns:** Height in pixels. **Side Effects / Dependencies:** None.
|
|
pub fn height(&self) -> u32 {
|
|
self.region[3] - self.region[1]
|
|
}
|
|
}
|
|
|
|
/// Validated row-copy geometry shared by recovery and upload staging.
|
|
struct ValidatedRegion {
|
|
canvas_w: usize,
|
|
x0: usize,
|
|
y0: usize,
|
|
height: usize,
|
|
row_bytes: usize,
|
|
bytes_per_pixel: usize,
|
|
}
|
|
|
|
impl ValidatedRegion {
|
|
/// Validates a rectangle against a complete tightly packed image.
|
|
///
|
|
/// **Arguments:** Image length, width, exclusive region, and channel byte count. **Returns:**
|
|
/// Safe row-copy geometry or an error. **Side Effects / Dependencies:** None.
|
|
fn new(
|
|
source_len: usize,
|
|
canvas_w: u32,
|
|
region: [u32; 4],
|
|
bytes_per_pixel: usize,
|
|
) -> Result<Self, &'static str> {
|
|
let [x0, y0, x1, y1] = region;
|
|
if canvas_w == 0 || x0 >= x1 || y0 >= y1 || x1 > canvas_w {
|
|
return Err("invalid dirty rectangle");
|
|
}
|
|
let row_stride = canvas_w as usize * bytes_per_pixel;
|
|
if row_stride == 0 || !source_len.is_multiple_of(row_stride) {
|
|
return Err("source length does not match canvas width");
|
|
}
|
|
let canvas_h = source_len / row_stride;
|
|
if y1 as usize > canvas_h {
|
|
return Err("dirty rectangle exceeds canvas height");
|
|
}
|
|
Ok(Self {
|
|
canvas_w: canvas_w as usize,
|
|
x0: x0 as usize,
|
|
y0: y0 as usize,
|
|
height: (y1 - y0) as usize,
|
|
row_bytes: (x1 - x0) as usize * bytes_per_pixel,
|
|
bytes_per_pixel,
|
|
})
|
|
}
|
|
|
|
/// Computes one source row's byte offset.
|
|
///
|
|
/// **Arguments:** `row` is relative to the validated rectangle. **Returns:** Byte offset into
|
|
/// the complete image. **Side Effects / Dependencies:** None.
|
|
fn source_offset(&self, row: usize) -> usize {
|
|
((self.y0 + row) * self.canvas_w + self.x0) * self.bytes_per_pixel
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{SharedCompositePixels, TextureUpdate};
|
|
use std::sync::Arc;
|
|
|
|
const WIDTH: u32 = 3840;
|
|
const HEIGHT: u32 = 2160;
|
|
const FULL_BYTES: usize = WIDTH as usize * HEIGHT as usize * 4;
|
|
|
|
/// Verifies retained shader references cannot trigger a full-canvas clone in the new path.
|
|
#[test]
|
|
fn retained_shader_reference_keeps_stable_allocation_and_dirty_budget() {
|
|
let mut source = vec![0; FULL_BYTES];
|
|
let shared = SharedCompositePixels::new(source.clone());
|
|
let shader_reference = shared.clone();
|
|
let identity = shared.allocation_identity();
|
|
let mut copied = 0usize;
|
|
let mut uploaded = 0usize;
|
|
|
|
for sample in 0..120u32 {
|
|
let x0 = 100 + sample;
|
|
let region = [x0, 200, x0 + 48, 248];
|
|
let marker = sample as u8;
|
|
for y in region[1]..region[3] {
|
|
let start = ((y * WIDTH + region[0]) * 4) as usize;
|
|
source[start..start + 48 * 4].fill(marker);
|
|
}
|
|
copied += shared.copy_region_from(&source, WIDTH, region).unwrap();
|
|
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
|
|
copied += update.pixels.len();
|
|
uploaded += update.pixels.len();
|
|
assert_eq!(update.pixels.len(), 48 * 48 * 4);
|
|
}
|
|
|
|
assert_eq!(identity, shader_reference.allocation_identity());
|
|
assert_eq!(copied, 120 * 48 * 48 * 4 * 2);
|
|
assert_eq!(uploaded, 120 * 48 * 48 * 4);
|
|
assert_eq!(&*shared.read(), &source);
|
|
}
|
|
|
|
/// Reproduces why the removed copy-on-write fallback cloned 33 MB per ordinary 4K update.
|
|
#[test]
|
|
fn legacy_arc_copy_on_write_reproduction_is_full_canvas() {
|
|
let mut pixels = Arc::new(vec![0u8; FULL_BYTES]);
|
|
let _shader_reference = pixels.clone();
|
|
assert!(Arc::get_mut(&mut pixels).is_none());
|
|
let fallback = Arc::new(pixels.as_ref().clone());
|
|
assert_eq!(fallback.len(), 33_177_600);
|
|
}
|
|
|
|
/// Confirms packed uploads preserve exact rows and reject malformed rectangles.
|
|
#[test]
|
|
fn packed_update_is_exact_and_bounds_checked() {
|
|
let source: Vec<u8> = (0..6 * 4 * 4).map(|value| value as u8).collect();
|
|
let update = TextureUpdate::pack(&source, 6, [2, 1, 5, 3]).unwrap();
|
|
assert_eq!(update.width(), 3);
|
|
assert_eq!(update.height(), 2);
|
|
assert_eq!(update.bytes_per_row, 12);
|
|
assert_eq!(&update.pixels[..12], &source[32..44]);
|
|
assert_eq!(&update.pixels[12..], &source[56..68]);
|
|
assert!(TextureUpdate::pack(&source, 6, [5, 1, 2, 3]).is_err());
|
|
assert!(TextureUpdate::pack(&source, 6, [0, 0, 7, 1]).is_err());
|
|
assert!(TextureUpdate::pack(&source, 6, [0, 0, 1, 5]).is_err());
|
|
}
|
|
|
|
/// Reports repeatable GUI staging latency and byte volume for a 4K stroke workload.
|
|
#[test]
|
|
#[ignore = "diagnostic benchmark; run with --ignored --nocapture"]
|
|
fn diagnostic_4k_dirty_staging_latency() {
|
|
let mut source = vec![0u8; FULL_BYTES];
|
|
let shared = SharedCompositePixels::new(source.clone());
|
|
let mut samples = Vec::with_capacity(120);
|
|
let mut dirty_bytes = 0usize;
|
|
for sample in 0..130u32 {
|
|
let x0 = 200 + sample * 8;
|
|
let region = [x0, 700, x0 + 48, 748];
|
|
let start = std::time::Instant::now();
|
|
for y in region[1]..region[3] {
|
|
let offset = ((y * WIDTH + region[0]) * 4) as usize;
|
|
source[offset..offset + 48 * 4].fill(sample as u8);
|
|
}
|
|
shared.copy_region_from(&source, WIDTH, region).unwrap();
|
|
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
|
|
if sample >= 10 {
|
|
samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
|
dirty_bytes += update.pixels.len();
|
|
}
|
|
}
|
|
samples.sort_by(f64::total_cmp);
|
|
let percentile = |fraction: f64| {
|
|
let index = ((samples.len() - 1) as f64 * fraction).round() as usize;
|
|
samples[index]
|
|
};
|
|
let legacy_bytes = samples.len() * FULL_BYTES;
|
|
println!(
|
|
"4K iced staging: p50={:.3}ms p95={:.3}ms max={:.3}ms dirty={}B legacy_cow={}B reduction={:.0}x",
|
|
percentile(0.5),
|
|
percentile(0.95),
|
|
samples[samples.len() - 1],
|
|
dirty_bytes,
|
|
legacy_bytes,
|
|
legacy_bytes as f64 / dirty_bytes as f64,
|
|
);
|
|
assert_eq!(dirty_bytes, 120 * 48 * 48 * 4);
|
|
}
|
|
}
|