Skip to content

perf: halve export pipeline peak memory, with bit-identical output - #114

Open
Dgmtnz wants to merge 2 commits into
CNCKitchen:mainfrom
Dgmtnz:perf/pipeline-memory
Open

perf: halve export pipeline peak memory, with bit-identical output#114
Dgmtnz wants to merge 2 commits into
CNCKitchen:mainfrom
Dgmtnz:perf/pipeline-memory

Conversation

@Dgmtnz

@Dgmtnz Dgmtnz commented Aug 7, 2026

Copy link
Copy Markdown

Where this is going: second of three, aimed at making large exports work.
Standalone as a perf change, and a prerequisite for the third: that one sizes
the subdivision cap from a measured bytes-per-triangle figure, which is only
correct once these allocations are fixed.

What

Cuts the export pipeline's peak memory roughly in half, with bit-identical output. Pure allocation sizing and lifetime — no geometry, no algorithm, no UX change.

Depends on #113 for IntPairMap.

Measurement

process.memoryUsage().arrayBuffers + .heapUsed, sampled every 5 ms. Not RSS — V8 doesn't return freed pages to the OS promptly and RSS overstates the peak by ~30 %, which is part of why the existing figure had drifted.

Sphere, 3.29 M subdivided triangles, bytes per subdivided triangle:

Stage before after
subdivide 178 147
displace 254 216
decimate 660 327

For context, subdivision.js's safety-cap comment assumes ~145 B/tri. The real figure was 660.

Where it went

  • SoAHeap capacity. Sized at faceCount * 3, then rounded up to a power of two. Seeding pushes one entry per unique edge — 1.5 F by Euler, not the 3 F edge slots the face loop visits — so a 4.9 M-entry heap was allocated as 16.7 M slots × 48 B = 805 MB, over half the pipeline peak. Capacity is only a bound in push(); nothing masks on it, so the pow2 rounding bought nothing either.
  • buildIndexed positions. Allocated at the corner count n and returned as a subarray — a view — so a 6× oversized buffer stayed reachable for the whole decimation (237 MB holding 39 MB). Now grows on demand and returns a copy.
  • slotFace / faceSlot. Slots are assigned s = f*3+k and never renumbered, so slotFace[s] is always (s/3)|0; faceSlot[s] only ever held s or -1, i.e. one bit. First removed, second is a Uint8Array flag. −24 B/tri.
  • Sizing hints. seedSeen and vertMap were 2–6× over, each tipping its table over a power-of-two doubling. seedSeen is also released after seeding instead of held through the collapse loop.
  • decimate(…, releaseInput). buildIndexed is the only reader of the input geometry; when the caller discards it anyway (the export pipeline disposes it on the next line), dropping the attributes frees 72 B/tri for the whole collapse loop. dispose() can't do this — it frees GPU resources, not the JS typed arrays.
  • splitEdges / midCache move to IntPairMap (12 B/slot vs 28).

Risk

Output is bit-identical: 10/10 bench-pipeline.mjs fingerprints unchanged across sphere, cube-with-fillets, cone, holed plate and sliver strip. Every change is allocation size or object lifetime.

Caveats

  • Measured on Linux / Node 22, one machine. The in-browser worker allocator may differ.
  • bench-decim-quality.mjs / bench-tri-estimate.mjs not run — they need model files not in the repo.

claude added 2 commits August 7, 2026 01:22
…d its check

meshRepair packs an edge as `a * 4294967296 + b` (a * 2^32 + b) into a JS
number. float64 holds that exactly only while it stays inside 2^53, i.e. up to
a = 2^21 = 2,097,152 vertices. Above that distinct edges collide onto one key.

diag-edgekey-collision.mjs (added) builds a closed torus whose manifoldness
follows from its grid topology rather than from any measurement, so a counter
that disagrees is wrong by construction:

  1.54M vertices ->       0 non-manifold edges   (below the threshold)
  2.52M vertices -> 210,422 non-manifold edges   (mesh is perfect)
  3.74M vertices -> 819,608 non-manifold edges   (mesh is perfect)

In countEdgeDefects the collision is a false alarm on a good file: a 400mm
sphere exported at 0.35mm / 5M triangles reported 185,146 non-manifold edges
that staged measurement showed were not in the mesh at any pipeline stage.

In resolveTJunctions it is a correctness bug. A genuine boundary edge (count 1)
colliding with another reads as count 2, so its T-junction is silently left
unrepaired, and decoding the key back with `b = k % 4294967296` yields vertex
ids that were never on that edge, feeding wrong candidates to the split search.

Both now key on exact Int32 pairs via a new IntPairMap in meshIndex.js, over a
dense edge table. This also removes a second ceiling in countEdgeDefects, which
counted in a JS Map and throws past V8's ~16.7M entry cap (~11M triangles).

Below 2.1M vertices the old keys were exact, so this is a no-op there: pipeline
fingerprints are unchanged across sphere, cube-with-fillets, cone, holed-plate
and sliver-strip inputs at 10 model/texture/resolution combinations.
Profiling the export pipeline with live typed-array accounting
(process.memoryUsage().arrayBuffers + .heapUsed sampled every 5ms; RSS
overstates by ~30% because V8 does not return freed pages promptly) put the
peak at 660 bytes per subdivided triangle, all of it in decimation — not the
"~145 B/tri" the subdivision safety-cap comment assumes.

Peak per subdivided triangle, sphere at 3.29M triangles:

  stage        before   after
  subdivide       178     147
  displace        254     216
  decimate        660     327

All of it is allocation sizing and lifetime; none of it changes geometry.

- SoAHeap was sized at 3F entries, then rounded up to a power of two. Seeding
  pushes one entry per UNIQUE edge (1.5F by Euler), so a 4.9M-entry heap was
  allocated as 16.7M slots x 48B = 805MB. Capacity is only a bound in push() —
  nothing masks on it — so the pow2 rounding was pure waste too.
- buildIndexed allocated `positions` at the corner count and returned a
  subarray VIEW, keeping a 6x-oversized buffer reachable for the entire run
  (237MB to store 39MB). Grows on demand, returns a copy.
- slotFace[s] is always (s/3)|0 and faceSlot[s] only ever held s or -1; the
  first is gone and the second is a Uint8Array flag (-24 B/tri).
- seedSeen and vertMap sizing hints were 2-6x over, each tipping its table over
  a power-of-two doubling; seedSeen is also freed after seeding rather than
  held through the collapse loop.
- decimate(..., releaseInput) lets the caller hand over the input geometry, so
  its 72 B/tri is not held across the collapse loop. dispose() cannot do this:
  it frees GPU resources, not the JS typed arrays.
- subdivision's splitEdges/midCache move to IntPairMap (12 B/slot vs 28).

Verified with bench-pipeline.mjs: 10/10 fingerprints identical to before,
across sphere, cube-with-fillets, cone, holed-plate and sliver-strip inputs.
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