git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit276382d87fe4b12fcaa37a58933abcb94e5684ad
parent0755823265
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:09
perf(replay): budget checkpoints by size, and build the wire edges alone

Checkpoints were budgeted as a count, every frame charged at the size of
the latest: a simulation growing to 57k points was thinned to every
fourth frame at a fraction of the budget, and replaying the cached
frames re-solved three in four (690 ms a frame). They are budgeted by
the sum of their sizes now: all 240 frames kept, the replay 34 ms.

The wireframe built a whole topology every frame for its edge list.
Detail::edge_list builds the edges alone, sorted by bucket (the same
list in the same order), and point_colors reads Cd once for the wire
pass and the fill. A replayed frame's CPU half at 57k points: 20 ms to 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |  32 +++++++++++++++-
 src/detail.rs   | 115 +++++++++++++++++++++++++++++++++++++++++++++-----------
 src/geometry.rs |  72 +++++++++++++++++++++++++++++++----
 src/main.rs     |  61 ++++++++++++++++++++++++++++++
 src/render.rs   |  14 +++++--
 5 files changed, 261 insertions(+), 33 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 03f6085..c7af123 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2245,7 +2245,17 @@ need.
   hand does not (the next section).
 - **Within a count and a budget** (`CHECKPOINTS_MAX` 1024,
   `CHECKPOINT_BUDGET` 2 GB per simnet by an estimate of a state's size;
-  48 and 512 MB until every frame was kept). With no
+  48 and 512 MB until every frame was kept). **The budget is the SUM of
+  what the kept checkpoints hold, each at its own size** (since
+  2026-10-07, `Checkpoints::bytes`); it was the budget over the size of
+  the checkpoint just kept, which charged every frame of a growing
+  simulation at the size of the latest. On the user's project, a surface
+  growing from 162 points to 57k over 240 frames, that thinned the whole
+  history to every fourth frame while holding about 0.4 GB, and a replay
+  of the cached frames 120–240 re-solved three in four at 690 ms a frame;
+  counted by size, all 240 are kept at 1.5 GB (1.37 GB resident in a
+  shadow session) and the replay runs no step at 34 ms a frame.
+  `checkpoints_are_budgeted_by_what_each_holds`. With no
   room the SPACING doubles and stays doubled — what is off the wider
   interval goes, and what arrives after arrives that far apart. Not the
   oldest: a scrub is as likely to land near the start. And not every
@@ -3459,6 +3469,26 @@ fix above, a refresh of the table on a selected simnet at 11k points went
 from about 9 ms to 1.1 (evaluation 0.37, the columns 0.7, the widget
 0.01); the cells read as they did (`{:.4}`, the same strings).
 
+**A replayed frame, profiled whole** (2026-10-07, the user's project,
+point markers, wireframe and two visualizers on, frames 120–240 replayed
+from the cache): the CPU half was 11 ms a frame at 33k points and 20 at
+57k, the wireframe's edge list most of it — every frame's scene is a
+fresh copy, whose topology is not kept, so the wire pass built a whole
+topology (point→prims, neighbours and edges, the edges by one sort of
+every edge) to draw its edges, and looked each end's colour up by name.
+`Detail::edge_list` builds the edges alone unless the topology is
+already built, `unique_edges` sorts them by bucket rather than as one
+list (the same edges in the same order,
+`unique_edges_match_a_sort_of_every_edge`; it also builds every
+topology's edges, so the solvers' too), and `Detail::point_colors` reads
+the colour column once, for the wire pass and `triangulate`'s fill. The
+CPU half is now 6 ms at 33k and 13 at 57k: the wire edges 4, the graph's
+evaluation (the simnet's cached state copied out) 2.5, the fill 2, the
+point markers' instances 1.6, the 2D frame 1.5. In a shadow session the
+markers cost nothing on the GPU, being instanced; frames are paced to
+the display, so a frame of 8 ms CPU shows on the next 16.7 ms, and one
+over shows on the one after.
+
 **The markers are instanced** (the same day, cce-ui's
 `SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
 Markers, the selected group's, the marked groups' and the spreadsheet
diff --git a/src/detail.rs b/src/detail.rs
index b3deaa4..6322116 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -926,27 +926,7 @@ impl Topology {
             }
         }
 
-        // Edges: every consecutive pair around each primitive, closing the
-        // loop. A two-point primitive (an open line segment) contributes one
-        // edge, not two — closing it would invent a neighbour.
-        let mut edges: Vec<[u32; 2]> = Vec::new();
-        for prim in 0..num_prims {
-            let pts = &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize];
-            let n = pts.len();
-            if n < 2 {
-                continue;
-            }
-            let span = if n == 2 { 1 } else { n };
-            for i in 0..span {
-                let (a, b) = (pts[i], pts[(i + 1) % n]);
-                if a == b {
-                    continue;
-                }
-                edges.push([a.min(b), a.max(b)]);
-            }
-        }
-        edges.sort_unstable();
-        edges.dedup();
+        let edges = unique_edges(num_points, vert_point, prim_start);
 
         // point -> neighbours, from the deduplicated edge list. Both endpoints
         // of every edge, counting-sorted the same way.
@@ -988,6 +968,71 @@ impl Topology {
     }
 }
 
+/// The mesh's edges, each once as `[low, high]`, in ascending order: every
+/// consecutive pair around each primitive, closing the loop. A two-point
+/// primitive (an open line segment) contributes one edge, not two — closing
+/// it would invent a neighbour.
+///
+/// Sorted by bucket rather than as one list: the edges are counted into
+/// their low point's bucket and each bucket, a handful long, is sorted and
+/// deduplicated on its own — the order a sort of the whole list gives, and
+/// the same list (`unique_edges_match_a_sort_of_every_edge`). Until
+/// 2026-10-07 it was that whole sort, two thirds of building a 57k-point
+/// mesh's topology, and a playing simulation's wireframe built one every
+/// frame. A primitive naming a point past the end (a hand-built mesh) falls
+/// back to the whole sort.
+fn unique_edges(num_points: usize, vert_point: &[u32], prim_start: &[u32]) -> Vec<[u32; 2]> {
+    let num_prims = prim_start.len().saturating_sub(1);
+    let mut all: Vec<[u32; 2]> = Vec::with_capacity(vert_point.len());
+    for prim in 0..num_prims {
+        let pts = &vert_point[prim_start[prim] as usize..prim_start[prim + 1] as usize];
+        let n = pts.len();
+        if n < 2 {
+            continue;
+        }
+        let span = if n == 2 { 1 } else { n };
+        for i in 0..span {
+            let (a, b) = (pts[i], pts[(i + 1) % n]);
+            if a == b {
+                continue;
+            }
+            all.push([a.min(b), a.max(b)]);
+        }
+    }
+    if all.iter().any(|e| e[0] as usize >= num_points) {
+        all.sort_unstable();
+        all.dedup();
+        return all;
+    }
+    let mut start = vec![0u32; num_points + 1];
+    for e in &all {
+        start[e[0] as usize + 1] += 1;
+    }
+    for p in 0..num_points {
+        start[p + 1] += start[p];
+    }
+    let mut cursor = start.clone();
+    let mut highs = vec![0u32; all.len()];
+    for e in &all {
+        let at = &mut cursor[e[0] as usize];
+        highs[*at as usize] = e[1];
+        *at += 1;
+    }
+    let mut edges = Vec::with_capacity(all.len() / 2 + 1);
+    for a in 0..num_points {
+        let bucket = &mut highs[start[a] as usize..start[a + 1] as usize];
+        bucket.sort_unstable();
+        let mut last = None;
+        for &b in bucket.iter() {
+            if last != Some(b) {
+                edges.push([a as u32, b]);
+                last = Some(b);
+            }
+        }
+    }
+    edges
+}
+
 /// Points, vertices, primitives and detail — one piece of geometry.
 ///
 /// See the module docs. Position and [`PointId`] get dedicated fields rather
@@ -1304,6 +1349,31 @@ impl Detail {
         self.topology().edges()
     }
 
+    /// The edges, as [`Detail::edges`] lists them, without building the
+    /// rest of the topology when it is not already built: what the wire
+    /// pass needs, every frame of a playing simulation, of a scene nothing
+    /// else asks the topology of.
+    pub fn edge_list(&self) -> std::borrow::Cow<'_, [[u32; 2]]> {
+        match self.topo.get() {
+            Some(topo) => std::borrow::Cow::Borrowed(topo.edges()),
+            None => std::borrow::Cow::Owned(unique_edges(self.num_points(), &self.vert_point, &self.prim_start)),
+        }
+    }
+
+    /// Every point's colour as [`Detail::color`] reads it, the column found
+    /// once — for the readers that walk every point or corner, where a
+    /// lookup by name each time was most of what they cost.
+    pub fn point_colors(&self) -> std::borrow::Cow<'_, [[f32; 3]]> {
+        let n = self.num_points();
+        match self.points.get(CD) {
+            Some(AttribData::Float3(c)) => std::borrow::Cow::Borrowed(c),
+            Some(other) => std::borrow::Cow::Owned(
+                (0..n).map(|p| other.get(p).map_or(DEFAULT_COLOR, |v| v.as_vec3().to_array())).collect(),
+            ),
+            None => std::borrow::Cow::Owned(vec![DEFAULT_COLOR; n]),
+        }
+    }
+
     /// Drop the derived topology. Called by every structural edit; public
     /// because an operator writing `vert_point` through a future bulk path
     /// must be able to say so.
@@ -1797,6 +1867,7 @@ impl Detail {
     /// renderer's vertex type so that this module stays free of anything that
     /// draws — the caller in `geometry.rs` supplies `Vertex3D`.
     pub fn triangulate<V>(&self, mut make: impl FnMut([f32; 3], [f32; 3]) -> V) -> Vec<V> {
+        let colors = self.point_colors();
         let mut out = Vec::new();
         for prim in 0..self.num_prims() {
             let pts = self.prim_points(prim);
@@ -1808,7 +1879,7 @@ impl Detail {
                     let p = p as usize;
                     out.push(make(
                         self.pos.get(p).copied().unwrap_or([0.0; 3]),
-                        self.color(p),
+                        colors.get(p).copied().unwrap_or(DEFAULT_COLOR),
                     ));
                 }
             }
diff --git a/src/geometry.rs b/src/geometry.rs
index b71a60b..fd0bdcf 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1180,16 +1180,17 @@ fn checkpoint_bytes(state: &Detail) -> usize {
     2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256)
 }
 
-/// One solve's checkpoints, in frame order, and how far apart they are
-/// being kept.
+/// One solve's checkpoints, in frame order, how far apart they are being
+/// kept, and what they hold by [`checkpoint_bytes`], summed.
 struct Checkpoints {
     every: i32,
     kept: Vec<Checkpoint>,
+    bytes: usize,
 }
 
 impl Default for Checkpoints {
     fn default() -> Self {
-        Checkpoints { every: CHECKPOINT_EVERY, kept: Vec::new() }
+        Checkpoints { every: CHECKPOINT_EVERY, kept: Vec::new(), bytes: 0 }
     }
 }
 
@@ -1202,17 +1203,35 @@ impl Checkpoints {
     /// arriving at the old spacing thinned the start again and again — a
     /// scrub is as likely to land there as anywhere, and an even spacing
     /// is what bounds the steps from anywhere.
+    ///
+    /// The budget is the SUM of what the kept checkpoints hold, each by its
+    /// own size. Until 2026-10-07 it was a count — the budget over the size
+    /// of the checkpoint just kept — which charged every frame of a growing
+    /// simulation at the size of the latest: on the user's project, whose
+    /// surface grows from 162 points to 57k over 240 frames, it thinned the
+    /// whole history to every fourth frame while holding about 0.4 GB by
+    /// this estimate, and a replay stepped three frames in four again.
+    /// Counted by size, all 240 frames are kept at 1.5 GB.
     fn keep(&mut self, at: Checkpoint) {
-        let each = checkpoint_bytes(&at.state).max(1);
+        self.keep_within(at, CHECKPOINT_BUDGET, CHECKPOINTS_MAX);
+    }
+
+    /// [`keep`](Self::keep) under a given budget and count, for the tests.
+    fn keep_within(&mut self, at: Checkpoint, budget: usize, max: usize) {
+        let each = checkpoint_bytes(&at.state);
         match self.kept.binary_search_by_key(&at.frame, |c| c.frame) {
-            Ok(i) => self.kept[i] = at,
+            Ok(i) => {
+                self.bytes -= checkpoint_bytes(&self.kept[i].state);
+                self.kept[i] = at;
+            }
             Err(i) => self.kept.insert(i, at),
         }
-        let room = (CHECKPOINT_BUDGET / each).clamp(2, CHECKPOINTS_MAX);
-        while self.kept.len() > room {
+        self.bytes += each;
+        while self.kept.len() > max || (self.bytes > budget && self.kept.len() > 2) {
             self.every = self.every.saturating_mul(2);
             let every = self.every;
             self.kept.retain(|c| c.frame % every == 0);
+            self.bytes = self.kept.iter().map(|c| checkpoint_bytes(&c.state)).sum();
         }
     }
 
@@ -1274,6 +1293,11 @@ impl SimCache {
             .collect()
     }
 
+    /// What simnet `id`'s checkpoints hold, by [`checkpoint_bytes`].
+    pub fn checkpoint_bytes_held(&self, id: &str) -> usize {
+        self.entries.get(id).map_or(0, |e| e.checkpoints.bytes)
+    }
+
     /// The frames simnet `id` has checkpoints at, ascending — relative to
     /// its start frame, as the solve counts them.
     pub fn checkpoint_frames(&self, id: &str) -> Vec<i32> {
@@ -7741,6 +7765,40 @@ mod simnet_tests {
         }
     }
 
+    /// Checkpoints are kept within a budget of what they HOLD, each counted
+    /// at its own size: a history whose states grow fits where charging
+    /// every frame at the size of the latest would have thinned it, and a
+    /// budget too small for it thins the spacing and stays within it.
+    #[test]
+    fn checkpoints_are_budgeted_by_what_each_holds() {
+        // Frame f's state has 10 f points: the history grows as a solve's does.
+        let state = |f: i32| sphere_detail(Vec3::ZERO, 1.0, 2, 5 * f as usize);
+        let at = |f: i32| Checkpoint { frame: f, state: state(f), prev: Detail::default() };
+        let all: usize = (1..=60).map(|f| checkpoint_bytes(&state(f))).sum();
+        let latest = checkpoint_bytes(&state(60));
+        assert!(60 * latest > all * 3 / 2, "the latest times the count overstates the history");
+
+        let mut kept = Checkpoints::default();
+        for f in 1..=60 {
+            kept.keep_within(at(f), all, 1024);
+        }
+        assert_eq!(kept.kept.len(), 60, "every frame fits a budget of what they hold");
+        assert_eq!(kept.bytes, all);
+
+        let mut thinned = Checkpoints::default();
+        for f in 1..=60 {
+            // Offered as a solve offers them: on the spacing in force.
+            if f % thinned.every != 0 {
+                continue;
+            }
+            thinned.keep_within(at(f), all / 3, 1024);
+            assert!(thinned.bytes <= all / 3, "within the budget at frame {f}");
+            assert_eq!(thinned.bytes, thinned.kept.iter().map(|c| checkpoint_bytes(&c.state)).sum::<usize>());
+        }
+        assert!(thinned.every > 1 && thinned.kept.iter().all(|c| c.frame % thinned.every == 0));
+        assert!(thinned.kept.len() > 4, "{} kept", thinned.kept.len());
+    }
+
     /// The Visualize node reads columns and a group mask where it read a
     /// value by name at every point and searched a list of every point for
     /// each one; what it writes is the reference's bit for bit, in every
diff --git a/src/main.rs b/src/main.rs
index e27f8c6..7664c09 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17978,6 +17978,67 @@ mod tests {
         }
     }
 
+    /// The edge list is built by bucket, not by one sort of every edge, and
+    /// without the rest of the topology for the wire pass: the same edges in
+    /// the same order either way — on meshes of triangles, quads and
+    /// polygons, open segments, a primitive that names a point twice, and a
+    /// hand-built one naming a point past the end (which takes the sort).
+    #[test]
+    fn unique_edges_match_a_sort_of_every_edge() {
+        let reference = |d: &crate::detail::Detail| -> Vec<[u32; 2]> {
+            let mut all = Vec::new();
+            for prim in 0..d.num_prims() {
+                let pts = d.prim_points(prim);
+                let n = pts.len();
+                if n < 2 {
+                    continue;
+                }
+                let span = if n == 2 { 1 } else { n };
+                for i in 0..span {
+                    let (a, b) = (pts[i], pts[(i + 1) % n]);
+                    if a != b {
+                        all.push([a.min(b), a.max(b)]);
+                    }
+                }
+            }
+            all.sort_unstable();
+            all.dedup();
+            all
+        };
+        let mut meshes = vec![
+            crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 9, 14),
+            crate::geometry::box_detail(Vec3::ZERO, Vec3::ONE, 0.2),
+        ];
+        // A scramble of triangles, quads, pentagons, segments and a
+        // degenerate polygon over 60 points, numbered out of order.
+        let mut d = crate::detail::Detail::new();
+        for i in 0..60 {
+            d.add_point(Vec3::new(i as f32, (i * 7 % 11) as f32, 0.0));
+        }
+        let mut k = 17u32;
+        for prim in 0..120 {
+            let n = [2, 3, 3, 4, 5][prim % 5];
+            let pts: Vec<u32> = (0..n).map(|_| { k = (k * 31 + 7) % 60; k }).collect();
+            d.add_prim(&pts);
+        }
+        d.add_prim(&[5, 5, 9, 5]);
+        meshes.push(d);
+        let mut past = crate::detail::Detail::new();
+        for i in 0..5 {
+            past.add_point(Vec3::new(i as f32, 0.0, 0.0));
+        }
+        past.add_prim(&[0, 1, 2]);
+        past.add_prim(&[3, 99]);
+        meshes.push(past);
+        for d in &meshes {
+            let want = reference(d);
+            assert_eq!(d.edge_list().into_owned(), want, "built alone");
+            assert!(matches!(d.edge_list(), std::borrow::Cow::Owned(_)), "without building the topology");
+            assert_eq!(d.edges(), want.as_slice(), "built with the topology");
+            assert!(matches!(d.edge_list(), std::borrow::Cow::Borrowed(_)), "and taken from it once built");
+        }
+    }
+
     /// The playbar's cache strip, as a rule: a frame is cached when every
     /// simnet in the tree holds it, stale when one of them holds it from
     /// the chain as it was — before an edit the solve went on across, or
diff --git a/src/render.rs b/src/render.rs
index b84f942..dc992fb 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1848,14 +1848,22 @@ pub(crate) fn scene_element_overlays(
 /// The soup version was what the global Show Wireframe drew until
 /// 2026-09-23, while the per-node meta Wireframe drew this one; with the
 /// per-node flag retired there is one wireframe, and it is this one.
+///
+/// The edges without the rest of the topology (`Detail::edge_list`) and the
+/// colours as one column: a playing simulation's wireframe is built every
+/// frame, and until 2026-10-07 each built a whole topology and looked the
+/// colour up by name at both ends of every edge — the largest part of a
+/// replayed frame at 57k points.
 pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
-    let mut wires = Vec::new();
-    for e in geom.edges() {
+    let edges = geom.edge_list();
+    let colors = geom.point_colors();
+    let mut wires = Vec::with_capacity(edges.len() * 2);
+    for e in edges.iter() {
         for &p in e {
             let p = p as usize;
             wires.push(crate::geometry::Vertex3D {
                 position: geom.positions()[p],
-                color: geom.color(p),
+                color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR),
             });
         }
     }