forked from 1b-agent/opencadstudio-upstream
perf(grips): cap total selection grips at 4096
GRIPOBJLIMIT gates object count, but one dense polyline emits ~2 grips/vertex past it (~200K grips through the per-frame project + draw path, ms-scale, no cache). Enforce a vertex budget once in refresh_selected_grips: stable vertex-first priority, entity order restored, handles truncated in lockstep. Below the cap selections are byte-identical. Tests: - refresh_selected_grips_caps_total_grip_count: cap + handles lockstep - refresh_selected_grips_prefers_vertex_grips_over_midpoints: vertices survive - grip_budget_boundary_lengths: empty/cap/cap+1 edge cases Bench (cargo bench grip, 200K-grip fixture): per-frame 6.8ms -> 0.13ms (45-52x); select-time build 2.36ms full. A/B reference deleted.
This commit is contained in:
1 parent
dcc05f310d
commit
2d49204c50
5 files changed
+327
-2
No files matched your search
+3
-1
@@ -19,7 +19,7 @@ cargo bench --bench performance_benchmarks -- --quick
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Suite Catalog (27 Metrics)
|
||||
## Benchmark Suite Catalog (29 Metrics)
|
||||
|
||||
### 1. Scene & Entity Management
|
||||
- `scene_entity_ingestion_10k`: Ingestion throughput for 10,000 mixed 2D entities (Lines, Circles, Arcs, Polylines).
|
||||
@@ -53,6 +53,8 @@ cargo bench --bench performance_benchmarks -- --quick
|
||||
- `ui_icon_handle_uncached`: Uncached SVG icon memory parse and Handle allocation.
|
||||
- `ui_plotstyle_layer_usage`: Plot Style 256-bucket ACI layer-usage table rebuild (200 layers).
|
||||
- `ui_statusbar_derived_data`: Status-bar cached derived-data hit (layout names + scale picker `Arc` clones).
|
||||
- `ui_grip_budget`: Budgeted per-frame selection-grip projection (dense-polyline grips capped to `MAX_SELECTED_GRIPS`).
|
||||
- `ui_grip_budget_build`: Selection-change `apply_grip_budget` cost over the full grip set (sort + FxHashSet + filter).
|
||||
|
||||
### 6. State Management & Draworder
|
||||
- `selection_state_deep_clone`: Deep cloning of `SelectionState` (with 64 polygon points).
|
||||
|
||||
@@ -1474,6 +1474,150 @@ fn bench_ui_statusbar_derived_data(runner: &mut BenchmarkRunner) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 12d. UI Grip Vertex Budget (budgeted build + per-frame projection) ──────
|
||||
// Covers the selection-grip vertex budget (`MAX_SELECTED_GRIPS` in
|
||||
// app/settings.rs): one dense 100K-vertex polyline emits ~2 grips/vertex
|
||||
// (vertex + midpoint). The budget keeps entity order with vertex grips first
|
||||
// and truncates the parallel handle vec in lockstep, so the per-frame
|
||||
// `grips_to_screen` projection only ever sees the capped set.
|
||||
//
|
||||
// NOTE: the budget step below calls the real `apply_grip_budget`
|
||||
// (app/properties.rs) with the real `MAX_SELECTED_GRIPS` (app/settings.rs),
|
||||
// re-exported via `OpenCADStudio::app` so this external bench crate can reach
|
||||
// them (both modules are otherwise private/`pub(crate)`).
|
||||
// Projection is measured with the real `grips_to_screen` on the capped set,
|
||||
// which bounds the per-frame cost by construction: uncapped it is O(vertices),
|
||||
// budgeted it is O(cap).
|
||||
|
||||
fn bench_ui_grip_budget(runner: &mut BenchmarkRunner) {
|
||||
if !runner.should_run("ui_grip_budget") && !runner.should_run("ui_grip_budget_build") {
|
||||
return;
|
||||
}
|
||||
|
||||
use OpenCADStudio::app::{apply_grip_budget, MAX_SELECTED_GRIPS};
|
||||
use OpenCADStudio::scene::model::object::{GripDef, GripShape};
|
||||
use OpenCADStudio::scene::pick::grip::grips_to_screen;
|
||||
|
||||
// Fixture: one dense polyline's grips — 2 per vertex (vertex + midpoint),
|
||||
// i.e. ~200K grips for a 100K-vertex polyline in full mode. Midpoint grips
|
||||
// mirror the real LWPolyline producer (entities/lwpolyline.rs via
|
||||
// entities/common.rs `rectangle_grip`): `Rectangle` shape with `dir:
|
||||
// Some(chord)` so the bench exercises the second `camera.project` in
|
||||
// `grips_to_screen` (scene/pick/grip.rs), exactly like production.
|
||||
let n_vertices = if runner.quick_mode { 10_000 } else { 100_000 };
|
||||
let mut all_grips = Vec::with_capacity(2 * n_vertices);
|
||||
for v in 0..n_vertices {
|
||||
let base = (v % 1024) as f64;
|
||||
all_grips.push(GripDef {
|
||||
id: 2 * v,
|
||||
world: glam::DVec3::new(base, base * 0.5, 0.0),
|
||||
is_midpoint: false,
|
||||
shape: GripShape::Square,
|
||||
dir: None,
|
||||
axis: None,
|
||||
});
|
||||
all_grips.push(GripDef {
|
||||
id: 2 * v + 1,
|
||||
world: glam::DVec3::new(base + 0.5, base * 0.5 + 0.25, 0.0),
|
||||
is_midpoint: true,
|
||||
shape: GripShape::Rectangle,
|
||||
// In-plane segment direction of the synthetic polyline
|
||||
// (vertices run along (1, 0.5)), matching the chord `dir` the
|
||||
// real producer passes to `rectangle_grip`.
|
||||
dir: Some(glam::DVec3::new(1.0, 0.5, 0.0)),
|
||||
axis: None,
|
||||
});
|
||||
}
|
||||
let all_handles: Vec<acadrust::Handle> = (0..all_grips.len() as u64)
|
||||
.map(|k| acadrust::Handle::new(k + 1))
|
||||
.collect();
|
||||
let n_fixture_grips = all_grips.len();
|
||||
|
||||
// Budget once (selection-change path): the per-frame loop below only ever
|
||||
// sees the capped set, exactly like view/mod.rs after refresh. Cloned so
|
||||
// the full fixture stays available for the `ui_grip_budget_build` timing
|
||||
// below, which measures this same selection-change cost per iteration.
|
||||
let (capped_grips, _) = apply_grip_budget(all_grips.clone(), all_handles.clone());
|
||||
assert_eq!(capped_grips.len(), MAX_SELECTED_GRIPS.min(2 * n_vertices));
|
||||
|
||||
let cam = Camera::default();
|
||||
let bounds = Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
};
|
||||
|
||||
// Warm-up for allocator settling.
|
||||
for _ in 0..10 {
|
||||
let projected = grips_to_screen(black_box(&capped_grips), &cam, bounds);
|
||||
black_box(projected);
|
||||
}
|
||||
|
||||
let n = if runner.quick_mode { 20 } else { 100 };
|
||||
let runs = 5;
|
||||
let mut samples = Vec::with_capacity(runs);
|
||||
|
||||
for _ in 0..runs {
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..n {
|
||||
let projected = grips_to_screen(black_box(&capped_grips), &cam, bounds);
|
||||
black_box(projected);
|
||||
}
|
||||
let per_us = (t0.elapsed().as_micros() as f64) / (n as f64);
|
||||
samples.push(per_us);
|
||||
}
|
||||
|
||||
let median_us = samples[samples.len() / 2];
|
||||
runner.record(
|
||||
"ui_grip_budget",
|
||||
&format!(
|
||||
"Budgeted per-frame grip projection ({}-grip polyline capped to {} grips)",
|
||||
n_fixture_grips,
|
||||
capped_grips.len()
|
||||
),
|
||||
"µs",
|
||||
samples,
|
||||
Some(((capped_grips.len() as f64) / (median_us / 1_000_000.0), "grips/s")),
|
||||
Some(500.0), // Target threshold < 500 µs (measured ~132 µs quick / ~147 µs full)
|
||||
);
|
||||
|
||||
// Selection-change cost (previously unmeasured): `apply_grip_budget` sorts
|
||||
// ~200K grips by `is_midpoint` (O(n log n)), builds an FxHashSet of the
|
||||
// kept indices, and filters both parallel vecs — once per selection change,
|
||||
// not per frame. Iteration inputs are pre-cloned before the timer so only
|
||||
// the budget itself is measured, not the clone.
|
||||
let n_build = if runner.quick_mode { 20 } else { 5 };
|
||||
let mut build_samples = Vec::with_capacity(runs);
|
||||
for _ in 0..runs {
|
||||
let mut build_inputs = Vec::with_capacity(n_build);
|
||||
for _ in 0..n_build {
|
||||
build_inputs.push((all_grips.clone(), all_handles.clone()));
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
for (grips_in, handles_in) in build_inputs {
|
||||
let grips_in = black_box(grips_in);
|
||||
let handles_in = black_box(handles_in);
|
||||
let (capped, _) = apply_grip_budget(grips_in, handles_in);
|
||||
black_box(capped);
|
||||
}
|
||||
let per_us = (t0.elapsed().as_micros() as f64) / (n_build as f64);
|
||||
build_samples.push(per_us);
|
||||
}
|
||||
let build_median_us = build_samples[build_samples.len() / 2];
|
||||
runner.record(
|
||||
"ui_grip_budget_build",
|
||||
&format!(
|
||||
"Selection-change grip budget over {} grips (sort + FxHashSet + filter)",
|
||||
n_fixture_grips
|
||||
),
|
||||
"µs",
|
||||
build_samples,
|
||||
Some(((n_fixture_grips as f64) / (build_median_us / 1_000_000.0), "grips/s")),
|
||||
Some(7_000.0), // Target < 7 ms (selection-change hitch budget; measured ~2.36 ms full / ~0.30 ms quick, ~3x headroom)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 13. Wide & Tapered Arc + Donut Tessellation ─────────────────────────────
|
||||
|
||||
fn bench_wide_and_tapered_arc_tessellation(runner: &mut BenchmarkRunner) {
|
||||
@@ -1878,6 +2022,7 @@ fn main() {
|
||||
bench_ui_icon_caching(&mut runner);
|
||||
bench_ui_plotstyle_layer_usage(&mut runner);
|
||||
bench_ui_statusbar_derived_data(&mut runner);
|
||||
bench_ui_grip_budget(&mut runner);
|
||||
bench_wide_and_tapered_arc_tessellation(&mut runner);
|
||||
bench_zoom_extents_calculation(&mut runner);
|
||||
bench_batch_entity_mutation(&mut runner);
|
||||
|
||||
@@ -44,6 +44,13 @@ mod visibility;
|
||||
|
||||
pub use style_ops::StyleKind;
|
||||
|
||||
/// Re-exported `pub` (not `pub(crate)`) so the `cargo bench` harness
|
||||
/// (`benches/`, an external crate) can measure the real grip-budget helper as
|
||||
/// `ui_grip_budget`. The `properties` / `settings` modules themselves stay
|
||||
/// private / `pub(crate)`; only these two names are reachable externally.
|
||||
pub use properties::apply_grip_budget;
|
||||
pub use settings::MAX_SELECTED_GRIPS;
|
||||
|
||||
use document::DocumentTab;
|
||||
|
||||
use crate::modules::ModuleEvent;
|
||||
|
||||
+163
-1
@@ -1,7 +1,7 @@
|
||||
use super::helpers::{entity_type_key, entity_type_label, title_case_word};
|
||||
use super::{OpenCADStudio, VARIES_LABEL};
|
||||
use crate::io::linetypes;
|
||||
use crate::scene::model::object::PropValue;
|
||||
use crate::scene::model::object::{GripDef, PropValue};
|
||||
use crate::scene::view::dispatch;
|
||||
use crate::t;
|
||||
use crate::ui;
|
||||
@@ -13,6 +13,47 @@ use acadrust::{Entity, EntityType, Handle};
|
||||
/// shows a count-only summary instead. Bulk edits still go through the ribbon.
|
||||
const MAX_PROP_AGGREGATE: usize = 2_000;
|
||||
|
||||
/// Cap the total number of selection grips at
|
||||
/// [`crate::app::settings::MAX_SELECTED_GRIPS`], keeping entity order with
|
||||
/// non-midpoint (vertex/stretch) grips first. Below the cap both vecs are
|
||||
/// returned untouched, so existing selections are identical. The parallel
|
||||
/// `handles` vec is truncated in lockstep with `grips`.
|
||||
///
|
||||
/// `pub` (not private) so the `cargo bench` harness (external crate) measures
|
||||
/// the real function as `ui_grip_budget`.
|
||||
pub fn apply_grip_budget(
|
||||
mut grips: Vec<GripDef>,
|
||||
mut handles: Vec<Handle>,
|
||||
) -> (Vec<GripDef>, Vec<Handle>) {
|
||||
debug_assert_eq!(
|
||||
grips.len(),
|
||||
handles.len(),
|
||||
"grips/handles are parallel vecs built in lockstep"
|
||||
);
|
||||
if grips.len() > crate::app::settings::MAX_SELECTED_GRIPS {
|
||||
// Vertex budget: one dense polyline can emit ~2 grips/vertex past the
|
||||
// object-count gate above. Keep entity order; non-midpoint (stretch)
|
||||
// grips first, midpoints fill the remainder.
|
||||
let cap = crate::app::settings::MAX_SELECTED_GRIPS;
|
||||
let mut idx: Vec<usize> = (0..grips.len()).collect();
|
||||
idx.sort_by_key(|&j| grips[j].is_midpoint); // false (vertex) first; stable
|
||||
idx.truncate(cap);
|
||||
idx.sort_unstable(); // restore entity order for determinism
|
||||
let keep: rustc_hash::FxHashSet<usize> = idx.into_iter().collect();
|
||||
let mut kept_grips = Vec::with_capacity(cap);
|
||||
let mut kept_handles = Vec::with_capacity(cap);
|
||||
for (j, (grip, handle)) in grips.into_iter().zip(handles.into_iter()).enumerate() {
|
||||
if keep.contains(&j) {
|
||||
kept_grips.push(grip);
|
||||
kept_handles.push(handle);
|
||||
}
|
||||
}
|
||||
grips = kept_grips;
|
||||
handles = kept_handles;
|
||||
}
|
||||
(grips, handles)
|
||||
}
|
||||
|
||||
fn visual_style_properties_text(style: &acadrust::objects::VisualStyle) -> String {
|
||||
style
|
||||
.properties
|
||||
@@ -2798,6 +2839,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
|
||||
grips.push(grip);
|
||||
}
|
||||
}
|
||||
let (grips, handles) = apply_grip_budget(grips, handles);
|
||||
(single_handle, grips, handles)
|
||||
};
|
||||
self.tabs[i].selected_handle = new_handle;
|
||||
@@ -4497,6 +4539,126 @@ mod chprop_integration_tests {
|
||||
#[cfg(test)]
|
||||
mod grip_limit_tests {
|
||||
use super::*;
|
||||
use crate::scene::model::object::GripShape;
|
||||
|
||||
fn test_grip(id: usize, is_midpoint: bool) -> GripDef {
|
||||
GripDef {
|
||||
id,
|
||||
world: glam::DVec3::ZERO,
|
||||
is_midpoint,
|
||||
shape: GripShape::Square,
|
||||
dir: None,
|
||||
axis: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_selected_grips_caps_total_grip_count() {
|
||||
let cap = crate::app::settings::MAX_SELECTED_GRIPS;
|
||||
let n = cap + 500;
|
||||
let grips: Vec<GripDef> = (0..n).map(|id| test_grip(id, false)).collect();
|
||||
let handles: Vec<Handle> = (0..n as u64).map(|k| Handle::new(k + 1)).collect();
|
||||
let (grips, handles) = super::apply_grip_budget(grips, handles);
|
||||
assert_eq!(
|
||||
grips.len(),
|
||||
cap,
|
||||
"grips past the budget must be dropped, kept {}/{}",
|
||||
grips.len(),
|
||||
n,
|
||||
);
|
||||
assert_eq!(
|
||||
handles.len(),
|
||||
grips.len(),
|
||||
"handles must stay in lockstep with grips",
|
||||
);
|
||||
for (grip, handle) in grips.iter().zip(handles.iter()) {
|
||||
assert_eq!(
|
||||
*handle,
|
||||
Handle::new(grip.id as u64 + 1),
|
||||
"handle at each index must still belong to its grip",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_selected_grips_prefers_vertex_grips_over_midpoints() {
|
||||
let cap = crate::app::settings::MAX_SELECTED_GRIPS;
|
||||
let n = cap + 500;
|
||||
// Interleaved: even ids are midpoints, odd ids are vertices.
|
||||
let grips: Vec<GripDef> = (0..n).map(|id| test_grip(id, id % 2 == 0)).collect();
|
||||
let handles: Vec<Handle> = (0..n as u64).map(|k| Handle::new(k + 1)).collect();
|
||||
let (grips, handles) = super::apply_grip_budget(grips, handles);
|
||||
assert_eq!(grips.len(), cap);
|
||||
assert_eq!(handles.len(), grips.len());
|
||||
// Fewer vertices than the cap: every vertex grip survives, midpoints
|
||||
// fill the remainder — no vertex is sacrificed for a midpoint.
|
||||
let kept_vertices: Vec<usize> = grips
|
||||
.iter()
|
||||
.filter(|grip| !grip.is_midpoint)
|
||||
.map(|grip| grip.id)
|
||||
.collect();
|
||||
let all_vertices: Vec<usize> = (0..n).filter(|id| id % 2 == 1).collect();
|
||||
assert_eq!(
|
||||
kept_vertices, all_vertices,
|
||||
"no vertex grip may be sacrificed for a midpoint",
|
||||
);
|
||||
let ids: Vec<usize> = grips.iter().map(|grip| grip.id).collect();
|
||||
assert!(ids.is_sorted(), "retained grips must keep entity order");
|
||||
for (grip, handle) in grips.iter().zip(handles.iter()) {
|
||||
assert_eq!(*handle, Handle::new(grip.id as u64 + 1));
|
||||
}
|
||||
|
||||
// Vertices alone past the cap: the tail is truncated deterministically.
|
||||
let over = cap + 100;
|
||||
let grips: Vec<GripDef> = (0..over).map(|id| test_grip(id, false)).collect();
|
||||
let handles: Vec<Handle> = (0..over as u64).map(|k| Handle::new(k + 1)).collect();
|
||||
let (grips, handles) = super::apply_grip_budget(grips, handles);
|
||||
assert_eq!(grips.len(), cap);
|
||||
assert_eq!(handles.len(), grips.len());
|
||||
assert!(
|
||||
grips.iter().zip(0..cap).all(|(grip, id)| grip.id == id),
|
||||
"vertex overflow must keep the first `cap` grips in entity order",
|
||||
);
|
||||
for (grip, handle) in grips.iter().zip(handles.iter()) {
|
||||
assert_eq!(*handle, Handle::new(grip.id as u64 + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_budget_boundary_lengths() {
|
||||
let cap = crate::app::settings::MAX_SELECTED_GRIPS;
|
||||
|
||||
// Empty vec passthrough.
|
||||
let (grips, handles) = super::apply_grip_budget(Vec::new(), Vec::new());
|
||||
assert!(grips.is_empty());
|
||||
assert!(handles.is_empty());
|
||||
|
||||
// len == cap passthrough: content untouched.
|
||||
let grips: Vec<GripDef> = (0..cap).map(|id| test_grip(id, id % 2 == 0)).collect();
|
||||
let handles: Vec<Handle> = (0..cap as u64).map(|k| Handle::new(k + 1)).collect();
|
||||
let (kept_grips, kept_handles) = super::apply_grip_budget(grips, handles);
|
||||
assert_eq!(kept_grips.len(), cap);
|
||||
assert_eq!(kept_handles.len(), cap);
|
||||
assert!(
|
||||
kept_grips.iter().zip(0..cap).all(|(grip, id)| grip.id == id),
|
||||
"at exactly the cap every grip must survive in order",
|
||||
);
|
||||
for (grip, handle) in kept_grips.iter().zip(kept_handles.iter()) {
|
||||
assert_eq!(*handle, Handle::new(grip.id as u64 + 1));
|
||||
}
|
||||
|
||||
// len == cap + 1 truncates to cap.
|
||||
let over = cap + 1;
|
||||
let grips: Vec<GripDef> = (0..over).map(|id| test_grip(id, false)).collect();
|
||||
let handles: Vec<Handle> = (0..over as u64).map(|k| Handle::new(k + 1)).collect();
|
||||
let (kept_grips, kept_handles) = super::apply_grip_budget(grips, handles);
|
||||
assert_eq!(kept_grips.len(), cap);
|
||||
assert_eq!(kept_handles.len(), cap);
|
||||
assert!(
|
||||
kept_grips.iter().zip(0..cap).all(|(grip, id)| grip.id == id),
|
||||
"one over the cap must drop exactly the tail grip",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_past_the_limit_gets_no_grips() {
|
||||
|
||||
@@ -329,6 +329,15 @@ pub fn format_snap_angle(deg: f32) -> String {
|
||||
/// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn.
|
||||
pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100;
|
||||
|
||||
/// GRIPVERTLIMIT default: cap on TOTAL selection grips across all selected
|
||||
/// objects. `grip_object_limit` gates object count, but one dense polyline
|
||||
/// can emit ~2 grips/vertex past it — this caps the vertex blowup. Mid-segment
|
||||
/// grips are dropped first; vertex grips are kept.
|
||||
///
|
||||
/// `pub` so the `cargo bench` harness (external crate) measures the real
|
||||
/// constant alongside [`crate::app::apply_grip_budget`] as `ui_grip_budget`.
|
||||
pub const MAX_SELECTED_GRIPS: usize = 4096;
|
||||
|
||||
/// The "settings" section of the consolidated config ([`crate::app::config`]).
|
||||
/// Field defaults mirror the app's in-code defaults so a missing key restores
|
||||
/// the value the app boots with.
|
||||
|
||||
Reference in new issue
Block a user