Skip to content

perf: memoise curve caches in the curve value (outputs bit-identical) - #992

Merged
bherbruck merged 1 commit into
tangency-labs:mainfrom
meros:perf/memo-curve-caches
Oct 25, 2026
Merged

bherbruck merged 1 commit into
tangency-labs:mainfrom
meros:perf/memo-curve-caches

Conversation

@meros

@meros meros commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

On the #948 loft at 20 sections, the port now builds 1,409 curve caches where the C++ builds 1,393 (main: 16,918). Per caller, the counts are the same as the C++ ones. The whole run takes 51 % fewer instructions at 20 sections and 42 % fewer at 40. No output changes, with the memo on or off. This is option C of #982, as refined in the issue's comments. Merging this PR accepts option C of #982. If you prefer another option, comment there and we will adapt this PR. Limit: the remaining gap to the C++ is the cost per build and other work, not the number of builds.

Cause

Upstream builds a curve's cache once and keeps it in SmCacheMgr until the curve is edited (SmCurve::Notify, SM_NO_PRE_EDIT). The port built a new cache for every request, unless one call chain kept it (#539, #753, #951). The Rust makes the same requests per caller as the C++ (22,661 at 20 sections, counted on #982). The C++ answers 94 % of them from its cache, and the port answered almost none.

Change

make_curve_cache (upstream SmCurve::CacheMakeOrValidate) keeps a memo of its result in the Curve value.

  • Where the memo lives. Curve has a private field memo: CurveMemo, an OnceLock<Arc<Mutex<Option<entry>>>>. Clone and the evaluation copies share it. kind_mut and the 5 editing methods that write self.kind start an empty one. Debug prints the memo as a constant.
  • Key. The entry stores every input that the build reads besides the geometry: Kernel::tolerances and the context's scalars (zone tolerance, model size estimate, size ratio, doing_boolean, trim_uv_curves_at_corner_drops). A lookup with another key is a miss. The owner is not an input, because the build works on its own ownerless copy.
  • Which curves. A curve is memoised when it is a pure value (memo_pure): B-spline, line, ellipse, hyperbola, parabola, Hermite and conic curves, and offset, projected and composite curves made only of these. The walk over nested curves uses an explicit stack, bounded by MAX_GEOMETRY_NESTING. Iso curves, curves on surfaces and curves in volumes hold a surface or a volume with cache state of its own (Model lazy surface cache trees: GetDomainWithContinuity depends on whether the cache tree was built #568), so they always build.
  • Handle space. The entry is handle-free (CurveCache<()>). A hit returns a copy in the caller's handle space (CurveCache::retag, Tree::retag_curve_tree). The build only stores the handle, so the copy is what a build with that handle gives. One entry serves the 32 handle types that the port uses for curve caches.
  • Off switch. Kernel::memoize_curve_caches turns the memo off. The boolean's adapter kernels copy it. The new feature curve-memo-off of solidcore makes off the default, so difftest and the tests run in both positions.
  • No cycles. The cache keeps its own copy of the curve with an empty memo (Curve::clone_detached), so a memo never holds itself.
  • Docs. The module headers of curve.curve_p2, curve.curve_p5, curve.curve_cache and geom.tree describe the memo. The finding is in plan/findings/curve.curve_p5--curve-cache-memo.md.

No call site changes. The *_cached APIs still work. Their first call now also hits the memo.

Why the memo cannot change a result

The build takes &Kernel, so it cannot draw from the RNG or record upstream UB. It writes nothing outside the new cache. Its result is a function of the curve's value and the key, and a hit returns that result. So the fill order, the clone that filled the memo and the thread cannot change a result.

  • Memory. There is at most one entry per live curve value. It is freed with the last clone of that curve. There is no count limit like upstream's 1000 (SM_DEFAULT_MAXCOUNT_CURVECACHE).
  • Threads. The change uses no unsafe. Curve is Send and not Sync on main, because CrvOnSurf and the offset surface hold Cell and RefCell. It stays so, and the new test asserts Send for Curve and Brep.
  • wasm. OnceLock and Mutex compile for wasm32-unknown-unknown, as the Surface cache slot of port: face-bound trimmed surface cache and IntersectTSBound's cached branch #697 does.

Test plan

Curve-cache builds per caller. I counted them with gdb, with a breakpoint on every Tessellate (the method on #982), on the 20-section loft. C++ and main are the counts from #982.

Phase Caller C++ main this PR
diff CoincidenceCheck 600 602 600
diff SplitIntersectingProtoEdges 67 4278 67
diff IntersectEdges 30 822 30
diff IntersectVertices 13 268 13
diff ContainsUVPoint 125 568 125
diff FindBoundaryStartPoints 101 159 117
diff FindInteriorCurves 43 55 43
diff EdgePointSolve 0 8 8
diff RadialSector 4 28 4
isect SplitIntersectingProtoEdges 68 4418 68
isect IntersectEdges 41 1294 41
isect ContainsUVPoint 82 3156 82
isect FindBoundaryStartPoints 62 62 62
skin Stitching 68 174 68
all 1393 16918 1409

The two rows that differ from the C++ (FindBoundaryStartPoints in the difference, EdgePointSolve) differ in their requests: the Rust makes requests there that the C++ does not make (#982). The memo is not the cause.

Instructions and time. The #948 skins at 20 and 40 sections, run with examples/toebox_loft.rs (local only, not in this PR). Release builds of main 53fac4fe1 and of this branch, pinned to the 4 performance cores, 3 runs each, interleaved with the C++ (c9c5979). The instruction counts are user space (cpu_core/instructions, whole run) and vary by less than 0.1 %. The machine load was 8 to 11 on 8 cores during the timed runs, so the times are higher than in #951 and #952 and their spread is wide.

main this PR C++
20 sections, instructions (median) 118.0 G 57.3 G (-51 %) 41.7 G
40 sections, instructions (median) 310.9 G 179.1 G (-42 %) 127.8 G
20 sections, outer - inner (median, range) 4.63 s (4.63 to 4.83) 2.52 s (2.51 to 2.57) 1.72 s (1.70 to 1.77)
40 sections, outer - inner (median, range) 14.54 s (13.68 to 14.78) 10.04 s (8.69 to 10.17) 5.99 s (4.73 to 6.03)
  • Every non-timing output line (face, shell and edge counts, manifold flag, volumes and bounding boxes at full precision) is identical in all 12 Rust runs. There is one output per section count. 20 sections give 50 faces and volume 311598.64275730244. 40 sections give 95 faces and volume 312587.4711458608.
  • The remaining gap to the C++ is 1.37x in instructions at 20 sections and 1.40x at 40.

Bit identity.

  • New test tests/curve_cache_memo.rs. It runs 6 booleans: 4 of primitives, and 2 of lofted skins (skin - skin, skin ∩ box). Each runs with the memo off, then on, then a second time on the same kernel. The test compares the {:?} text of the result B-rep, its volume and upstream_ub_passed, so every value is compared bit for bit. It also asserts that every case gives a result. It passes with the default features and with curve-memo-off.
  • New unit tests in curve::curve::p5. A hit, a miss and a fresh build give the same cache, for a B-spline, a line segment and a Hermite curve, and for a clone in another handle space. An edit of one clone empties only its memo. Other tolerances do not match a stored key.

Checks.

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo clippy -p solidcore --all-targets --features curve-memo-off -- -D warnings and cargo check -p solidcore --target wasm32-unknown-unknown pass.
  • cargo test -p solidcore passes: 2,221 tests, 0 failed.
  • difftest --unit in both positions, with --features cpp and with --features cpp,solidcore/curve-memo-off. 31 units each, 0 failed, max_rel_err=0e0 in each. The summary lines are the same in both positions:
    • curve.curve_cache (642 cases), curve.curve_p1, curve.curve_p2, curve.curve_p5 (3097), curve.curve_p6 (3138), curve.curve_p8 (3068), curve.composite_curve_p2 (676);
    • geom.tree (639), surface.surface_p8 (2108), topology.face_p3 (1781), topology.proto_topology_p3 (3013);
    • query.face_p2, query.loop, query.edge;
    • intersect.curve_class_p5, _p7, _p9, intersect.point_class (2520), intersect.surface_intersector_p1, _p3, intersect.adv_surface_intersector (1931);
    • heal.stitch_p1, boolean.api_brep, boolean.merge_p1 to _p5, boolean.topology_intersector_p1 to _p3.
    • boolean.topology_intersector_p1 keeps its 3 deferred cases. The rejected counts (boolean.api_brep 8, boolean.merge_p4 3, intersect.curve_class_p9 2) are the same in both positions, and the first two are the same as in the perf: keep curve caches across repeated solves on the same curve (outputs bit-identical) #951 difftest run.

Audit

  • Copies. 123 clone_for_evaluation call sites and every Curve::clone now share the memo. A curve made anew from the same geometry (Curve::new / Curve::from, 767 sites) starts with an empty memo. On this input those sites cost no extra builds, because the per-caller counts equal the C++ counts.
  • Handle O. There are 32 handle types for curve caches. All of them use the one handle-free entry. A hit copies the spans and the tree (CurveCache::retag), which was 0.6 % of the samples at 40 sections in a perf profile of the first version of this branch.
  • Other builders. Two builders outside make_curve_cache use options of their own (curve::curve::p5 line 1631, surface::trim_srf_cache), so they are not memoised. They are inside the counts above.

Not covered

Related

make_curve_cache (SmCurve::CacheMakeOrValidate) keeps a memo of its
result in the Curve value, as upstream keeps the cache in SmCacheMgr.
Clones and evaluation copies share the memo, and every &mut edit of
the geometry starts an empty one. The entry is keyed by the kernel
state the build reads (tolerances and the context scalars). Every curve
that is a pure value is memoised: B-spline, line, ellipse, hyperbola,
parabola, Hermite and conic curves, and offset, projected and composite
curves made only of these. A hit returns the cache a fresh build gives,
copied into the caller's handle space.

Kernel::memoize_curve_caches turns the memo off; the curve-memo-off
feature makes that the default, so difftest runs in both positions.
tests/curve_cache_memo.rs runs booleans with the memo on and off and
compares every output bit for bit. The finding is in
plan/findings/curve.curve_p5--curve-cache-memo.md.

tangency-labs#948 loft, 20 sections: 1409 curve-cache builds (C++ 1393, main 16918).

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

@bherbruck bherbruck left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved by the automated first-pass review: CI green, mergeable, no CI/build/dependency file changes, no red-flag patterns in added code (process/network/env/unsafe outside FFI/blobs/hidden unicode), 0 known-mismatch entries added. Logic is gated by CI and the difftest merge queue.

@bherbruck
bherbruck added this pull request to the merge queue Oct 25, 2026
Merged via the queue into tangency-labs:main with commit cf4dd47 Oct 25, 2026
13 checks passed
brett19 pushed a commit to brett19/solidcore-fork that referenced this pull request Oct 25, 2026
…AdvSurfaceIntersector (outputs bit-identical)

SmAdvSurfaceIntersector::BranchIsGaussSeparate reads the polar box of a
tree node and builds it lazily when it is missing (GetPolarBox). The
port shares the surface caches, so it copied the whole surface cache
(tree, patches, aux data) to build the box on the copy, and dropped the
copy. SurfaceCache::polar_box_unstored builds the same box from a shared
reference without storing it, as surface p8, p10, p11 and the silhouette
code already do. It gives the box polar_box gives on a copy: the same
chain from the nearest ancestor with a box, the same guesses, and the
stored box on a failure.

On the lofted solids of tangency-labs#948, on top of the curve-cache memo (tangency-labs#992) and
tangency-labs#1006 and tangency-labs#993, this saves 1.8 % of the instructions at 20 sections and
2.0 % at 40, and 1.1 % of the CPU cycles at both (median of 3 runs). On
main alone the change is below the noise. Results are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants