Share the proxy transfer operator across a swarm's variables; measure the SWARM-15 seam - #460
Conversation
The order=1 weights depend only on geometry -- proxy node coordinates and
particle positions -- so every proxied variable whose proxy has the same
degree and continuity on the same mesh needs the SAME operator. Each was
solving for it independently.
Measured before the change: refresh cost scales linearly with the number of
proxied variables on one swarm (4 variables cost 3.95x one in 2D, 4.08x in
3D), and the weight solve is 74-76% of a refresh.
Swarm now caches the sparse particle -> proxy-node operator, keyed by
(mesh version, proxy degree, proxy continuity, nnn, p, order). Same-degree
proxies were confirmed to have byte-identical node coordinates in distinct
objects, so that key shares exactly and never conflates different node sets.
Validity is tied to the kd-tree INSTANCE rather than to a flag: the entry
carries the tree it was built from and a lookup compares identity, so
migrate() -- which drops the tree -- invalidates it structurally. There is no
new invalidation contract to remember. A stale entry keeps its old tree alive
until replaced: one tree per distinct key, bounded by the number of proxy
discretisations in use.
Measured under a realistic timestep (invalidate the tree, then refresh every
proxied variable -- one miss plus K-1 hits):
uncached cached saving
2D, 1 variable 26.99 ms 27.09 ms -0.4%
2D, 2 variables 50.90 ms 27.00 ms 47.0%
2D, 4 variables 99.33 ms 28.12 ms 71.7%
3D, 1 variable 131.28 ms 132.06 ms -0.6%
3D, 2 variables 239.48 ms 133.28 ms 44.3%
3D, 4 variables 448.89 ms 135.69 ms 69.8%
A swarm carrying ONE proxied variable gains nothing -- the ~5x order=1 cost
over inverse distance is unchanged there. The win is entirely the sharing.
monotone bypasses the cache: the limiter is data-dependent and cannot ride on
a geometry-only operator.
nnn/order resolution moved into SwarmVariable._resolve_stencil so the direct
and cached paths cannot disagree about the stencil they asked for.
Inherited limitation, unchanged and now stated: the kd-tree is a no-copy view
of the coordinate buffer, so writing coordinates in place without migrating
leaves it stale (SWARM-02, test_0113). A cached operator is stale in exactly
those circumstances and no others.
level_1 623 passed, level_2 322 passed, np=2 parallel 6 passed.
Underworld development team with AI support from Claude Code
order=1 makes a linear field exactly np-independent (5.1e-16 at np=1, 2 and 4).
For a curved field np-dependence persists in both schemes -- roughly doubling
from np=1 to np=4 -- but the absolute error at np=4 is 73x smaller under
order=1 (2.3e-4 against 1.7e-2).
Script and output: ~/+Simulations/linear-rbf-proxy/step7_swarm15_seam.{py,txt}.
Underworld development team with AI support from Claude Code
Adversarial reviewThe cache holds a reference to a dead kd-treeEntries carry the tree they were built from so that identity comparison can invalidate them. That means a stale entry keeps its tree — and therefore its no-copy view of the particle coordinate buffer — alive until the key is next used. One tree per distinct key, so bounded, but for a swarm whose proxy discretisations change (an adapt loop varying degree, or callers passing different Not a leak in any run I measured; a slow one in a long adapt sequence is plausible and untested.
|
Found by the adversarial review on this PR, which flagged unbounded growth
as plausible and untested. It is real: keying the cache on
mesh._mesh_version kept every dead entry forever, one set per mesh
generation. Measured: two entries after a single deform, and each holds a
kd-tree, which is a no-copy view of a particle coordinate buffer.
The version leaves the key and becomes an explicit clear instead, so the
two invalidation mechanisms now each guard one thing and both are
structural:
mesh geometry deform/adapt bumps _mesh_version -> drop the whole cache,
since every entry was built against the old node positions
particle motion migrate() replaces the kd-tree -> an entry not carrying
the current tree is stale
Verified mesh.deform() does bump _mesh_version (0 -> 1) rather than
assuming it, and that the operator is genuinely rebuilt afterwards.
Two tests added: the cache stays at one entry across three successive
deforms, and the operator object is not reused across one.
level_1 625 passed.
Underworld development team with AI support from Claude Code
|
Fixed the memory point from the review (c43c1f8), which turned out to be real rather than plausible. Keying on The version leaves the key and becomes an explicit clear, so the two invalidation mechanisms each guard one thing and both stay structural:
Also closed the other untested assumption from the review: Two tests added: the cache stays at one entry across three successive deforms, and the operator object is not reused across one. level_1 625 passed. The remaining review points stand as stated: no saving for a single proxied variable, sharing conditional on matching proxy degree, and the seam measurement comparing error-against-analytic at each np rather than values at matched coordinates. |
Two follow-ups from #430, both of which that PR left explicitly open: the ~5x
order=1refresh cost, and the unmeasured rank-local seam.1. Shared transfer operator
The
order=1weights depend only on geometry, so every proxied variable whose proxy has the same degree and continuity on the same mesh needs the same operator. Each was solving for it independently.Measured first, to decide whether to build anything:
migrate(), so an advecting model gets one miss per timestep and K−1 hits.Swarmnow caches the sparse particle → proxy-node operator, keyed by(mesh version, proxy degree, proxy continuity, nnn, p, order). Same-degree proxies were confirmed to have byte-identical node coordinates in distinct objects, so that key shares exactly and never conflates different node sets.Validity is tied to the kd-tree instance, not a flag: the entry carries the tree it was built from and lookup compares identity, so
migrate()invalidates it structurally. No new invalidation contract to remember.Measured under a realistic timestep — invalidate the tree, then refresh every proxied variable:
A swarm carrying one proxied variable gains nothing — the ~5x
order=1cost over inverse distance is unchanged there. The win is entirely the sharing, which is what a Lagrangian DDt (multiplepsi_starhistory levels) or any multi-field swarm actually does.monotonebypasses the cache: the limiter is data-dependent and cannot ride on a geometry-only operator.nnn/orderresolution moved intoSwarmVariable._resolve_stencilso the direct and cached paths cannot disagree.2. SWARM-15 seam, measured
#430 stated the curved-field seam error was unmeasured. SWARM-15's own migration plan asks for it as step 1. Max relative error against the analytic field, same mesh and particles at each np:
order=0order=1order=0order=1A linear field is exactly np-independent under
order=1. For a curved field np-dependence persists in both schemes, roughly doubling from np=1 to np=4 — but the absolute error at np=4 is 73x smaller underorder=1. Soorder=1reduces the seam by about two orders of magnitude without removing it; the halo exchange is still the real fix. Recorded insubsystems/interpolation.md, replacing the "unmeasured" wording.Regression
level_1 623 passed, level_2 322 passed, np=2 parallel 6 passed, 0 failures. Six new tests in
tests/test_0117_swarm_proxy_weight_cache.pycovering value-identity with the uncached path, sharing, non-sharing across degrees, invalidation on migrate, linear exactness through the cached path, and the monotone bypass.Artifacts:
~/+Simulations/linear-rbf-proxy/step5_*,step6_*,step7_*.Underworld development team with AI support from Claude Code