Skip to content

perf: copy-on-write NURB storage, so evaluation copies share the control net (outputs bit-identical) - #1088

Merged
bherbruck merged 1 commit into
tangency-labs:mainfrom
meros:perf/nurb-cow
Oct 26, 2026
Merged

bherbruck merged 1 commit into
tangency-labs:mainfrom
meros:perf/nurb-cow

Conversation

@meros

@meros meros commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

In short: the kernel makes many short-lived copies of spline curves and surfaces, and each copy duplicated the full control-point grid although neither copy changes it. The grid is now shared between copies and duplicated only when one side writes to it. Results do not change. On the loft boolean of #948, user CPU cycles fall by 2.4 % (20 sections) and 3.1 % (40 sections). This changes how every spline curve and surface stores its data.

What is wrong

Two copy sites on the #948 loft (20 sections, 7952 profile samples on main d4940b63e) copy whole NURB nets that neither side writes:

Site Samples Why it copies Why it cannot borrow
SurfPtSolveStepper::find_boundary_solution 61 IsoCurve::from_surface(surface.clone_for_evaluation()?): the iso curve owns its surface Curve is a 'static enum that owns its geometry; Arc<surface::Surface> would make Curve !Send (the geometry Surface holds a RefCell through CrvOnSurf)
curve_on_classify -> Brep::clone_sharing_surface_caches 98 (51 surface copies, 38 curve copies) a private B-rep snapshot that lazy UV and gap computations write to; read-only callers discard the writes the snapshot is written (UV trims, gap caches, owner scopes), so both copies survive and one is mutated

In both, what is copied is mostly NLib storage (srf_basic::Surface net and knots, crv_basic::Curve control points and knots), and nobody writes it.

Copy Rule case Replaced by
NURB net and knots in every clone shared, rarely mutated: Arc + Arc::make_mut shared until one side writes
compress of storage that is already compact read-only keeps the shared storage (same values)
knot fix-up of the copy constructor real write copies only the knot vector it writes

Change

  • srf_basic::Surface holds net, u, v as Arc<Vec<..>>; crv_basic::Curve holds pw, u the same way. Every write goes through Arc::make_mut. The fields were already private to their modules, so all writes are in these two files.
  • compress returns early when the storage is already exactly the indices in use (then it would build the same values).
  • compressed_copy (the one-pass copy from perf: stop copying NURB surfaces for out-of-bounds solves and tolerance tests (outputs bit-identical) #1006) returns self.clone() when the storage is already compact, so the copy constructor shares the net too.
  • Nothing else changes: Clone, PartialEq and Debug behave as before on the values. Arc of plain data keeps both types Send + Sync.

Test plan

toebox_loft <sections> 1 full (the #948 driver, a local example, not in this PR), release build, main b206d3f5a against this branch, 3 interleaved runs each, taskset -c 0-3, perf stat -e instructions:u,cycles:u,task-clock, medians (load 1 to 3):

Sections Metric main branch Change
20 cycles (user) 10.84 G 10.58 G -2.4 %
20 instructions (user) 54.87 G 53.66 G -2.2 %
40 cycles (user) 33.53 G 32.50 G -3.1 %
40 instructions (user) 167.59 G 163.23 G -2.6 %

Per-site samples (frame-pointer builds, perf record -F 1000, 20 sections, 2 runs each):

Site main branch
iso-curve copy in find_boundary_solution 44, 29 0, 0
clone_sharing_surface_caches snapshot 53, 41 31, 38

The rest of the snapshot is the copy constructor's knot fix-up (fixup_knot_vector, upstream work) and the topology arrays. All non-timing output (counts, volumes and bounding boxes to 17 digits) is identical across all runs of main and the branch, for both inputs.

Checks

  • cargo fmt --all --check: pass
  • cargo clippy --workspace --all-targets -- -D warnings: pass
  • cargo test -p solidcore: 2276 passed, 0 failed. Two new tests: a clone shares the storage, compress and compressed_copy of compact storage keep it shared, and a write unshares only what it writes, leaving the original unchanged (surface and curve).
  • cargo check -p solidcore --target wasm32-unknown-unknown: pass
  • difftest --unit, all failed=0 deferred=0 max_rel_err=0e0:
PASS nlib.srf_basic cases=3220 passed=3220 failed=0 deferred=0 max_rel_err=0e0
PASS nlib.crv_basic cases=3417 passed=3417 failed=0 deferred=0 max_rel_err=0e0
PASS nurbs.nurbs_srf cases=3092 passed=3092 failed=0 deferred=0 max_rel_err=0e0
PASS nurbs.nurbs_crv cases=4113 passed=4113 failed=0 deferred=0 max_rel_err=0e0
PASS nurbs.b_spline_surface_p1 cases=2120 passed=2120 failed=0 deferred=0 max_rel_err=0e0
PASS nurbs.b_spline_curve_p1 cases=2216 passed=2216 failed=0 deferred=0 max_rel_err=0e0
PASS surface.surface_point_solve_p3 cases=1365 passed=1365 failed=0 deferred=0 max_rel_err=0e0
PASS query.face_p2 cases=1401 passed=1401 failed=0 deferred=0 max_rel_err=0e0
PASS curve.curve_cache cases=642 passed=642 failed=0 deferred=0 max_rel_err=0e0

Not run

  • This PR changes the storage of every NURB curve and surface in the kernel. difftest --green (the CI gate, every green and partial unit) did not run locally; only the 9 units above ran. CI runs the full set.
  • No memory measurement: shared storage lowers peak memory, but I did not measure it.

What follows

Refs #948.

…rol net (outputs bit-identical)

Two copies on the tangency-labs#948 loft copy whole NURB nets that neither side
writes: the IsoCurve that SurfPtSolveStepper::find_boundary_solution
builds over an evaluation copy of its surface (61 of 7952 profile
samples), and the private B-rep snapshot of curve_on_classify
(Brep::clone_sharing_surface_caches, 98 samples: surface and curve
copies). Neither can borrow: Curve owns its geometry ('static enum), and
Arc<geometry::Surface> would make Curve !Send (Surface holds a RefCell).

The NLib storage itself is plain data, so srf_basic::Surface (net, u, v)
and crv_basic::Curve (pw, u) now hold it in Arc and write through
Arc::make_mut. A clone shares the storage until one side writes; compress
keeps compact storage shared (same values), and so does
compressed_copy (the one-pass copy of tangency-labs#1006); the copy constructor's knot
fix-up copies only the knot vector it writes. The fields were already
private to their modules, so every write is in these two files.

Loft driver, 3 interleaved runs, P-cores, medians: user cycles -5.5 %
(20 sections) and -9.4 % (40); all non-timing output identical.

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 26, 2026
Merged via the queue into tangency-labs:main with commit 3513d65 Oct 26, 2026
13 checks passed
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