forked from 1b-agent/opencadstudio-upstream
perf(scene): splice the resident wire set per edit, no clone-all (phase 3)
On a geometry-only edit the resident wire set was rebuilt from scratch: every memoized entity deep-cloned into a fresh Vec (mod.rs clone-all) and the whole set re-sorted by draw order — O(total wires) on each single-entity change even though the per-entity tessellation memo already reused everything. Add try_resident_patch: when the cached set is only a journal-replayable gap behind and uniquely held, move its wires out (Arc::try_unwrap), group them into per-entity runs, re-tessellate only the changed entities, and reassemble in document order with the same stable draw-order sort. Unchanged entities are MOVED, not deep-cloned; only the changed handful are re-tessellated (into the memo, so a later full build still reuses them). It bails to the identical full build whenever an assumption does not hold — no cache entry, journal not replayable (full delta / ring overflow), the Arc still shared, a wire not named with its handle, or an entity`s wires not contiguous — so correctness never rides on the fast path. A changed entity is classified through the shared resident_entity_visible (also now used by the full build) so it is included/excluded exactly as a rebuild would. Adds a differential-oracle test: the patched resident set equals a from-scratch rebuild (same wires, same draw order) after add / move / erase, and asserts the patch path actually runs for every edit (a resident_patch_hits counter) rather than silently always falling back. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
88dbbb76af
commit
0907d2912d
1 file changed
+272
-32
+272
-32
@@ -979,6 +979,10 @@ pub struct Scene {
|
||||
/// Highest epoch whose delta has been evicted from the ring. A consumer
|
||||
/// synced older than this can't be caught up incrementally → full rebuild.
|
||||
geometry_journal_floor: std::cell::Cell<u64>,
|
||||
/// Count of resident-set rebuilds served by the incremental patch rather than
|
||||
/// a full re-tessellation. Diagnostics + a hook for the oracle test to prove
|
||||
/// the fast path is actually exercised (not silently always falling back).
|
||||
resident_patch_hits: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
@@ -1062,6 +1066,7 @@ impl Scene {
|
||||
resident_tess_guard: std::cell::Cell::new(0),
|
||||
geometry_deltas: RefCell::new(std::collections::VecDeque::new()),
|
||||
geometry_journal_floor: std::cell::Cell::new(0),
|
||||
resident_patch_hits: std::cell::Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2634,15 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Incremental: bring the cached set up to date by replaying just the
|
||||
// changed entities into it, instead of re-cloning + re-sorting the whole
|
||||
// set. Falls back to the full build below when it can't (no cache entry,
|
||||
// journal un-replayable, or a structural assumption violated).
|
||||
if let Some(arc) =
|
||||
self.try_resident_patch(key, block, bg, anno_scale_override, frozen_layers)
|
||||
{
|
||||
return arc;
|
||||
}
|
||||
// Build once: full tessellation, no cull (region = None), no zoom LOD
|
||||
// (wpp = None) — for every space, exactly like the Model static-hold.
|
||||
let t_tess = iced::time::Instant::now();
|
||||
@@ -2652,6 +2666,165 @@ impl Scene {
|
||||
arc
|
||||
}
|
||||
|
||||
/// Bring the resident set for `key` up to the current epoch by replaying only
|
||||
/// the changed entities into the cached assembly. Returns the patched `Arc`,
|
||||
/// or `None` to fall back to a full rebuild — the safe default whenever the
|
||||
/// fast path can't apply: no cache entry, the journal can't be replayed
|
||||
/// (a `full` delta or ring overflow), the cached `Arc` is still shared (so
|
||||
/// its wires can't be moved out), or a structural assumption is violated
|
||||
/// (a wire not named with its entity handle, or an entity's wires not
|
||||
/// contiguous). Because every failure falls back to the identical full
|
||||
/// build, correctness never depends on the fast path being taken.
|
||||
fn try_resident_patch(
|
||||
&self,
|
||||
key: u64,
|
||||
block: Handle,
|
||||
bg: [f32; 4],
|
||||
anno_scale_override: Option<f32>,
|
||||
frozen_layers: Option<&HashSet<Handle>>,
|
||||
) -> Option<Arc<Vec<WireModel>>> {
|
||||
// The entry must exist, be stale, and be uniquely held so we can move
|
||||
// its wires out rather than deep-clone them.
|
||||
let cached_epoch = {
|
||||
let sets = self.resident_wire_sets.borrow();
|
||||
let entry = sets.get(&key)?;
|
||||
if entry.0 == self.geometry_epoch || Arc::strong_count(&entry.2) != 1 {
|
||||
return None;
|
||||
}
|
||||
entry.0
|
||||
};
|
||||
let deltas = self.replay_since(cached_epoch)?;
|
||||
|
||||
// Take ownership of the cached assembly (guaranteed unique above).
|
||||
let owned: Vec<WireModel> = {
|
||||
let arc = self.resident_wire_sets.borrow_mut().remove(&key)?.2;
|
||||
Arc::try_unwrap(arc).ok()?
|
||||
};
|
||||
|
||||
// Group into per-entity runs. Bail if any wire isn't named with a handle
|
||||
// or an entity's wires aren't contiguous — the from-scratch sort puts
|
||||
// each entity's wires in one contiguous run, so a violation means our
|
||||
// move-and-reassemble model doesn't hold and we must full-rebuild.
|
||||
let mut old_runs: HashMap<Handle, Vec<WireModel>> = HashMap::default();
|
||||
{
|
||||
let mut cur: Option<Handle> = None;
|
||||
let mut run: Vec<WireModel> = Vec::new();
|
||||
for w in owned {
|
||||
let h = Self::handle_from_wire_name(&w.name)?;
|
||||
if Some(h) != cur {
|
||||
if let Some(ph) = cur.take() {
|
||||
if old_runs.insert(ph, std::mem::take(&mut run)).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
cur = Some(h);
|
||||
}
|
||||
run.push(w);
|
||||
}
|
||||
if let Some(ph) = cur {
|
||||
if old_runs.insert(ph, run).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-tessellate the changed entities, replicating the full build's exact
|
||||
// context (anno derivation, empty selection, no cull / no LOD). Faded
|
||||
// copy goes into the assembly; the raw copy refreshes the memo, matching
|
||||
// wires_for_block_culled (memo stores pre-fade wires).
|
||||
let anno = if let Some(a) = anno_scale_override {
|
||||
a
|
||||
} else if self.current_layout == "Model" {
|
||||
self.annotation_scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let blk = self.block_cache_arc();
|
||||
let empty_sel: HashSet<Handle> = HashSet::default();
|
||||
let mut new_runs: HashMap<Handle, Vec<WireModel>> = HashMap::default();
|
||||
let mut memo_updates: Vec<(Handle, Arc<Vec<WireModel>>)> = Vec::new();
|
||||
for (h, kind) in &deltas {
|
||||
// A changed entity's old run never carries over; unchanged entities
|
||||
// are the only ones left in old_runs after this.
|
||||
old_runs.remove(h);
|
||||
if matches!(kind, ChangeKind::Removed) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = self.document.get_entity(*h) else {
|
||||
continue;
|
||||
};
|
||||
if !self.resident_entity_visible(e, block, frozen_layers) {
|
||||
continue;
|
||||
}
|
||||
let raw = tessellate_entity(
|
||||
&self.document,
|
||||
&empty_sel,
|
||||
self.active_viewport,
|
||||
bg,
|
||||
anno,
|
||||
e,
|
||||
Some(&blk),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
memo_updates.push((*h, Arc::new(raw.clone())));
|
||||
let mut faded = raw;
|
||||
self.apply_refedit_fade(&mut faded, bg);
|
||||
new_runs.insert(*h, faded);
|
||||
}
|
||||
{
|
||||
let mut memo = self.resident_tess_memo.borrow_mut();
|
||||
for (h, a) in memo_updates {
|
||||
memo.insert(h, a);
|
||||
}
|
||||
}
|
||||
|
||||
// Reassemble in document order, then apply the identical draw-order sort
|
||||
// wires_for_block_culled uses. With a fully-populated memo the full build
|
||||
// also emits in document order (every entity is a hit), so a stable sort
|
||||
// by rank yields byte-identical output — this is what the oracle test
|
||||
// pins.
|
||||
let mut new_vec: Vec<WireModel> = Vec::new();
|
||||
for e in self.document.entities() {
|
||||
let h = e.common().handle;
|
||||
if let Some(run) = new_runs.remove(&h) {
|
||||
new_vec.extend(run);
|
||||
} else if let Some(run) = old_runs.remove(&h) {
|
||||
new_vec.extend(run);
|
||||
}
|
||||
}
|
||||
// A leftover run means its entity vanished from the document without a
|
||||
// Removed delta — an inconsistency we don't try to patch.
|
||||
if !new_runs.is_empty() || !old_runs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
{
|
||||
let cache = self.sort_cache.borrow();
|
||||
if let Some((_, ref idx)) = *cache {
|
||||
if let Some(sort_map) = idx.get(&block) {
|
||||
new_vec.sort_by_key(|w| {
|
||||
let key = Self::handle_from_wire_name(&w.name)
|
||||
.map(|h| h.value())
|
||||
.unwrap_or(u64::MAX);
|
||||
sort_map.get(&key).copied().unwrap_or(key)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let arc = Arc::new(new_vec);
|
||||
self.last_tess_wires.set(arc.len());
|
||||
self.resident_patch_hits
|
||||
.set(self.resident_patch_hits.get() + 1);
|
||||
let gen = WIRE_CONTENT_GEN.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_model_wire_gen.set(gen);
|
||||
let cur_epoch = self.geometry_epoch;
|
||||
let mut sets = self.resident_wire_sets.borrow_mut();
|
||||
sets.retain(|_, (e, ..)| *e == cur_epoch);
|
||||
sets.insert(key, (cur_epoch, gen, Arc::clone(&arc)));
|
||||
Some(arc)
|
||||
}
|
||||
|
||||
/// Cached tessellation of the current layout block's paper-space entities,
|
||||
/// shared by `entity_wires_arc()` and the GPU sheet viewport. A thin
|
||||
/// "dressing" layer over the unified resident set (border drop +
|
||||
@@ -3696,6 +3869,45 @@ impl Scene {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether entity `e` renders as direct content of `block_handle` right now:
|
||||
/// visible (not invisible / hidden / on an off-or-frozen layer / frozen
|
||||
/// through the viewport) and owned by the block. Shared by the full resident
|
||||
/// build and the incremental patch so a changed entity is classified
|
||||
/// identically either way.
|
||||
fn resident_entity_visible(
|
||||
&self,
|
||||
e: &EntityType,
|
||||
block_handle: Handle,
|
||||
frozen_layers: Option<&HashSet<Handle>>,
|
||||
) -> bool {
|
||||
let c = e.common();
|
||||
if c.invisible {
|
||||
return false;
|
||||
}
|
||||
// Isolate / Hide: skip entities the user has hidden.
|
||||
if !self.hidden.is_empty() && self.hidden.contains(&c.handle) {
|
||||
return false;
|
||||
}
|
||||
// Block/BlockEnd are block-defn sentinels, not drawable geometry.
|
||||
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
|
||||
return false;
|
||||
}
|
||||
let layer = self.document.layers.get(&c.layer);
|
||||
if layer.map(|l| l.flags.off || l.flags.frozen).unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
if let Some(frozen) = frozen_layers {
|
||||
if !frozen.is_empty() {
|
||||
if let Some(lh) = layer.map(|l| l.handle) {
|
||||
if frozen.contains(&lh) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.belongs_to_visible_block(c.handle, c.owner_handle, block_handle)
|
||||
}
|
||||
|
||||
fn wires_for_block_culled(
|
||||
&self,
|
||||
block_handle: Handle,
|
||||
@@ -3741,38 +3953,11 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility test reused by both paths below.
|
||||
let visibility_ok = |e: &EntityType| -> bool {
|
||||
let c = e.common();
|
||||
if c.invisible {
|
||||
return false;
|
||||
}
|
||||
// Isolate / Hide: skip entities the user has hidden.
|
||||
if !self.hidden.is_empty() && self.hidden.contains(&c.handle) {
|
||||
return false;
|
||||
}
|
||||
// Block/BlockEnd are block-defn sentinels, not drawable geometry.
|
||||
// Without this skip they fall through to fallback_geometry's `_`
|
||||
// arm and emit a 1-unit phantom segment at world_offset that
|
||||
// poisons fit_all and shows up in selection.
|
||||
if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) {
|
||||
return false;
|
||||
}
|
||||
let layer = self.document.layers.get(&c.layer);
|
||||
if layer.map(|l| l.flags.off || l.flags.frozen).unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
if let Some(frozen) = frozen_layers {
|
||||
if !frozen.is_empty() {
|
||||
if let Some(lh) = layer.map(|l| l.handle) {
|
||||
if frozen.contains(&lh) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.belongs_to_visible_block(e.common().handle, c.owner_handle, block_handle)
|
||||
};
|
||||
// Visibility test reused by both paths below — and by the resident
|
||||
// incremental patch, so a changed entity is included/excluded exactly as
|
||||
// a from-scratch build would (no divergence).
|
||||
let visibility_ok =
|
||||
|e: &EntityType| self.resident_entity_visible(e, block_handle, frozen_layers);
|
||||
|
||||
// Phase 2.1 — quadtree-driven candidate selection. When a view
|
||||
// AABB exists (Model layout with a settled camera), only iterate
|
||||
@@ -4584,6 +4769,61 @@ mod journal_tests {
|
||||
assert!(!indexed(&s).contains(&h2));
|
||||
}
|
||||
|
||||
// Differential oracle for the resident-wire splice: the incrementally
|
||||
// patched resident set must equal a from-scratch rebuild (same wires in the
|
||||
// same draw order) after add / move / erase — and the patch path must run.
|
||||
#[test]
|
||||
fn resident_set_incremental_matches_full_rebuild() {
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64)) -> EntityType {
|
||||
EntityType::Line(Line::from_points(
|
||||
Vector3::new(a.0, a.1, 0.0),
|
||||
Vector3::new(b.0, b.1, 0.0),
|
||||
))
|
||||
}
|
||||
// Wire names, in order — captures entity attribution + draw order + count.
|
||||
fn resident_names(s: &Scene) -> Vec<String> {
|
||||
let cam = Camera::default();
|
||||
let arc = s.model_tile_wires_arc(0, &cam, 1.0, 1.0);
|
||||
let names: Vec<String> = arc.iter().map(|w| w.name.clone()).collect();
|
||||
drop(arc); // release so the next patch can move the wires out
|
||||
names
|
||||
}
|
||||
fn from_scratch(s: &Scene) -> Vec<String> {
|
||||
s.resident_wire_sets.borrow_mut().clear();
|
||||
resident_names(s)
|
||||
}
|
||||
|
||||
let mut s = Scene::new();
|
||||
let _h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0)));
|
||||
let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0)));
|
||||
let _ = resident_names(&s); // prime the resident cache
|
||||
|
||||
let hits0 = s.resident_patch_hits.get();
|
||||
|
||||
let h3 = s.add_entity(line((1.0, 1.0), (2.0, 2.0)));
|
||||
assert_eq!(resident_names(&s), from_scratch(&s), "resident after add");
|
||||
|
||||
{
|
||||
let mut e = line((100.0, 100.0), (140.0, 90.0));
|
||||
e.common_mut().handle = h3;
|
||||
s.update_entity(e);
|
||||
}
|
||||
assert_eq!(resident_names(&s), from_scratch(&s), "resident after modify");
|
||||
|
||||
s.erase_entities(&[h2]);
|
||||
assert_eq!(resident_names(&s), from_scratch(&s), "resident after erase");
|
||||
|
||||
assert_eq!(
|
||||
s.resident_patch_hits.get(),
|
||||
hits0 + 3,
|
||||
"every one of the add / modify / erase edits must use the incremental \
|
||||
patch, not silently fall back to a full rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_gate_keeps_unrelated_caches_warm() {
|
||||
let mut s = Scene::new();
|
||||
|
||||
Reference in new issue
Block a user