Repository navigation
Evaluate mask extrude's surface projection only where the mask can matter - #691
Merged
Merged
Conversation
…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
added a commit
that referenced
this pull request
Oct 6, 2026
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
mask_extrude10 stampsmask_extrude100 stampsmask_extrude1000 stampsmask_extract(gallery, one shot)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) andStrokeGallery.swift(mask_extract) both callclay_document_mask_extrudeon an SDF layer. That compiles the layer's tape and callsbrush::mask_extrude(std::function<float(cfloat3)>...)withtape.eval(p).das the source. The voxel path (clay_voxel_mask_extrude) is exempt inCoverage.swiftand 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
sample_blocksalready 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 expressionBrickGrid::sample_positionuses (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.max(shell, region), or the quadratic smooth max withborder_round. A max pyramid over the measured mask distances bounds whatMaskDistance::evalcan 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. Whereshell > bound,cmaxreturns the shell. Withborder_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 byFieldVolumewhatever 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_extruderuns culled.brush::detail::mask_extrude_field(..., cull, tally)lives in the src-privatesrc/brush/mask_extrude_internal.h. Withculloff 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:
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 bitcompares the culled volume to the unculled reference. It compares the wholeto_blob()withmemcmp: 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 withborder_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 sharewalks 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 toreads 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 samplecounts 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.Mutations, each reverted:
border_smoothrim test fails toobound_skips_in_band > 60)bricks_beyond_band > 0and 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.* 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)
RegionCeiling's level 0 now readsMaskDistance::din 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.FieldVolume::BrickGrid::cell_position(const int cell[3]).sample_position,sample_positionsand 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=fastcould have contracted differently.include/clay/brush/mask_extrude.hintosrc/brush/mask_extrude_internal.h, together withMaskExtrudeTally.topbelow 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.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 andpyclay_pytest.test_mask_extrude.cpp: 23 cases, 335 assertions, all pass, including Honor mask extrude thickness beyond paint depth #667's unchanged thickness tests.check_layering,check_kernel_dialect,check_licenses,check_gallery,check_doc_latency,check_c_abirc=0;check_test_shards --binaryrc=0; binding parity rc=0 (imported); GCC-Werroronmask_extrude.cpp,volume.cppand 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).-fsyntax-only -Wall -Wextra -Wpedantic -Wshadow -Werroronsrc/brush/mask_extrude.cppandtests/unit/test_mask_extrude.cpp: rc=0.extrude_field12,RegionCeiling::above10,neighbour7). The voxelmask_extrudeat 63 is pre-existing and untouched.check_device_coverage.pyneeds a device-run JSON. Not applicable to a C++-only change.Docs / spec
honor-mask-extrude-thicknessis 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).