Skip to content

Evaluate mask extrude's surface projection only where the mask can matter - #691

Merged
leonardoaraujosantos merged 2 commits into
mainfrom
fix/mask-extrude-projection-cost
Oct 6, 2026
Merged

leonardoaraujosantos merged 2 commits into
mainfrom
fix/mask-extrude-projection-cost

Conversation

@leonardoaraujosantos

@leonardoaraujosantos leonardoaraujosantos commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the v0.126.0 device-gate failure; follow-up to #667 / #660.

The failure

v0.126.0's iPad gate failed on two cases. Measured on iPad15,5, iOS 27.0.1, against v0.120.1's engine on the same OS:

case before v0.126.0 ratio
mask_extrude 10 stamps 51.6 ms 288.3 ms 5.58x
mask_extrude 100 stamps 374.5 ms 2566.1 ms 6.85x
mask_extrude 1000 stamps 5297 ms 26834 ms 5.07x
mask_extract (gallery, one shot) 75.3 ms 468.5 ms 6.22x

The growth exponent stayed at 0.98, so this is a per-stamp cost. Every other case held.

Which path the device cases take

Both take the field path. VerbHeavyCases.swift (mask_extrude) and StrokeGallery.swift (mask_extract) both call clay_document_mask_extrude on an SDF layer. That compiles the layer's tape and calls brush::mask_extrude(std::function<float(cfloat3)>...) with tape.eval(p).d as the source. The voxel path (clay_voxel_mask_extrude) is exempt in Coverage.swift and not on the gate. Its 5x5x5 normal scan is unchanged here.

The cause

#667 (merge 9c95533) reads the mask at each sample's projection onto the source surface. The projection's normal was a six-tap central difference at p ± cell/2, so every sample called the source seven times where it had called it once. The device case is a pure scalar coloured tape walk, so source calls are the cost. 7x the calls gave 5-7x the time.

The fix

  1. The gradient comes from the sample lattice. It is a central difference of the distances one cell either side. The fill now takes the source distance for a whole window of bricks (the 512-brick window sample_blocks already hands it) before projecting any of them. A neighbour inside the brick, or across a face into a brick in the same window, is a lookup. Only a neighbour outside the window calls the source. That call uses the exact expression BrickGrid::sample_position uses (integers added before converting), so it is bit-for-bit the value the neighbouring brick samples. A sample two bricks share therefore gets the same gradient in each, and the halo stays seamless.
  2. The projection is skipped where the shell alone decides the stored value. The stored value is max(shell, region), or the quadratic smooth max with border_round. A max pyramid over the measured mask distances bounds what MaskDistance::eval can return anywhere within |distance| of the sample. That bound is conservative by construction: eval is a convex combination of eight stored distances, and the bound covers the box of every corner eval could read, plus a 1e-5 relative slack for float lerp rounding. Where shell > bound, cmax returns the shell. With border_round, shell - bound >= 4k (the quadratic support) makes the blend term exactly zero. Separately, a brick whose every shell is beyond the band is dropped by FieldVolume whatever the region says: the stored value is never below the shell, so only the sign survives, and the shell has it. All of this is exact.

The public mask_extrude runs culled. brush::detail::mask_extrude_field(..., cull, tally) lives in the src-private src/brush/mask_extrude_internal.h. With cull off it projects every sample, as the test reference. No C ABI change and no version bump.

What the measuring changed

The task's first option, the exact skip alone with #667's six taps kept, was built first and is bit-identical to main. It was not enough: it measured 3.51 source calls per lattice sample. Instrumenting it showed about 42% of samples still projected. About 55% of those genuinely need the region (they lie outside the mask, inside the band, where the stored value is the region). Tightening the bound cannot fix that: an exact level-0 box max gave the same 1.2786 as the pyramid. So no exact skip gets below about 2.4 calls, and a 4-tap tetrahedral gradient on top would still leave about 2.7. The lattice gradient is what gets to about 1. The first version of it called the source for every out-of-brick neighbour (1.28 calls). Reading neighbours from the same window took that to 1.009.

Value change against main (stated, as the lattice gradient changes values)

The six-tap and lattice gradients differ, so stored values move wherever the projection runs. I measured the largest absolute difference over every stored float, against main's six-tap formula, on the test fixtures:

fixture max change
sphere cap, all sides, round 0 / 0.06 0.0005-0.0034 cells
#660 walls 0.05 / 0.1 / 0.6 (outward) 0.0002-0.0018 cells
device shell (dabbed 0.8 sphere) 0.010 cells
box edge (outward / centred) 0.10-0.13 cells
0.6 inward wall reaching the sphere's centre 0.21 cells

The brick layout (index and far bounds) is identical on every fixture. The two largest are where the source has no well-defined gradient (an edge, and the centre of a sphere), so neither stencil is right there. #667's thickness and evenness tests pass unchanged.

Proof the skip is exact

mask extrude: skipping the projection changes no stored bit compares the culled volume to the unculled reference. It compares the whole to_blob() with memcmp: lattice, index, far bounds, every stored sample and the measured Lipschitz. It covers 19 fixtures: a sphere cap outward/inward/centred at round 0 and 0.06; #660's 0.05, 0.1 and 0.6 walls (plus 0.6 inward, rounded); a box edge outward/centred at round 0 and 0.04; a serrated cap with border_smooth; the device shell, outward and inward-rounded; and two buried-mask fixtures tight on the bound's reach (see the follow-ups below). All bit-identical.

mask extrude: neighbouring bricks agree on every sample they share walks every pair of face-adjacent stored bricks and requires identical bits on the shared face, on the same 19 fixtures.

mask extrude: the skip fires where it is meant to reads a tally of what the fill did. On the device fixture, whole bricks beyond the band are skipped and under 60% of lattice samples project. On the sphere cap, more than 60 samples the volume stores are skipped by the bound (631 when written). The unculled reference must report no skips.

The count gate

mask extrude: the source is called about once a sample counts source calls per lattice sample (bricks x 729, stored or not, so sparsity cannot flatter it) on the device fixture: a 0.8 shell, 24 dabs at the gate's stamp spread, a 0.05 wall at a 0.04 cell. Bound: <= 1.5.

build calls / lattice sample gate
main (#667) 7.0 FAILS
exact skip only, six taps kept 3.51 fails
this PR 1.009 passes

Mutations, each reverted:

mutation result
skip removed (public entry runs unculled), on the earlier per-brick variant count test fails at 1.667
one-sided differences at brick faces (no out-of-brick taps) seam test fails on all 17 fixtures; the border_smooth rim test fails too
bound's reach halved (unsound) bit-identity test fails on box edge and both buried-mask fixtures
smooth-rim support taken as k instead of 4k (unsound) bit-identity test fails (8 fixtures)
bound skip never fires skip-fires test fails (bound_skips_in_band > 60)
brick skip never fires skip-fires test fails (bricks_beyond_band > 0 and the projected fraction)

With the window lookup in place, the unculled reference is itself about 1 call per sample. The skip now saves the trilinear read and the projection arithmetic rather than source calls. Its guards are the tally test and the A/B below, not the count.

Mac A/B (not a device number)

The device fixture through the C ABI, linking each revision's libclaycore.a (Release, cpu-only). Machine: Apple M2 Max (12 cores), macOS. 200 samples per arm, arms interleaved, round 1 discarded. The box carried heavy unrelated background load (load average 7-17), so p50s swing up to 2x between rounds. The table gives the lowest p10 over the settled rounds, with the settled p50 range beside it.

stamps pre-#667 (9c95533^1) main this PR PR without skip PR / pre-#667 main / pre-#667
10 73.1 ms (p50 82-140) 395.5 ms (p50 451-683) 69.1 ms (p50 72-208) 76.1 ms (p50 80-232) 0.95x 5.4x
100 515.9 ms (p50 572-748) 3405.9 ms* (p50 3559-3783) 519.7 ms (p50 684-759) 562.6 ms (p50 590-651) 1.01x 6.6x

* main at 100 stamps: 20 samples x 2 rounds (one call takes about 3.5 s).

On the Mac, main reproduces the device's 5.6x / 6.9x as 5.4x / 6.6x. This PR returns to the pre-#667 cost (0.95x / 1.01x, inside the 1.2x target). The skip is worth about 5-10% at 10 stamps. These are Mac numbers. The device gate has to be re-run on the iPad to confirm the fix there.

Review follow-ups (second commit)

  • Memory. RegionCeiling's level 0 now reads MaskDistance::d in place (a pointer view); only the coarser levels are allocated, about 1/7 of the dense array. It used to copy level 0, which held about 2.14x the dense distances while sampling. This verb has jetsam history on the iPad.
  • One position function. New FieldVolume::BrickGrid::cell_position(const int cell[3]). sample_position, sample_positions and the extrude's off-window neighbour now all go through it, so they are equal by construction rather than two copies of the same float expression in different translation units, which -ffp-contract=fast could have contracted differently.
  • Reference out of the public header. It moved from include/clay/brush/mask_extrude.h into src/brush/mask_extrude_internal.h, together with MaskExtrudeTally.
  • The skip is proved to fire (the tally test above). One finding: on the device fixture the per-sample bound fires only about 22 times. The dabs are small, so the wall has almost no deep interior, and nearly all the skipping there is whole bricks beyond the band (298 bricks). The bound's in-band skips are asserted on the sphere cap instead.
  • Reach-tight fixture. A masked volume is buried just under an unpainted surface (its top top below the surface, under a 0.6 column along +Y), with a small cap elsewhere so the extrude isn't refused. Deep inward walls are extruded through it. A sample at depth D is inside the mask out to D/2 once 1.5 D > t + top, but its projection lands on the unmasked surface. That means the buried boundary has to sit within about t/4 of the surface; the suggested 0.3-0.6 cannot trip a half-reach bound with a 0.6 wall. One honest limit: the pyramid reads up to twice the span it is asked for, which absorbs most of a halved reach. A quarter reach failed on most variants measured, a half reach on some. The two kept fixtures are variants measured to fail at half reach: 0.6 wall at a 0.02 cell, and 0.45 wall at a 0.04 cell, both with top 0.06. With the reach halved, bit-identity fails on both and on the box edge.
  • Re-measured at 10 stamps (same Mac, same caveats, 200 samples interleaved, round 1 discarded; settled rounds below). The follow-ups cost nothing, and the second commit is no slower than the first:
arm p50 (rounds 2, 3) p10 (rounds 2, 3)
pre-#667 69.0, 67.2 ms 66.4, 63.5 ms
main 402.5, 424.8 ms 387.8, 386.2 ms
first commit 92.1, 91.2 ms 71.0, 73.8 ms
this branch 74.0, 79.0 ms 69.2, 70.4 ms

This branch against pre-#667: 1.07x / 1.17x at p50, 1.04x / 1.11x at p10, inside 1.2x. This run was quieter than the first and reads a little higher than the 0.95x above.

Verification

  • cmake --preset cpu-only -DCLAY_BUILD_TESTS=ON -DCLAY_BUILD_PYTHON=ON, full build rc=0. ctest --preset cpu-only: 11/11 passed (rc=0), including all four unit shards and pyclay_pytest.
  • test_mask_extrude.cpp: 23 cases, 335 assertions, all pass, including Honor mask extrude thickness beyond paint depth #667's unchanged thickness tests.
  • After the follow-ups, all re-run: build rc=0; ctest 11/11 rc=0; check_layering, check_kernel_dialect, check_licenses, check_gallery, check_doc_latency, check_c_abi rc=0; check_test_shards --binary rc=0; binding parity rc=0 (imported); GCC -Werror on mask_extrude.cpp, volume.cpp and the test rc=0; openspec strict rc=0. New functions' cognitive complexity is <= 13.
  • check_layering, check_kernel_dialect, check_licenses, check_gallery, check_doc_latency, check_c_abi: rc=0 each. check_test_shards --binary: rc=0 (3078 cases, 4 shards).
  • check_binding_parity --pyclay build/cpu-only/bindings/python --require-import: rc=0, "imported .../pyclay.cpython-311-darwin.so" (a real import, not the parsed fallback).
  • npx @fission-ai/openspec@1.12.0 validate --all --strict: rc=0 (80 passed).
  • GCC 16 -fsyntax-only -Wall -Wextra -Wpedantic -Wshadow -Werror on src/brush/mask_extrude.cpp and tests/unit/test_mask_extrude.cpp: rc=0.
  • Cognitive complexity (clang-tidy): every new or changed function is <= 12 (extrude_field 12, RegionCeiling::above 10, neighbour 7). The voxel mask_extrude at 63 is pre-existing and untouched.
  • check_device_coverage.py needs a device-run JSON. Not applicable to a C++-only change.

Docs / spec

  • honor-mask-extrude-thickness is not archived, so it is amended. design.md gains "Cost of the anchor": the device numbers, both mechanisms, why each is sound, and the value change. tasks.md gains the follow-up tasks. The sdf-kernels delta gains a scenario: at most 1.5 source calls per lattice sample, bit-identical to projecting every sample, seamless halo.
  • docs/07-brushes-and-features.md: how the SDF path finds the surface, and what it costs.
  • include/clay/field/volume.h: BrickGrid::cell_position. The public extrude header is unchanged from main.

No ABI or format change, and no version bump (0.126.0 is untagged).

…tter

#667 anchored the mask to the source surface by projecting every field
sample along a six-tap central-difference gradient, so each sample called
the source seven times. The v0.126.0 device gate measured mask_extrude
5.1-6.9x slower and the gallery's mask_extract 6.2x.

The gradient now comes from the sample lattice: a central difference of
the distances one cell either side, taken from the window of bricks the
fill already evaluated. Only a neighbour outside the window calls the
source, at the exact lattice position its own brick samples, so shared
halo samples stay bit-equal across brick faces.

The projection is also skipped where the shell alone decides the stored
value: where it exceeds a max-pyramid bound on what the mask distance can
report within |distance| of the sample (by the quadratic blend's support
when border_round is set), and in bricks lying wholly beyond the band.
The skip is exact, and tests hold it bit-identical to projecting every
sample.

Source calls per lattice sample on the device fixture: 7 -> 1.009.
Review follow-ups to the skip and lattice gradient.

- RegionCeiling's level 0 now reads MaskDistance::d in place instead of
  copying it, so the bound adds about a seventh of the dense distances
  rather than doubling them while sampling. This verb has jetsam history
  on the tablet.
- BrickGrid::cell_position is now the one function every sample position
  goes through, and the extrude's off-window neighbour uses it too. The two
  were separate copies of the same float expression in different
  translation units, which contraction could have split.
- The unculled reference moves out of the public header into
  src/brush/mask_extrude_internal.h, with a tally of what the fill skipped.
  New tests assert the skip fires on whole bricks beyond the band (device
  fixture) and on stored samples under a painted interior (sphere cap).
  Turning either skip off fails them.
- Two buried-mask fixtures (a masked volume starting just under an
  unpainted surface, deep inward walls) make a bound that reads only half
  the projection's reach change stored bits. With the reach halved,
  bit-identity now fails on both and on the box edge. The pyramid reads up
  to twice the span it is asked for, which hides most half-reach errors;
  these variants were measured to show it anyway.
@leonardoaraujosantos
leonardoaraujosantos merged commit abdd1d4 into main Oct 6, 2026
@leonardoaraujosantos
leonardoaraujosantos deleted the fix/mask-extrude-projection-cost branch October 6, 2026 05:49
leonardoaraujosantos added a commit that referenced this pull request Oct 6, 2026
The first device gate failed on mask_extrude and mask_extract at 5-7x,
caused by #667's per-sample gradient; #691 restored the cost. The notes
record the failed run, the cause, the fix and its small value change.
leonardoaraujosantos pushed a commit that referenced this pull request Oct 6, 2026
The gate passed at 7049e19 against the iOS 27.0.1 baseline #690 recorded
from v0.120.1's engine, so REGRESSION compares v0.120.1 to v0.126.0 on the
same OS: iPad15,5, 75 cases, seven cold sessions with 1800 s cooldowns,
nominal throughout, canary steady at x1.18, median ratio 0.996, none above
both the 1.4 tolerance and the 0.125 ms floor, largest rise 1.04x.

The first run at 7406eb1, before #691, failed on mask_extrude (x4.97,
26992 ms at 1000 stamps against an 8154 ms budget) and mask_extract (x6.26);
with #691 they read 1.01x and 1.04x. The stroke cases read 0.81-0.89x,
named as a candidate for #688 and not a claim. The run needed the iPad's
Wi-Fi off and an air conditioner; the notes say why.
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.

1 participant