Skip to content

Design question: where should edge-curve caches live, so that a boolean builds each curve cache once? #982

Closed

Description

@meros

Suggested labels: enhancement, question.

A question for the maintainers before we write more _cached plumbing. After #951 and #952, outer - inner of the #948 lofts at 40 sections takes 11.4 s in solidcore and 3.4 s in the C++ (measured, median of 3, release builds). Most of the remaining gap is curve caches that the port builds again on each call, where upstream builds them once and keeps them with the curve. We propose to keep them on the Curve value, as #697 already does for surfaces (option C below). Which ownership do you want?

What happens

Profile of the 40-section run with #951 and #952 applied (perf, frame pointers; shares of all samples, measured):

Caller Share Curves
curve_on_classify, its edge intersections 16 % B-rep edge curves
ProtoTopology::split_intersecting_proto_edges 7 % proto-edge curves (imprint and merge)
CoincidenceCheck boundary curves 22 % surface boundaries; same build count as upstream, out of scope here

#951 also names FindBoundaryStartPoints and Loop::ContainsUVPoint. With the first two rows, these took 17 % (40 sections) to 28 % (20 sections) of all samples, measured with #951 only, before #952.

Cause

Upstream keeps one SmCurveCache per curve in the context's global cache (SmCacheMgr::GetOrCreateObjectCache, SmCacheMgr.cpp:737):

  • The first solve on a curve builds the cache. Every later solve on that curve object uses it.
  • SmCurve::Notify deletes it on SM_NO_PRE_EDIT and SM_NO_DESTRUCTION (SmCurve.cpp:19354).
  • The queue holds at most SM_DEFAULT_MAXCOUNT_CURVECACHE = 1000 curve caches (SmConfig.h:91). Eviction changes only speed, because a rebuild gives the same cache.

The port has no global state, so a cache lives only as long as the caller keeps it (global_point_solve_cached, global_curve_intersect_cached, CurveOnClassifyCache, find_boundary_start_points_with_caches). kernel::Caches (kernel.rs, "upstream SmCacheMgr*") is still empty. Each fix so far (#539, #753, #951, #952) keeps caches for one caller. The callers above solve on curves that the B-reps and the proto topology own, across many calls, so a per-caller fix does not reach them.

Requirement: the cache must be invisible

A cache that only memoizes a deterministic function of immutable geometry is not state in the sense of the "no global state" goal: with or without it, every output is bit-identical, and no result depends on what was solved before. Whatever option is chosen, we propose to make that a tested property:

  • a switch (feature or test setting) that turns every kept curve cache off;
  • a test that runs representative operations both ways and asserts bit-identical outputs, and difftest runs with the switch in both positions;
  • no dependence on solve order, thread or clone.

Options

All three keep the goals: no unsafe, no global state, deterministic, wasm32. A kept curve cache is the cache a fresh build makes (make_curve_cache has one option set), so outputs stay bit-identical in each option, as in #951.

A. Map on the Brep B. Per-operation context C. Slot on the Curve
Where it lives Brep field, BTreeMap<EdgeId, CurveCache> a CurveCaches map that the boolean owns (as ArenaPair owns FaceCaches in #952), lent down Curve field, Arc<Mutex<..>> slot, as Surface's CacheSlot (#697)
Invalidation drop an edge's entry at Brep::edge_mut and edge removal empty all maps at any mutable lend of a B-rep (the #952 rule) clear at Curve::kind_mut and every &mut geometry edit (= SM_NO_PRE_EDIT); dropped with the curve
Against upstream stricter: any edge_mut drops it (upstream: a curve edit only) stricter: any edit drops all the same rule as upstream
API impact new Brep field; the edge solve sites read it (&Brep needs interior mutability to fill it) a &mut CurveCaches parameter through curve_on_classify, split_intersecting_proto_edges, the boundary start points and ContainsUVPoint none at the about 90 solve sites; CurveCache must not store the caller's handle O, or must take it at solve time
Covers B-rep edges only (16 % row) the call chains it is threaded through every solve on a curve object (both rows, other callers too)
Gain (estimate) up to 1.8 s of 11.4 s up to 2.6 s, less in imprint and merge, where edits empty the maps up to 2.6 s
Main risk 185 edge_mut calls in 48 files drop caches that are still valid the most signature changes; gains stop at each edit memory; solve sites that pass Curve::clone get an empty slot

Notes:

  • Estimates. "Up to" assumes run time scales with the sample share, and that every build in that row goes away. Upstream also builds each cache once, so the true gain is smaller. If one option removes all of the 23 %, the 40-section difference is about 8.8 s, still 2.6x the C++. The CoincidenceCheck work is separate.
  • A. split_intersecting_proto_edges solves on proto-edge curves, which the Brep map does not hold. A also adds a second edit path (edge_mut) that must stay in step with the curve.
  • B. This continues perf: keep face surface caches across the face pairs of a boolean (outputs bit-identical) #952 with the least new design. It does not help callers outside the boolean, such as offset, blend and healing.
  • C. Clone gives an empty slot (upstream's copy constructor leaves m_pCacheObj null), and clone_for_evaluation shares the slot, as for surfaces. The cache holds its own Curve copy, whose slot is empty, so a slot never owns itself. Memory has no bound like upstream's 1000. It grows with the live curves that have had a solve, and a Kernel::caches.clear() cannot reach it. The cache build reads k.tolerances, so a curve that is solved under two kernels needs the tolerances in the key, or a check.
  • Kernel::caches. AGENTS.md names Kernel as the home for caches. A map there needs a curve identity (notify_identity) and an edit notification. Curve::kind_mut has no &Kernel to send one, so a kept cache can go stale. For this reason we did not list it as an option.

Question

  1. Is C acceptable, given the precedent of the Surface slot? If not, do you prefer B, or an A/B mix (B for the boolean, later A)?
  2. For C: is an unbounded per-curve slot acceptable, or do you want a size or count bound, and where?
  3. For C: may CurveCache stop holding the caller's handle O (the tree owner), so that one cached tree serves every handle space? Or do you prefer one slot for each handle space, as TrimCacheSlot keeps EntityRef?

Not measured

  • A draft PR with a prototype of option C and this switch follows, so the options can be compared on measured numbers. It is a proposal for this question, not a decision.

  • The gains in the table are estimates from profile shares. We did not build any of the options.

  • We did not count the cache builds in the C++ for these callers.

  • We did not audit how many solve sites pass a plain Curve::clone (C) or reach the curve through edge_mut (A).

Related

Activity

  1. meros commented on Oct 25, 2026

    @meros
    ContributorAuthor

    Measured since filing: exact curve-cache build counts in both kernels, so the "Not measured" item on C++ build counts is now answered.

    Method: 20-section case of #948, solidcore main 4fe6f4d51 (with #951 and #952), pinned C++ reference. Every SmCurveCache::Tessellate (C++) and every CurveCache::tessellate (Rust, all 32 monomorphs) was caught with a gdb breakpoint and a backtrace, and classified by caller. These are exact counts, not samples. diff = outer - inner, isect = hollow ∩ box.

    Caller C++ diff Rust diff C++ isect Rust isect
    SplitIntersectingProtoEdges 67 4278 68 4418
    IntersectEdges (curve_on_classify) 30 822 41 1294
    ContainsUVPoint 125 568 82 3156
    FindBoundaryStartPoints 101 159 62 62
    FindInteriorCurves 43 55 40 64
    CoincidenceCheck 600 602 0 0
    other GlobalPointSolve 36 451 27 743
    skin stitching (DoStitching) 68 174 – –
    All builds, whole run 1393 16886

    Two corrections to the issue text:

    • CoincidenceCheck is not part of the gap. It builds the same number of caches (602 against 600), and one build costs about the same: 1.96 ms against 1.78 ms (perf, cycles). Its leaf profile matches routine for routine (basis_derivs_into 375 against sm_BasisDerivs 343 samples), so the port builds no deeper tree and uses no other tolerance.
    • The remaining gap is the number of builds on edge and proto-edge curves: 27x to 64x more in curve_on_classify and SplitIntersectingProtoEdges, and 5x to 38x in ContainsUVPoint. That supports option C (or any option that reaches proto-edge curves), because upstream builds each of these once and keeps it on the curve.

    The per-build comparison is from 20 sections only; the 40-section counts were not taken.

  2. meros commented on Oct 25, 2026

    @meros
    ContributorAuthor

    Refinement of option C (the slot on the Curve), worked out while starting the prototype. In short: make it a pure memo of make_curve_cache, keyed by every input of that function, so that it cannot change a result.

    Changes against the option C row above:

    • Key: the entry also stores the two kernel inputs of the build (the tolerances and the context's approximate tolerance), and a lookup compares them. This answers the "solved under two kernels" note.
    • Scope: only curve kinds whose build reads nothing outside the value (B-spline, line, ellipse, hyperbola, parabola), and only unowned curves.
    • Clone: Curve::clone shares the memo instead of starting empty, because the copy has the same geometry. An edit to either copy replaces only that copy's memo.
    • Invalidation: every &mut path to the geometry (kind_mut, set_owner, the 5 match &mut self.kind in curve/curve/p2.rs) replaces the memo with an empty one.

    The principle: a cache that nobody can observe

    A cache is global state only if a result can depend on it. make_curve_cache (upstream SmCurve::CacheMakeOrValidate) is a deterministic function of:

    • the curve's geometry (CurveKind: control points, knots, weights);
    • the curve's owner (an owned curve fails with Unported today, curve_cache.rs, curve_approx_tol_3d);
    • two values that it reads from the &Kernel: the tolerances and the context's approximate tolerance.

    It takes &Kernel, so it cannot draw from the RNG or write upstream_ub_passed, and Context has no interior mutability. #951 already relies on this: a kept cache is the cache that a fresh build makes. A memo of this function, keyed by all of its inputs, cannot change a result. It can change only the time.

    Conditions for an invisible memo, and how to test them

    1. Hit and miss are bit-identical. A switch disables the memo (a Kernel field, read through &Kernel). A test runs representative operations with the memo on and off and compares every output bit by bit. The difftest of the touched units must keep max_rel_err=0e0. The SMLib boolean: difference of two capped ruled skins fails with SM_ERR (1001), no radial sector found (C++ and Rust alike) #948 loft outputs must be identical to main.
    2. No dependence on order. The entry is a function of its key only, so the order of builds cannot matter. A lookup with a different key rebuilds and gives the same result as a miss. The test above covers this, because the memo-on run fills entries in a different order from the memo-off run.
    3. The key is complete. Only curve kinds whose build reads nothing outside the value are memoized: B-spline, line, ellipse, hyperbola and parabola. Curves on surfaces, offset, projected, iso and composite curves hold surfaces or other curves with their own cache state (Model lazy surface cache trees: GetDomainWithContinuity depends on whether the cache tree was built #568), so they are excluded. An owned curve is not memoized.
    4. Clone. Curve::clone shares the memo: the copy has the same geometry, so the entry is valid for it. An edit to either copy replaces only that copy's memo. The cache keeps its own copy of the curve without a memo, so a memo never holds itself.
    5. Bounded memory. There is at most one entry per distinct curve value. The entry is freed with the last copy of the curve. There is no growth outside the live geometry, but there is no count bound like upstream's 1000.
    6. Threads. Arc<Mutex<..>> is Send + Sync and needs no unsafe. It compiles for wasm32-unknown-unknown, as the Surface cache slot of port: face-bound trimmed surface cache and IntersectTSBound's cached branch #697 does. Curve stays Send and Sync. Two threads that fill one entry build the same value, so the race decides only who builds it.

    Where it does not fit

    • CurveCache<O> stores the caller's handle as the tree owner. A shared entry must be handle-neutral, and a hit must give a copy with the caller's handle. The copy costs a clone of the spans, which is less than a build. We did not measure how much less.
    • CoincidenceCheck solves on boundary curves made for each check, so a memo on the curve value does not reach them. That work stays separate.
    • The memo does not remove the *_cached APIs. They still save the clone that a hit costs.

    Updated questions (replacing 2 and 3 above):

    • Is "one entry per live curve value, freed with the curve" an acceptable memory bound, or do you want a count bound?
    • Should the switch that turns off the memo be public (KernelOptions) or test-only?

    The prototype PR follows and will report build counts per caller against the C++ counts in the comment above.

  3. meros commented on Oct 25, 2026

    @meros
    ContributorAuthor

    Cache requests and hits, next to the build counts above. This tests whether the port solves more often than upstream, or only rebuilds more often.

    Method: 20-section case of #948, main d4940b63e, exact gdb counts. C++: every SmCacheMgr::GetOrCreateObjectCache with SM_OC_CURVE (a request) and every SmCurveCache::Tessellate (a build). Rust: the request sites (surface/surface/p8.rs:409, :412, curve/curve/p1.rs:1673) and every CurveCache tessellate.

    Phase Caller Requests C++ Requests Rust Builds C++ Builds Rust Hits C++
    diff SplitIntersectingProtoEdges 4278 4278 67 4278 4211
    diff CoincidenceCheck 2424 2424 600 602 1824
    diff FindInteriorCurves 2128 2128 43 55 2085
    diff IntersectEdges (curve_on_classify) 1162 ≥ 822 30 822 1132
    diff ContainsUVPoint 568 568 125 568 443
    diff IntersectVertices 268 268 13 268 255
    isect SplitIntersectingProtoEdges 4418 4418 68 4418 4350
    isect ContainsUVPoint 3156 3156 82 3156 3074
    all 22661 ≈ 22.6k 1393 16918 21268

    (≥: some Rust sites build without passing the instrumented request lines, so the builds are the lower bound.)

    • Where both kernels are instrumented, the request counts are equal. The port has no extra solve loops. The whole difference is get-or-create (upstream) against build-per-call (port). Upstream answers 94 % of its requests from the cache.
    • Time, whole 20-section run (perf cycles): the gap is 4.95 s (7.95 s against 3.0 s CPU). Curve-cache builds account for 3.4 to 3.7 s of it (68 to 75 %; the range comes from 11 % truncated C++ stacks). Per build the cost is equal (round 2 above).
    • So a get-or-create curve cache, as this issue proposes, addresses about three quarters of the gap. The rest is about 1.3 s: surface copies per evaluation, the surface-cache copy per fetch, and allocations in the volume integration. Those are separate small items.
  4. meros commented on Oct 25, 2026

    @meros
    ContributorAuthor

    Prototype of option C (as refined above): #992 (draft, waiting for the decision here).

    Measured on the #948 lofts, whole run, instruction counts (vary by less than 0.2 %):

    main #992 C++
    20 sections 118.0 G 58.2 G (-51 %) 41.7 G
    40 sections 311.5 G 182.2 G (-41 %) 127.8 G

    All outputs are identical, and a new test runs 6 booleans with the memo off and on and compares every value bit for bit. Details and the remaining hotspots are in #992.

    Two corrections to the refinement comment above, found while building it:

    • Threads: Curve is Send but not Sync on main (CrvOnSurf and the offset surface hold Cell and RefCell). The memo keeps it Send and not Sync; the claim "Curve stays Send and Sync" was wrong.
    • Owner: the owner is not an input of the build, because the build works on an ownerless copy. So owned curves are memoised too, and the scope condition "only unowned curves" is dropped. That roughly doubled the gain.
  5. meros commented on Oct 25, 2026

    @meros
    ContributorAuthor

    Answered by merging #992 (option C: the curve-cache memo in the curve value, with Kernel::memoize_curve_caches and the curve-memo-off feature). Follow-ups: #1031 (boundary curves kept per surface, stacked on #992) and #1032 (handle-agnostic caches).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions