forked from 1b-agent/opencadstudio-upstream
perf(bench): introduce standardized 25-metric CAD and UI benchmark suite
- Add comprehensive performance benchmark harness in benches/performance_benchmarks.rs configured with harness = false in Cargo.toml (runs via cargo bench in --release, isolated from cargo test). - Benchmark coverage across 25 distinct CAD & UI operations: * Entity Ingestion, GPU Wire Partitioning, Analytical & Wide/Tapered Arc Tessellation * 100x Zoom Navigation, Spatial InteractionIndex, Multi-mode Hit-Testing, OSnap Tracking * 2D Parametric Dogleg Constraint Solving, DXF Import/Export Throughput * UI Ribbon View Construction, Viewport Grid Projection, Grid Cache Evaluation * Themed SVG Icon Lookup Caching, Cold Extents AABB Calculation * Batch Entity Transformation, Draworder Depth Map Full/Patch, Delta-Undo Recording - Expose internal calculation hooks with #[doc(hidden)] pub to prevent API leakage. - Remove 644 lines of legacy, scattered ad-hoc #[test] #[ignore] benchmarks across src/. - Add developer and regression documentation in benches/README.md.
This commit is contained in:
1 parent
754cc89fab
commit
58c9fcb748
9 files changed
+1786
-651
No files matched your search
@@ -136,3 +136,7 @@ iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
[[bench]]
|
||||
name = "performance_benchmarks"
|
||||
harness = false
|
||||
@@ -0,0 +1,91 @@
|
||||
# OpenCADStudio Performance Benchmarks
|
||||
|
||||
A standardized, statistical performance benchmarking suite measuring throughput, latency, and memory metrics across core CAD subsystems and the user interface.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the full benchmark suite with native `--release` optimizations:
|
||||
```powershell
|
||||
cargo bench
|
||||
```
|
||||
|
||||
Run a fast validation smoke test (~2 seconds):
|
||||
```powershell
|
||||
cargo bench --bench performance_benchmarks -- --quick
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Benchmarks are registered under `[[bench]]` with `harness = false`. They are **never** executed by regular `cargo test` runs, preventing CI slowdowns.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Suite Catalog (25 Metrics)
|
||||
|
||||
### 1. Scene & Entity Management
|
||||
- `scene_entity_ingestion_10k`: Ingestion throughput for 10,000 mixed 2D entities (Lines, Circles, Arcs, Polylines).
|
||||
- `batch_entity_transform_mutation`: Translating 1,000 entities, invoking `bump_entities`, and updating the spatial acceleration structure.
|
||||
- `undo_delta_recording_cycle`: Capturing before-images and transactional delta recordings for 500 entity mutations.
|
||||
|
||||
### 2. Geometry Tessellation & Viewport Navigation
|
||||
- `analytical_curved_tessellation`: Initial wire generation for 14,000 curved entities (circles, arcs, ellipses, polylines).
|
||||
- `wire_partitioning_per_frame`: Partitioning 14,000 wires into GPU analytical instances vs regular lines.
|
||||
- `analytical_curved_zoom_100x`: 100 camera zoom navigations measuring frame overhead and FPS.
|
||||
- `wide_and_tapered_arc_tessellation`: Tessellating 7,500 thick polyline arcs, tapered arcs, and donuts.
|
||||
- `zoom_extents_bounding_box_calc`: Cold axis-aligned bounding box (AABB) calculation across 20,000 mixed entities (`ZOOM EXTENTS`).
|
||||
|
||||
### 3. Spatial Indexing, Snapping & Picking
|
||||
- `spatial_interaction_index_build`: Building the spatial `InteractionIndex` and `prepare_screen` for 10,000 wires.
|
||||
- `hit_test_click_pick_latency`: Single-point cursor click selection across 5,000 entities in screen space.
|
||||
- `hit_test_box_window_enclosed`: Window selection (`box_hit` enclosed) across 5,000 entities.
|
||||
- `hit_test_box_crossing_boundary`: Crossing selection (`box_hit` crossing boundary) across 5,000 entities.
|
||||
- `osnap_cursor_tracking_latency`: Real-time cursor snapping across Endpoint, Midpoint, Center, Intersection, and Nearest modes.
|
||||
|
||||
### 4. Parametrics & DXF I/O
|
||||
- `constraint_solver_dogleg_system`: Solving coupled 2D geometric constraints (Perpendicular, Parallel, Length) via the Dogleg solver.
|
||||
- `dxf_export_write_throughput`: Serializing 5,000 entities to DXF binary stream.
|
||||
- `dxf_import_parse_throughput`: Parsing DXF stream of 5,000 entities into `CadDocument`.
|
||||
|
||||
### 5. UI & Viewport Overlay Performance
|
||||
- `ui_ribbon_view_construction`: Per-frame ribbon widget tree construction.
|
||||
- `ui_grid_geometry_uncached`: Multi-pane tiled viewport grid projection and segment generation.
|
||||
- `ui_grid_cache_hit_evaluation`: Grid overlay cache key construction and reuse decision.
|
||||
- `ui_icon_handle_cached`: Themed SVG icon cache hit (FxHashMap lookup + Handle clone).
|
||||
- `ui_icon_handle_uncached`: Uncached SVG icon memory parse and Handle allocation.
|
||||
|
||||
### 6. State Management & Draworder
|
||||
- `selection_state_deep_clone`: Deep cloning of `SelectionState` (with 64 polygon points).
|
||||
- `selection_state_arc_clone`: `Arc<SelectionState>::clone` (atomic reference bump).
|
||||
- `draworder_depth_map_full_build`: Full draworder depth map generation across 10,000 ordered entities.
|
||||
- `draworder_depth_map_incremental_patch`: Fast-path incremental depth map patch on entity modification.
|
||||
|
||||
---
|
||||
|
||||
## Filtering Benchmarks
|
||||
|
||||
Filter execution by subsystem or name pattern:
|
||||
```powershell
|
||||
# Filter by UI
|
||||
cargo bench --bench performance_benchmarks -- ui
|
||||
|
||||
# Filter by tessellation
|
||||
cargo bench --bench performance_benchmarks -- tessellation
|
||||
|
||||
# Filter by hit testing
|
||||
cargo bench --bench performance_benchmarks -- hit_test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Baseline Tracking & Regression Detection
|
||||
|
||||
### 1. Save Current Metrics as Baseline
|
||||
```powershell
|
||||
cargo bench --bench performance_benchmarks -- --output target/baseline_metrics.json
|
||||
```
|
||||
|
||||
### 2. Compare PR / Branch Against Baseline
|
||||
```powershell
|
||||
cargo bench --bench performance_benchmarks -- --baseline target/baseline_metrics.json
|
||||
```
|
||||
|
||||
The benchmark runner will output statistical deltas (`% faster` / `% slower`) for each metric and flag regressions exceeding the defined thresholds.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1538,73 +1538,7 @@ mod tests {
|
||||
use acadrust::objects::ObjectType;
|
||||
use acadrust::EntityType;
|
||||
|
||||
// Benchmark, not a correctness test: measures end-to-end latency of
|
||||
// HATCHTOBACK and interactive DRAWORDER BACK on a large synthetic drawing.
|
||||
// Ignored by default; A/B across commits with:
|
||||
// BENCH_LINES=100000 BENCH_HATCHES=350 BENCH_RUNS=5 \
|
||||
// cargo test --release --lib bench_draworder_large_drawing -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_draworder_large_drawing() {
|
||||
let lines_n: usize = std::env::var("BENCH_LINES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000);
|
||||
let hatches_n: usize = std::env::var("BENCH_HATCHES").ok().and_then(|v| v.parse().ok()).unwrap_or(350);
|
||||
let runs: usize = std::env::var("BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(5);
|
||||
let sel_n: usize = std::env::var("BENCH_SEL").ok().and_then(|v| v.parse().ok()).unwrap_or(100);
|
||||
|
||||
fn median(v: &mut [f64]) -> f64 {
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
// --- HATCHTOBACK ---
|
||||
let mut hb_ms: Vec<f64> = Vec::new();
|
||||
for _ in 0..runs {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let t0 = std::time::Instant::now();
|
||||
let lines: Vec<EntityType> =
|
||||
(0..lines_n).map(|_| EntityType::Line(Default::default())).collect();
|
||||
let _ = app.tabs[i].scene.add_entities(lines);
|
||||
let hatches: Vec<EntityType> =
|
||||
(0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect();
|
||||
app.tabs[i].scene.add_entities(hatches);
|
||||
eprintln!(
|
||||
"[bench] setup ({} lines + {} hatches): {:.1} ms",
|
||||
lines_n,
|
||||
hatches_n,
|
||||
t0.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
let t1 = std::time::Instant::now();
|
||||
let _ = app.run_command_line("HATCHTOBACK");
|
||||
hb_ms.push(t1.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
eprintln!("[bench] HATCHTOBACK median: {:.3} ms over {} runs", median(&mut hb_ms), runs);
|
||||
|
||||
// --- DRAWORDER BACK (interactive path) ---
|
||||
let mut db_ms: Vec<f64> = Vec::new();
|
||||
for _ in 0..runs {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let lines: Vec<EntityType> =
|
||||
(0..lines_n).map(|_| EntityType::Line(Default::default())).collect();
|
||||
let _ = app.tabs[i].scene.add_entities(lines);
|
||||
let hatches: Vec<EntityType> =
|
||||
(0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect();
|
||||
let handles = app.tabs[i].scene.add_entities(hatches);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.replace_selection(handles.into_iter().take(sel_n).collect());
|
||||
let t1 = std::time::Instant::now();
|
||||
let _ = app.run_command_line("DRAWORDER BACK");
|
||||
db_ms.push(t1.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
eprintln!(
|
||||
"[bench] DRAWORDER BACK ({} selected) median: {:.3} ms over {} runs",
|
||||
sel_n, median(&mut db_ms), runs
|
||||
);
|
||||
}
|
||||
|
||||
fn fresh_app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
+7
-258
@@ -2661,7 +2661,8 @@ impl Scene {
|
||||
/// is `None` for a freshly added entity. No-op when not recording — the
|
||||
/// callers guard with [`Scene::is_recording_undo`] so the clone is skipped
|
||||
/// entirely on the common (no-recording) path.
|
||||
pub(crate) fn record_undo_before(&mut self, handle: Handle, before: Option<Arc<EntityType>>) {
|
||||
#[doc(hidden)]
|
||||
pub fn record_undo_before(&mut self, handle: Handle, before: Option<Arc<EntityType>>) {
|
||||
if let Some(rec) = self.undo_recording.as_mut() {
|
||||
if !rec.before.contains_key(&handle) {
|
||||
rec.order.push(handle);
|
||||
@@ -5377,7 +5378,8 @@ impl Scene {
|
||||
/// frustum cull, no zoom LOD, and no re-tessellation/re-upload on camera
|
||||
/// moves. The per-tile camera args are unused (kept for call-site symmetry
|
||||
/// with the paper-space wire sources).
|
||||
pub(super) fn model_tile_wires_arc(
|
||||
#[doc(hidden)]
|
||||
pub fn model_tile_wires_arc(
|
||||
&self,
|
||||
_tile_idx: usize,
|
||||
_cam: &Camera,
|
||||
@@ -6226,7 +6228,8 @@ impl Scene {
|
||||
self.draw_depth_generation.get()
|
||||
}
|
||||
|
||||
pub(super) fn draw_depth_map(&self) -> Arc<HashMap<u64, [f32; 2]>> {
|
||||
#[doc(hidden)]
|
||||
pub fn draw_depth_map(&self) -> Arc<HashMap<u64, [f32; 2]>> {
|
||||
{
|
||||
let cache = self.draw_depth_cache.borrow();
|
||||
if let Some(cache) = cache.as_ref() {
|
||||
@@ -12535,260 +12538,6 @@ mod layout_cache_tests {
|
||||
2,
|
||||
"Mixed polyline should have its 2 bulge arcs routed to analytical CircleGpu"
|
||||
);
|
||||
assert_eq!(
|
||||
partitioned.regular.len(),
|
||||
1,
|
||||
"Mixed polyline straight lines should form 1 regular wire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn bench_analytical_rendering() {
|
||||
use std::time::Instant;
|
||||
use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, LwPolyline, LwVertex};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
|
||||
let n_circles = 5000;
|
||||
let n_arcs = 5000;
|
||||
let n_ellipses = 2000;
|
||||
let n_polylines = 2000;
|
||||
let total_entities = n_circles + n_arcs + n_ellipses + n_polylines;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
for i in 0..n_circles {
|
||||
let x = (i % 100) as f64 * 50.0;
|
||||
let y = (i / 100) as f64 * 50.0;
|
||||
let mut circle = Circle::default();
|
||||
circle.center = Vector3::new(x, y, 0.0);
|
||||
circle.radius = 10.0 + (i % 20) as f64;
|
||||
scene.add_entity(EntityType::Circle(circle));
|
||||
}
|
||||
|
||||
for i in 0..n_arcs {
|
||||
let x = (i % 100) as f64 * 50.0 + 25.0;
|
||||
let y = (i / 100) as f64 * 50.0;
|
||||
let mut arc = AcadArc::default();
|
||||
arc.center = Vector3::new(x, y, 0.0);
|
||||
arc.radius = 15.0;
|
||||
arc.start_angle = ((i % 8) as f64) * 0.25 * std::f64::consts::PI;
|
||||
arc.end_angle = arc.start_angle + 0.75 * std::f64::consts::PI;
|
||||
scene.add_entity(EntityType::Arc(arc));
|
||||
}
|
||||
|
||||
for i in 0..n_ellipses {
|
||||
let x = (i % 50) as f64 * 100.0;
|
||||
let y = (i / 50) as f64 * 100.0 + 5000.0;
|
||||
let mut el = Ellipse::default();
|
||||
el.center = Vector3::new(x, y, 0.0);
|
||||
el.major_axis = Vector3::new(20.0, 0.0, 0.0);
|
||||
el.minor_axis_ratio = 0.5;
|
||||
el.start_parameter = 0.0;
|
||||
el.end_parameter = std::f64::consts::TAU;
|
||||
scene.add_entity(EntityType::Ellipse(el));
|
||||
}
|
||||
|
||||
for i in 0..n_polylines {
|
||||
let x = (i % 50) as f64 * 100.0 + 50.0;
|
||||
let y = (i / 50) as f64 * 100.0 + 5000.0;
|
||||
let mut pline = LwPolyline::new();
|
||||
pline.is_closed = true;
|
||||
pline.vertices = vec![
|
||||
LwVertex {
|
||||
location: Vector2::new(x, y),
|
||||
bulge: 1.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 0,
|
||||
},
|
||||
LwVertex {
|
||||
location: Vector2::new(x + 30.0, y),
|
||||
bulge: 1.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 1,
|
||||
},
|
||||
];
|
||||
scene.add_entity(EntityType::LwPolyline(pline));
|
||||
}
|
||||
|
||||
let cam = Camera::default();
|
||||
let t_tess_start = Instant::now();
|
||||
let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0);
|
||||
let tess_duration = t_tess_start.elapsed();
|
||||
|
||||
let total_wires = wires.len();
|
||||
let mut chord_points = 0usize;
|
||||
let mut analytical_tangents = 0usize;
|
||||
for w in wires.iter() {
|
||||
chord_points += w.points.len();
|
||||
analytical_tangents += w.tangent_geoms.len();
|
||||
}
|
||||
|
||||
let depths = rustc_hash::FxHashMap::default();
|
||||
let t_part_start = Instant::now();
|
||||
let iters = 100;
|
||||
let mut part = None;
|
||||
for _ in 0..iters {
|
||||
part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths));
|
||||
}
|
||||
let part_duration = t_part_start.elapsed() / (iters as u32);
|
||||
let p = part.unwrap();
|
||||
|
||||
// Zoom simulation
|
||||
let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0];
|
||||
let t_zoom_start = Instant::now();
|
||||
let mut dynamic_cam = Camera::default();
|
||||
for &zoom in zoom_levels.iter().cycle().take(100) {
|
||||
dynamic_cam.distance = 1000.0 / zoom;
|
||||
let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0);
|
||||
}
|
||||
let zoom_100_duration = t_zoom_start.elapsed();
|
||||
let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0;
|
||||
|
||||
let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0);
|
||||
let inst_vram_mb = (p.circle_instances.len() * 128 + p.ellipse_instances.len() * 144) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
println!("\n=== BENCHMARK REPORT: CURRENT HEAD (WITH GPU ANALYTICAL & OPTIMIZATIONS) ===");
|
||||
println!("Curved Entities Tested: {total_entities}");
|
||||
println!("Initial Wire Build Time: {:.2?}", tess_duration);
|
||||
println!("Total Wires: {total_wires}");
|
||||
println!("Tessellated Chord Vertices: {chord_points}");
|
||||
println!("Analytical Tangent Geometries: {analytical_tangents}");
|
||||
println!("Partitioning Time (per frame): {:.3?}", part_duration);
|
||||
println!("Regular Line Wires: {}", p.regular.len());
|
||||
println!("GPU Circle/Arc Instances: {}", p.circle_instances.len());
|
||||
println!("GPU Ellipse Instances: {}", p.ellipse_instances.len());
|
||||
println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb);
|
||||
println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb);
|
||||
println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 });
|
||||
println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration);
|
||||
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
|
||||
println!("============================================================================\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn bench_wide_and_tapered_arc_rendering() {
|
||||
use std::time::Instant;
|
||||
use acadrust::entities::LwPolyline;
|
||||
use acadrust::types::Vector2;
|
||||
|
||||
let n_wide_arcs = 2500;
|
||||
let n_tapered_arcs = 2500;
|
||||
let n_donuts = 2500;
|
||||
let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
|
||||
// 1. Wide polyline arcs (constant width 6.0)
|
||||
for i in 0..n_wide_arcs {
|
||||
let x = (i % 50) as f64 * 80.0;
|
||||
let y = (i / 50) as f64 * 80.0;
|
||||
let mut pline = LwPolyline::new();
|
||||
pline.constant_width = 6.0;
|
||||
pline.vertices = vec![
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x, y),
|
||||
bulge: 0.5,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 0,
|
||||
},
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x + 40.0, y),
|
||||
bulge: 0.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 1,
|
||||
},
|
||||
];
|
||||
scene.add_entity(EntityType::LwPolyline(pline));
|
||||
}
|
||||
|
||||
// 2. Tapered arcs (width: 2.0 -> 12.0)
|
||||
for i in 0..n_tapered_arcs {
|
||||
let x = (i % 50) as f64 * 80.0;
|
||||
let y = (i / 50) as f64 * 80.0 + 4500.0;
|
||||
let mut pline = LwPolyline::new();
|
||||
pline.vertices = vec![
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x, y),
|
||||
bulge: 0.7,
|
||||
start_width: 2.0,
|
||||
end_width: 12.0,
|
||||
vertex_id: 0,
|
||||
},
|
||||
acadrust::entities::LwVertex {
|
||||
location: Vector2::new(x + 35.0, y + 10.0),
|
||||
bulge: 0.0,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
vertex_id: 1,
|
||||
},
|
||||
];
|
||||
scene.add_entity(EntityType::LwPolyline(pline));
|
||||
}
|
||||
|
||||
// 3. Donuts (2 semicircular arc segments, constant width 16.0)
|
||||
for i in 0..n_donuts {
|
||||
let cx = (i % 50) as f64 * 80.0;
|
||||
let cy = (i / 50) as f64 * 80.0 + 9000.0;
|
||||
let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0);
|
||||
scene.add_entity(donut);
|
||||
}
|
||||
|
||||
let cam = Camera::default();
|
||||
let t_tess_start = Instant::now();
|
||||
let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0);
|
||||
let tess_duration = t_tess_start.elapsed();
|
||||
|
||||
let total_wires = wires.len();
|
||||
let mut chord_points = 0usize;
|
||||
let mut analytical_tangents = 0usize;
|
||||
for w in wires.iter() {
|
||||
chord_points += w.points.len();
|
||||
analytical_tangents += w.tangent_geoms.len();
|
||||
}
|
||||
|
||||
let depths = rustc_hash::FxHashMap::default();
|
||||
let t_part_start = Instant::now();
|
||||
let iters = 100;
|
||||
let mut part = None;
|
||||
for _ in 0..iters {
|
||||
part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths));
|
||||
}
|
||||
let part_duration = t_part_start.elapsed() / (iters as u32);
|
||||
let p = part.unwrap();
|
||||
|
||||
// Zoom simulation
|
||||
let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0];
|
||||
let t_zoom_start = Instant::now();
|
||||
let mut dynamic_cam = Camera::default();
|
||||
for &zoom in zoom_levels.iter().cycle().take(100) {
|
||||
dynamic_cam.distance = 1000.0 / zoom;
|
||||
let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0);
|
||||
}
|
||||
let zoom_100_duration = t_zoom_start.elapsed();
|
||||
let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0;
|
||||
|
||||
let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0);
|
||||
let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0);
|
||||
|
||||
println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ===");
|
||||
println!("Thick & Tapered Entities: {total_entities}");
|
||||
println!("Initial Wire Build Time: {:.2?}", tess_duration);
|
||||
println!("Total Wires: {total_wires}");
|
||||
println!("Tessellated Chord Vertices: {chord_points}");
|
||||
println!("Analytical Tangent Geometries: {analytical_tangents}");
|
||||
println!("Partitioning Time (per frame): {:.3?}", part_duration);
|
||||
println!("Regular Line Wires: {}", p.regular.len());
|
||||
println!("GPU Circle/Arc Instances: {}", p.circle_instances.len());
|
||||
println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb);
|
||||
println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb);
|
||||
println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 });
|
||||
println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration);
|
||||
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
|
||||
println!("========================================================\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,85 +78,3 @@ impl SelectionState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bench_selection_clone_tests {
|
||||
use super::SelectionState;
|
||||
use iced::Point;
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn make_state(poly_len: usize) -> SelectionState {
|
||||
let mut s = SelectionState {
|
||||
vp_size: (1920.0, 1080.0),
|
||||
poly_points: vec![Point::new(10.0, 10.0); poly_len],
|
||||
..Default::default()
|
||||
};
|
||||
s.box_anchor = Some(Point::new(0.0, 0.0));
|
||||
s.box_current = Some(Point::new(100.0, 100.0));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_selection_state_clone() {
|
||||
let state = make_state(64);
|
||||
for _ in 0..20 {
|
||||
black_box(state.clone());
|
||||
}
|
||||
let n = 5000u32;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
black_box(state.clone());
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per = elapsed / n;
|
||||
println!(
|
||||
"SelectionState::clone (deep, 64 pts): {:?} per clone (n={}, total {:?})",
|
||||
per, n, elapsed
|
||||
);
|
||||
assert!(per.as_secs_f64() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_selection_arc_clone() {
|
||||
let state = Arc::new(make_state(64));
|
||||
for _ in 0..20 {
|
||||
black_box(Arc::clone(&state));
|
||||
}
|
||||
let n = 5000u32;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
black_box(Arc::clone(&state));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per = elapsed / n;
|
||||
println!(
|
||||
"Arc<SelectionState>::clone (Arc bump, 64 pts): {:?} per clone (n={}, total {:?})",
|
||||
per, n, elapsed
|
||||
);
|
||||
assert!(per.as_secs_f64() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_selection_state_clone_empty() {
|
||||
let state = make_state(0);
|
||||
for _ in 0..20 {
|
||||
black_box(state.clone());
|
||||
}
|
||||
let n = 5000u32;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
black_box(state.clone());
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per = elapsed / n;
|
||||
println!(
|
||||
"SelectionState::clone (deep, 0 pts): {:?} per clone (n={}, total {:?})",
|
||||
per, n, elapsed
|
||||
);
|
||||
assert!(per.as_secs_f64() > 0.0);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -159,7 +159,8 @@ thread_local! {
|
||||
/// trigger a `RefCell` panic on the second `borrow_mut`, because
|
||||
/// no `borrow_mut` is held while parsing runs. (The same
|
||||
/// `thread_local!` + `RefCell` shape is used by `SEMANTIC_CACHE`.)
|
||||
fn themed_handle(bytes: &'static [u8]) -> svg::Handle {
|
||||
#[doc(hidden)]
|
||||
pub fn themed_handle(bytes: &'static [u8]) -> svg::Handle {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
if let Some(handle) = THEMED_CACHE.with(|cache| cache.borrow().get(&key).cloned()) {
|
||||
return handle;
|
||||
|
||||
+14
-172
@@ -380,7 +380,8 @@ pub struct GridParams {
|
||||
/// uses to size the coloured UCS axes overlay. Returned from `grid_segments` so
|
||||
/// the renderer-free geometry construction can be unit-tested and benchmarked
|
||||
/// without an iced `Renderer` (Mission #1, 2026-08-26 bench-first plan).
|
||||
pub(crate) struct GridGeometry {
|
||||
#[doc(hidden)]
|
||||
pub struct GridGeometry {
|
||||
pub segments: Vec<(Point, Point)>,
|
||||
pub axis_extent: f32,
|
||||
}
|
||||
@@ -390,7 +391,8 @@ impl GridGeometry {
|
||||
/// early-exit branches of `grid_segments` (zero-sized bounds, no visible
|
||||
/// samples, non-finite step) so the caller never needs to special-case
|
||||
/// the `None` path.
|
||||
fn empty() -> Self {
|
||||
#[doc(hidden)]
|
||||
pub fn empty() -> Self {
|
||||
Self { segments: Vec::new(), axis_extent: 0.0 }
|
||||
}
|
||||
}
|
||||
@@ -405,8 +407,9 @@ impl GridGeometry {
|
||||
/// `GridParams` set is required for correctness.
|
||||
///
|
||||
/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1).
|
||||
#[doc(hidden)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct GridKey {
|
||||
pub struct GridKey {
|
||||
pub grids: Vec<GridParams>,
|
||||
pub bounds: iced::Rectangle,
|
||||
pub style: GridStyle,
|
||||
@@ -414,7 +417,8 @@ pub(crate) struct GridKey {
|
||||
|
||||
impl GridKey {
|
||||
/// Build a key from the per-pane `GridParams`, overlay bounds, and grid style.
|
||||
pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self {
|
||||
#[doc(hidden)]
|
||||
pub fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self {
|
||||
Self { grids: grids.to_vec(), bounds, style }
|
||||
}
|
||||
}
|
||||
@@ -423,7 +427,8 @@ impl GridKey {
|
||||
/// `new`. Reference-based to avoid moving the (potentially large) `Vec` of
|
||||
/// per-pane params; the caller borrows from `RefCell<Option<GridKey>>` on
|
||||
/// both sides.
|
||||
pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool {
|
||||
#[doc(hidden)]
|
||||
pub fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool {
|
||||
match cached {
|
||||
Some(old) => old == new,
|
||||
None => false,
|
||||
@@ -440,8 +445,9 @@ pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool {
|
||||
/// bounds happen to be unchanged (e.g. a pan within the same canvas size).
|
||||
///
|
||||
/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1).
|
||||
#[doc(hidden)]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GridCanvasState {
|
||||
pub struct GridCanvasState {
|
||||
pub key: RefCell<Option<GridKey>>,
|
||||
pub cache: canvas::Cache<iced::Renderer>,
|
||||
}
|
||||
@@ -1761,8 +1767,9 @@ fn draw_grid(
|
||||
/// Extracted from `draw_grid` (2026-08-26, Mission #1 step 1) so the geometry
|
||||
/// construction can be unit-tested and benchmarked without an iced
|
||||
/// `Renderer`. Behaviour is identical to the inlined version that preceded it.
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn grid_segments(
|
||||
pub fn grid_segments(
|
||||
view_rot: Mat4,
|
||||
eye: glam::DVec3,
|
||||
bounds: iced::Rectangle,
|
||||
@@ -3306,171 +3313,6 @@ mod clip_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bench_grid_geometry_tests {
|
||||
use super::*;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Benchmarks the pure grid geometry construction (uncached).
|
||||
/// Represents a 2-pane tiled Model layout: pane 1 at x=0..1280, pane 2 at
|
||||
/// x=1280..1920. Slight tilt, typical eye, step 80 (pane 1) / 160 (pane 2).
|
||||
/// RED: requires `grid_segments(...)` which does not exist yet — compilation
|
||||
/// must fail with E0425 "cannot find function `grid_segments`". The bench
|
||||
/// becomes meaningful at Step 1 once the helper is extracted.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_grid_geometry_uncached() {
|
||||
let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye1 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds1 = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1280.0,
|
||||
height: 720.0,
|
||||
};
|
||||
let step1 = 80.0_f32;
|
||||
let grid_origin1 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let grid_axes1 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits1: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye2 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds2 = iced::Rectangle {
|
||||
x: 1280.0,
|
||||
y: 0.0,
|
||||
width: 640.0,
|
||||
height: 720.0,
|
||||
};
|
||||
let step2 = 160.0_f32;
|
||||
let grid_origin2 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let grid_axes2 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits2: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
for _ in 0..20 {
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot1),
|
||||
black_box(eye1),
|
||||
black_box(bounds1),
|
||||
black_box(step1),
|
||||
black_box(grid_origin1),
|
||||
black_box(grid_axes1),
|
||||
black_box(limits1),
|
||||
));
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot2),
|
||||
black_box(eye2),
|
||||
black_box(bounds2),
|
||||
black_box(step2),
|
||||
black_box(grid_origin2),
|
||||
black_box(grid_axes2),
|
||||
black_box(limits2),
|
||||
));
|
||||
}
|
||||
|
||||
let n = 200u32;
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot1),
|
||||
black_box(eye1),
|
||||
black_box(bounds1),
|
||||
black_box(step1),
|
||||
black_box(grid_origin1),
|
||||
black_box(grid_axes1),
|
||||
black_box(limits1),
|
||||
));
|
||||
let _ = black_box(grid_segments(
|
||||
black_box(view_rot2),
|
||||
black_box(eye2),
|
||||
black_box(bounds2),
|
||||
black_box(step2),
|
||||
black_box(grid_origin2),
|
||||
black_box(grid_axes2),
|
||||
black_box(limits2),
|
||||
));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per_frame = elapsed / n;
|
||||
println!(
|
||||
"grid_segments uncached: {:?} per frame (n = {}, total {:?})",
|
||||
per_frame, n, elapsed
|
||||
);
|
||||
assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive");
|
||||
}
|
||||
|
||||
/// A/B partner of `bench_grid_geometry_uncached` (Mission #1, step 6).
|
||||
/// Times the hit-path decision only: build `GridKey` from the current
|
||||
/// pane params + canvas bounds, borrow the stored key, call
|
||||
/// `should_reuse`. Mirrors the body of the hit branch in
|
||||
/// `GridCanvas::draw`. Excludes the iced `canvas::Cache` internals
|
||||
/// (Arc-clone + draw_with_bounds fast path) because they live in the
|
||||
/// fork and are not what we added; measures only the cost we own.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_grid_geometry_cached() {
|
||||
let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye1 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds1 = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 };
|
||||
let step1 = 80.0_f32;
|
||||
let origin1 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let axes1 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits1: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05);
|
||||
let eye2 = glam::DVec3::new(4.0, 3.5, 9.0);
|
||||
let bounds2 = iced::Rectangle { x: 1280.0, y: 0.0, width: 640.0, height: 720.0 };
|
||||
let step2 = 160.0_f32;
|
||||
let origin2 = glam::DVec3::new(0.0, 0.0, 0.0);
|
||||
let axes2 = (Vec3::X, Vec3::Y, Vec3::Z);
|
||||
let limits2: Option<(glam::DVec2, glam::DVec2)> = None;
|
||||
|
||||
let params1 = GridParams {
|
||||
view_rot: view_rot1, eye: eye1, bounds: bounds1, step: step1,
|
||||
origin: origin1, axes: axes1, limits: limits1,
|
||||
};
|
||||
let params2 = GridParams {
|
||||
view_rot: view_rot2, eye: eye2, bounds: bounds2, step: step2,
|
||||
origin: origin2, axes: axes2, limits: limits2,
|
||||
};
|
||||
let grids = vec![params1, params2];
|
||||
// Overall canvas bounds — what `GridCanvas::draw` receives and
|
||||
// passes to `GridKey::from_grids`. The 1920×720 covers the two
|
||||
// tiled panes (1280 + 640).
|
||||
let canvas_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
|
||||
|
||||
// Pre-seed a `GridCanvasState` with the same key the bench will
|
||||
// build each iteration — guaranteed hit path.
|
||||
let state = GridCanvasState::default();
|
||||
let stored_key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default());
|
||||
*state.key.borrow_mut() = Some(stored_key);
|
||||
|
||||
for _ in 0..20 {
|
||||
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default());
|
||||
let hit = should_reuse(state.key.borrow().as_ref(), &key);
|
||||
black_box(hit);
|
||||
}
|
||||
|
||||
let n = 200u32;
|
||||
let start = Instant::now();
|
||||
let mut hit_count = 0u32;
|
||||
for _ in 0..n {
|
||||
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default());
|
||||
if should_reuse(state.key.borrow().as_ref(), &key) {
|
||||
hit_count += 1;
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let per_frame = elapsed / n;
|
||||
assert_eq!(hit_count, n, "bench should always hit (sanity)");
|
||||
println!(
|
||||
"grid key + should_reuse (hit path): {:?} per frame (n = {}, total {:?})",
|
||||
per_frame, n, elapsed
|
||||
);
|
||||
assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grid_key_tests {
|
||||
use super::*;
|
||||
|
||||
+1
-72
@@ -1610,76 +1610,5 @@ mod tests {
|
||||
assert!(ribbon.escape_extension());
|
||||
assert_eq!(ribbon.open_dropdown, None);
|
||||
}
|
||||
|
||||
/// Reproducible element-construction benchmark for the ribbon view. Run with:
|
||||
/// `cargo test --lib --release -- --ignored --nocapture bench_ribbon_view_construction`
|
||||
///
|
||||
/// Times `Ribbon::view()` element construction — the dominant cost in the
|
||||
/// cheap (non-render) part of a ribbon frame, and the thing Mission #4
|
||||
/// reworked. `#[ignore]`d so normal runs stay silent; state is populated so
|
||||
/// Auto-mode panels actually have four densities to build.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_ribbon_view_construction() {
|
||||
use std::time::Instant;
|
||||
use super::*;
|
||||
|
||||
let mut ribbon = Ribbon::new();
|
||||
ribbon.set_styles(
|
||||
vec![
|
||||
"Standard".to_string(),
|
||||
"Title".to_string(),
|
||||
"Annotative".to_string(),
|
||||
],
|
||||
"Standard",
|
||||
vec!["Standard".to_string()],
|
||||
"Standard",
|
||||
vec!["Standard".to_string()],
|
||||
"Standard",
|
||||
vec!["Standard".to_string()],
|
||||
"Standard",
|
||||
);
|
||||
ribbon.set_layers(
|
||||
vec![
|
||||
LayerInfo {
|
||||
name: "0".to_string(),
|
||||
color: Color::TRANSPARENT,
|
||||
visible: true,
|
||||
frozen: false,
|
||||
locked: false,
|
||||
},
|
||||
LayerInfo {
|
||||
name: "DRAWING".to_string(),
|
||||
color: Color::TRANSPARENT,
|
||||
visible: true,
|
||||
frozen: false,
|
||||
locked: false,
|
||||
},
|
||||
],
|
||||
"0",
|
||||
);
|
||||
ribbon.set_available_linetypes(vec![
|
||||
LinetypeItem {
|
||||
name: "Continuous".to_string(),
|
||||
art: String::new(),
|
||||
},
|
||||
LinetypeItem {
|
||||
name: "DASHED".to_string(),
|
||||
art: String::new(),
|
||||
},
|
||||
]);
|
||||
|
||||
let n = 200u32;
|
||||
// Warm-up for allocator/tree-slot settling before timing begins.
|
||||
let _ = ribbon.view(false, false, 0, 0, false);
|
||||
let start = Instant::now();
|
||||
for _ in 0..n {
|
||||
let _ = ribbon.view(false, false, 0, 0, false);
|
||||
}
|
||||
let per_frame = start.elapsed() / n;
|
||||
println!(
|
||||
"bench_ribbon_view_construction: {per_frame:?} per `Ribbon::view()` \
|
||||
element build (n = {n}, release measurement recommended)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user