Support bounded three-level separated patch unions

This commit is contained in:
TRON committed 2026-10-16 09:18:07 -07:00
1 parent 20fb16ded5
commit a24e6d2742
5 files changed
+647 -24

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
# Fixed three-level separated patches
`FixedPatchStepper` supports exactly three static, blocked levels with separated
rectangle sets on either adjacent pair. This is not general AMR: there is no
per-region timestep, moving geometry, dynamic refinement or L1 body support.
All regions share one current/spare buffer pair per level. The existing schedule
advances L0 once, L1 twice and L2 four times per base step, with aggregate transfers.
No CUDA kernel changes are involved.
## Explicit coordinate frames
Keep the existing scalar `child=(x0,y0,x1,y1)` for a single scalar parent patch.
Its coordinates retain their original meaning: half-open integer indices in the
allocated L1 window, including its ghost band.
The new keyword `child_rectangles_in_parent=[...]` is mutually exclusive with
`child`. It accepts a scalar or a set parent and interprets every rectangle in
the actual allocated L1 array, including ghost bands and holes. It never infers
region-local coordinates. The physical root cell edge corresponding to an L1
index `(x,y)` is `parent.fine_origin + (x,y)/2`:
- A scalar L1 parent has a shifted window origin.
- A set L1 parent has origin `(0,0)` and public shape `(2*ny,2*nx)`.
- A child set has public shape twice the allocated L1 shape. Its root origin is
the L1 allocated origin, even when the parent is a scalar window.
For a 16x16 root, scalar parent `(4,4,12,12)` and scalar child `(8,8,16,16)`
are physically equivalent to parent set `[(4,4,12,12)]` with child set
`[(12,12,20,20)]`. A child set on the *scalar* parent instead uses
`[(8,8,16,16)]`. Reusing the same numbers after changing the parent frame can
move the physical region.
Both adjacent pairs require a Chebyshev gap of at least four in their own
parent-cell units. The whole child union must preserve inherited ACTIVE
coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
restriction footprint. Sparse holes remain unowned; inherited INNER remains
stream-only and OUTER remains invalid. Block sizes must align with every
support edge, including its four-cell ghost band.
## Example
```python
run = FixedPatchStepper(
cfg, [(4, 4, 20, 28), (24, 4, 44, 28)], context=ctx,
base_block=(4, 4), fine_block=(4, 4), child_block=(4, 4),
child_rectangles_in_parent=[(16, 16, 32, 40), (56, 16, 80, 40)],
)
```
This example uses a 48x32 root. `initialize` and `download` use three Q-major
dense arrays. Public arrays and masks still scale with the full allocated
extent; sparse device buffers do not remove that host-memory limit.
Stationary whole-circle `child_objects` and `child_jets` use root edge
coordinates and a single L2 boundary object. Body IDs are physical object-list
positions, not region IDs or caller `obj_id`. Complete boundary stencils must
avoid the transfer footprint. Change a child Jet with
`set_jet_amplitude(index, value, level=2)`; L1 bodies/Jets remain unsupported.
## Verification
CPU geometry, table, pre-CUDA rejection, schedule and cleanup tests:
```sh
PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_refinement*.py' -v
```
Explicit GPU gates are in
`tests/validation/multigrid/test_refinement_three_level_multipatch_gpu.py`.
They cover physical-frame equivalence, twins, asymmetric refinement, multiple
children, reversed order, independently varied aligned layouts, public/raw
masks and two L2 bodies with static/zero/driven/switched Jets. Snapshots are
0/1/2/8. CPU mocks do not execute CUDA and do not establish numerical accuracy.
GPU equivalence gates supplement the separately frozen independent dense
three-level numerical replay; passing either alone is not a conservation,
performance or physical-accuracy claim.
+29 -5
View File
@@ -318,11 +318,35 @@ def nest_patch(cfg, parent, child_bounds):
would read a covered or OUTER cell without a child-supplied arrival; and
child coverage of L1's own two-hop restriction footprint toward L0.
"""
child = FixedPatch2D.from_config(_middle_config(cfg, parent), child_bounds)
return child, _nested_kind(parent, child)
def nest_patch_set(cfg, parent, child_rectangles_in_parent):
"""Separated child union in the parent's allocated L1 array coordinates.
The root edge coordinate of L1 index (x, y) is
``parent.fine_origin + (x, y)/2``. For a set parent its origin is zero;
a scalar parent's window includes its ghost band and has a shifted origin.
The child set allocates a dense public extent twice that L1 array, while
its blocked storage omits holes. Both adjacent pairs retain gap >= 4 in
their respective parent-cell units. Validate support over the whole union.
"""
child = FixedPatchSet2D.from_config(
_middle_config(cfg, parent), child_rectangles_in_parent)
return child, _nested_kind(parent, child)
def _middle_config(cfg, parent):
fy, fx = parent.fine_shape
return replace(cfg, nx=fx, ny=fy, viscosity=2 * cfg.viscosity, y_wall_bc="bounce_back",
top_wall_bc="velocity", wall_slot_width=0, wall_normal_velocity=0.0,
velocity=0.0)
def _nested_kind(parent, child):
"""Validate composed transfer support and preserve inherited ghost kinds."""
fy, fx = parent.fine_shape
middle = replace(cfg, nx=fx, ny=fy, viscosity=2 * cfg.viscosity, y_wall_bc="bounce_back",
top_wall_bc="velocity", wall_slot_width=0, wall_normal_velocity=0.0,
velocity=0.0)
child = FixedPatch2D.from_config(middle, child_bounds)
kind = parent.fine_kind
covered = child.covered
active = (kind == ACTIVE) & ~covered
@@ -346,7 +370,7 @@ def nest_patch(cfg, parent, child_bounds):
raise ValueError("child covers the parent's restriction footprint")
composed = kind.copy()
composed[covered] = OUTER_GHOST
return child, _readonly(composed, np.uint8)
return _readonly(composed, np.uint8)
def prepare_fine_boundary(cfg, patch, objects, *, wall_aware=False, jets=(),
+50 -19
View File
@@ -19,7 +19,7 @@ from .curved_links import surface_link_velocity
from ..body.jets import jet_link_shapes, jet_link_velocity
from .block_stepper import MAX_GRID_DIM
from .refinement import (ACTIVE, HALO, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D,
nest_patch, prepare_fine_boundary)
nest_patch, nest_patch_set, prepare_fine_boundary)
from .descriptors import SOLID, D2Q9_EX, D2Q9_EY
@@ -61,23 +61,34 @@ class _Pair:
parent_cells: int # parent block window cells (explode donor stride)
def blocked_base_tables(patch, layout, order, wall_aware=False):
def blocked_base_tables(patch, layout, order, wall_aware=False, *, composed_kind=None):
"""Parent donor cells and receiver-local arrival slots, in transfer row order.
Stage arrivals only after child reads and parent halo gather, immediately
before parent pull. Source *owner* slots are wrong at block seams.
Nested pairs must pass composed_kind to retain inherited INNER/OUTER and
sparse holes; donors and receivers must be owned, uncovered ACTIVE cells.
"""
owner = layout.owner.ravel()
q, receiver = np.divmod(patch.coalesce_coarse[order], np.uint64(owner.size))
if composed_kind is None:
dense_kind = np.where(patch.covered, OUTER_GHOST, ACTIVE).astype(np.uint8)
if wall_aware:
dense_kind[[0, -1]] = OUTER_GHOST
else:
dense_kind = np.asarray(composed_kind)
if dense_kind.shape != patch.coarse_shape:
raise ValueError("composed parent kind has the wrong shape")
donors = patch.ghost_coarse.astype(np.intp)
receivers = receiver.astype(np.intp)
legal = layout.owned.ravel() & (dense_kind.ravel() == ACTIVE) & ~patch.covered.ravel()
if not legal[donors].all() or not legal[receivers].all():
raise ValueError("child donors/receivers need owned uncovered parent ACTIVE cells")
offset = (np.asarray(D2Q9_EY)[q.astype(np.intp)] * layout.local_shape[1]
+ np.asarray(D2Q9_EX)[q.astype(np.intp)])
source = owner[receiver.astype(np.intp)].astype(np.int64) - offset
kind = np.full(layout.nblocks * int(np.prod(layout.local_shape)), OUTER_GHOST, np.uint8)
leaves = ~patch.covered
if wall_aware:
leaves[[0, -1]] = False
kind[owner[leaves.ravel()].astype(np.intp)] = ACTIVE
return (owner[patch.ghost_coarse].astype(np.uint64), layout.slot(source, q), kind)
source = owner[receivers].astype(np.int64) - offset
return (owner[donors].astype(np.uint64), layout.slot(source, q),
blocked_kind(layout, dense_kind))
@dataclass
@@ -171,6 +182,16 @@ class FixedPatchStepper:
shape, NaN outside the patches' ACTIVE cells. The step sequence is the
blocked one above, with every patch's transfers in one launch each.
Three-level separated unions: ``child_rectangles_in_parent=[...]`` is
mutually exclusive with scalar ``child`` and accepts either parent form.
Coordinates index the allocated L1 array, including ghost bands and holes:
root edge = patch.fine_origin + L1 index / 2. A parent set has origin zero;
a scalar parent retains its shifted window origin. The child set's public
extent is twice the allocated L1 shape, even though storage is sparse.
Each pair requires gap >= 4 in its own parent-cell units. All regions share
one buffer pair per level and the fixed 1:2:4 schedule; L2-only bodies/Jets
below apply unchanged, with object IDs assigned over the whole level.
Three-level single chain: ``child`` bounds use L1 window cells; all three
levels must be blocked. ``child_objects`` and ``child_jets`` use the L0
edge frame, with L2 dx=dt=.25 and unchanged lattice velocity. L1 bodies
@@ -181,11 +202,14 @@ class FixedPatchStepper:
"""
def __init__(self, cfg, bounds, *, context, coarse_boundary="periodic", fine_objects=(),
fine_block=None, base_block=None, jets=(), child=None, child_block=None,
child_objects=(), child_jets=()):
child_objects=(), child_jets=(), child_rectangles_in_parent=None):
if HALO != 4:
raise RuntimeError("refinement.cu requires a four-cell fine halo")
if coarse_boundary not in ("periodic", "slot_reservoir"):
raise ValueError("coarse_boundary must be periodic or slot_reservoir")
if child is not None and child_rectangles_in_parent is not None:
raise ValueError("child and child_rectangles_in_parent are mutually exclusive")
nested = child is not None or child_rectangles_in_parent is not None
bounds = tuple(bounds)
multipatch = len(bounds) > 0 and not isinstance(bounds[0], (Integral, np.integer))
if multipatch:
@@ -205,7 +229,7 @@ class FixedPatchStepper:
raise ValueError("slot_reservoir requires y0 >= 4 and y1 <= ny-4")
fine_objects, jets = tuple(fine_objects), tuple(jets)
child_objects, child_jets = tuple(child_objects), tuple(child_jets)
if child is None and (child_objects or child_jets):
if not nested and (child_objects or child_jets):
raise ValueError("child_objects/child_jets require a nested child")
if base_block is not None and fine_block is None:
raise ValueError("base_block requires fine_block")
@@ -230,17 +254,23 @@ class FixedPatchStepper:
raise ValueError("fine blocks exceed launch limits")
self.child_patch = self.child_layout = None
self._middle_kind = None
if child is not None:
if multipatch or fine_block is None or base_block is None or child_block is None:
raise ValueError("a nested child needs one patch plus fine_block, base_block "
"and child_block")
if nested:
if child is not None and multipatch:
raise ValueError("scalar child requires one parent patch; use child_rectangles_in_parent")
if fine_block is None or base_block is None or child_block is None:
raise ValueError("a nested child needs fine_block, base_block and child_block")
if tuple(fine_objects) or tuple(jets):
raise ValueError("a nested child supports bodies/Jets only on L2; use child_objects/child_jets")
if len(child_block) != 2:
raise ValueError("child_block must be (by, bx)")
self.child_patch, self._middle_kind = nest_patch(cfg, self.patch, child)
self.child_layout = BlockLayout2D.build(*self.child_patch.fine_shape, *child_block,
periodic=(False, False))
if child_rectangles_in_parent is not None:
self.child_patch, self._middle_kind = nest_patch_set(
cfg, self.patch, child_rectangles_in_parent)
self.child_layout = self.child_patch.fine_layout(child_block)
else:
self.child_patch, self._middle_kind = nest_patch(cfg, self.patch, child)
self.child_layout = BlockLayout2D.build(*self.child_patch.fine_shape, *child_block,
periodic=(False, False))
if (self.child_layout.nblocks > MAX_GRID_DIM
or self.child_layout.local_shape[0] > MAX_GRID_DIM):
raise ValueError("child blocks exceed launch limits")
@@ -365,7 +395,8 @@ class FixedPatchStepper:
g, cp = self._levels[2], self.child_patch
order = np.argsort(cp.coalesce_coarse)
ghosts, children, g_kind = blocked_fine_tables(cp, g.layout, order)
donors, targets, _ = blocked_base_tables(cp, f.layout, order)
donors, targets, _ = blocked_base_tables(
cp, f.layout, order, composed_kind=self._middle_kind)
g.kind = self._upload(g_kind)
g.halo_copy = g.module.get_function("BlockHaloCopy")
self._child = _Pair(g.module.get_function("BlockExplode"),
@@ -0,0 +1,314 @@
"""CPU-only union geometry and production host-control-flow tests.
The AST loader omits CUDA imports and supplies explicit mocks; it executes
unchanged production definitions, never CUDA kernels or a numerical emulator.
"""
import ast
from contextlib import nullcontext
from dataclasses import replace
from pathlib import Path
import types
import unittest
from unittest.mock import Mock, patch
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm import refinement as r
def host_module():
path=Path(r.__file__).with_name('refinement_stepper.py')
tree=ast.parse(path.read_text())
tree.body=[node for node in tree.body if not (
isinstance(node,ast.Import) and any(a.name=='pycuda.driver' for a in node.names)
or isinstance(node,ast.ImportFrom) and node.module in (
'cuda','block_stepper','curved_links'))]
module=types.ModuleType('CelerisLab.lbm._test_host_refinement')
module.__package__='CelerisLab.lbm'
from CelerisLab.body.jets import surface_link_velocity
module.__dict__.update(cuda=Mock(),compiler=Mock(),MAX_GRID_DIM=65535,
surface_link_velocity=surface_link_velocity)
exec(compile(tree,str(path),'exec'),module.__dict__)
return module
host=host_module()
CFG = LBMConfig(nx=48, ny=32, viscosity=.1)
PARENTS = ((4,4,20,28), (24,4,44,28)) # exact gap=4 coarse cells
CHILDREN = ((16,16,32,40), (56,16,80,40)) # parent-allocated L1 cells
class Gate(unittest.TestCase):
def setUp(self):
self.parent = r.FixedPatchSet2D.from_config(CFG, PARENTS)
def test_one_element_equivalence(self):
cfg = LBMConfig(nx=16,ny=16,viscosity=.1)
local = r.FixedPatch2D.from_config(cfg,(4,4,12,12))
global_parent = r.FixedPatchSet2D.from_config(cfg,[(4,4,12,12)])
a, ak = r.nest_patch(cfg,local,(8,8,16,16))
b, bk = r.nest_patch_set(cfg,global_parent,[(12,12,20,20)])
local_set, local_kind = r.nest_patch_set(cfg,local,[(8,8,16,16)])
np.testing.assert_array_equal(ak,local_kind)
np.testing.assert_array_equal(a.fine_kind,local_set.patches[0].fine_kind)
np.testing.assert_array_equal(a.ghost_coarse,local_set.ghost_coarse)
np.testing.assert_array_equal(a.coalesce_coarse,local_set.coalesce_coarse)
np.testing.assert_array_equal(ak, bk[4:28,4:28])
# L2 node-index offsets in global L2 grid: local parent origin=2 L0.
ay,ax=np.nonzero(a.fine_kind == r.ACTIVE)
by,bx=np.nonzero(b.fine_kind == r.ACTIVE)
np.testing.assert_array_equal(np.stack([ay+20,ax+20],-1),np.stack([by,bx],-1))
np.testing.assert_array_equal(a.fine_kind,b.fine_kind[20:44,20:44])
# Compare complete per-region maps after undoing set addressing.
bp=b.patches[0]
np.testing.assert_array_equal(a.coalesce_fine,bp.coalesce_fine)
aq,ac=np.divmod(a.coalesce_coarse,24*24)
bq,bc=np.divmod(bp.coalesce_coarse,32*32)
np.testing.assert_array_equal(aq,bq)
np.testing.assert_array_equal(np.stack(np.divmod(ac,24),-1)+4,np.stack(np.divmod(bc,32),-1))
ay,ax=np.divmod(a.ghost_coarse,24)
by,bx=np.divmod(bp.ghost_coarse,32)
np.testing.assert_array_equal(np.stack([ay+4,ax+4],-1),np.stack([by,bx],-1))
def test_twin_tile_chains_and_order(self):
children=((16,16,32,40),(56,16,72,40))
child,kind=r.nest_patch_set(CFG,self.parent,children)
other,ok=r.nest_patch_set(CFG,r.FixedPatchSet2D.from_config(CFG,PARENTS[::-1]),children[::-1])
for name in ('fine_kind','ghost_fine','ghost_coarse','coalesce_coarse','coalesce_fine'):
np.testing.assert_array_equal(getattr(child,name),getattr(other,name))
np.testing.assert_array_equal(kind,ok)
for p in child.patches:
self.assertEqual(p.fine_shape,child.patches[0].fine_shape)
np.testing.assert_array_equal(child.patches[0].fine_kind,child.patches[1].fine_kind)
self.assertEqual(child.tau_coarse,self.parent.tau_fine)
self.assertAlmostEqual(child.tau_fine,1.7)
def test_asymmetric_no_child_region(self):
child,kind=r.nest_patch_set(CFG,self.parent,[CHILDREN[0]])
np.testing.assert_array_equal(kind[:,48:],self.parent.fine_kind[:,48:])
self.assertEqual(int(child.covered.sum()),16*24)
def test_two_children_one_parent(self):
child,kind=r.nest_patch_set(CFG,self.parent,[(16,16,32,24),(16,28,32,40)])
self.assertEqual(len(child.patches),2)
self.assertEqual(int(child.covered.sum()),16*20)
self.assertEqual(int((kind==r.ACTIVE).sum()),int((self.parent.fine_kind==r.ACTIVE).sum())-16*20)
def test_holes_donors_receivers_footprint_and_invalid_pull(self):
child,_=r.nest_patch_set(CFG,self.parent,CHILDREN)
cases=[('inherited ACTIVE', lambda: r.nest_patch_set(CFG,self.parent,[(40,16,48,40)])),
('donors/receivers', lambda: r.nest_patch_set(CFG,self.parent,[(8,16,32,40)]))]
for text,call in cases:
with self.subTest(text=text),self.assertRaisesRegex(ValueError,text):call()
# Synthetic upstream dependency inside child coverage isolates the final guard.
upstream=self.parent.coalesce_fine.copy()
upstream[0,:]=20*self.parent.fine_shape[1]+20 # q=0, deep inherited ACTIVE
with self.assertRaisesRegex(ValueError,'restriction footprint'):
r._nested_kind(replace(self.parent,coalesce_fine=upstream),child)
hole=int(np.flatnonzero(~self.parent.fine_layout((4,4)).owned.ravel())[0])
donors=child.ghost_coarse.copy();donors[0]=hole
with self.assertRaisesRegex(ValueError,'donors/receivers'):
r._nested_kind(self.parent,replace(child,ghost_coarse=donors))
receivers=child.coalesce_coarse.copy();receivers[0]=hole
with self.assertRaisesRegex(ValueError,'donors/receivers'):
r._nested_kind(self.parent,replace(child,coalesce_coarse=receivers))
# Remove all supplying rows; covered-to-uncovered pulls must be rejected.
with self.assertRaisesRegex(ValueError,'surviving parent ACTIVE pull'):
r._nested_kind(self.parent,replace(child,coalesce_coarse=child.coalesce_coarse[:0],coalesce_fine=child.coalesce_fine[:0]))
def test_existing_l2_body_jet_preparation_on_union(self):
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.body.jets import Jet, Tube, Parallel, jet_link_shapes
child,_=r.nest_patch_set(CFG,self.parent,CHILDREN)
centers=((12.,14.),(34.,14.))
objects=[SimObject(99,geometry=CircleGeometry(*c,.8),center=c,radius=.8) for c in centers]
jets=[Jet(Tube((c[0]-1.5,c[1]),0.,3.,.3),Parallel(0.),amplitude=.002) for c in centers]
middle=replace(CFG,nx=self.parent.fine_shape[1],ny=self.parent.fine_shape[0],viscosity=.2)
boundary=r.prepare_fine_boundary(middle,child,objects,jets=jets,
parent_origin=self.parent.fine_origin,parent_spacing=.5)
ids=boundary.curved_host[3]
np.testing.assert_array_equal(np.unique(ids),[0,1])
self.assertEqual(int((ids==0).sum()),int((ids==1).sum()))
np.testing.assert_allclose(boundary.curved_hits[ids==1]-boundary.curved_hits[ids==0],
np.broadcast_to([22.,0.],boundary.curved_hits[ids==0].shape),atol=1e-14,rtol=0)
_,jid=jet_link_shapes(jets,boundary.curved_hits)
for i in (0,1):
self.assertTrue((ids[jid==i]==i).all())
self.assertTrue((jid==i).any())
def test_sparse_parent_tables_preserve_inherited_kinds(self):
child,kind=r.nest_patch_set(CFG,self.parent,CHILDREN)
layout=self.parent.fine_layout((4,4));order=np.argsort(child.coalesce_coarse)
donors,targets,bkind=host.blocked_base_tables(child,layout,order,composed_kind=kind)
self.assertTrue((donors < layout.hole).all())
self.assertTrue((targets < np.prod(layout.blocked_shape)).all())
np.testing.assert_array_equal(bkind[layout.owner[layout.owned]],kind[layout.owned])
inherited=self.parent.fine_kind != r.ACTIVE
np.testing.assert_array_equal(kind[inherited],self.parent.fine_kind[inherited])
self.assertTrue((kind[child.covered] == r.OUTER_GHOST).all())
l2=child.fine_layout((4,4))
ghosts,children,ckind=host.blocked_fine_tables(child,l2,order)
self.assertTrue((ghosts<l2.hole).all())
self.assertTrue((children<np.prod(l2.blocked_shape)).all())
for p,lay in ((self.parent,layout),(child,l2)):
self.assertTrue(lay.owned.ravel()[p.ghost_fine].all())
self.assertTrue(lay.owned.ravel()[p.coalesce_fine % np.uint64(np.prod(p.fine_shape))].all())
hole=int(np.flatnonzero(~layout.owned.ravel())[0])
bad=child.ghost_coarse.copy();bad[0]=hole
with self.assertRaisesRegex(ValueError,'owned uncovered'):
host.blocked_base_tables(replace(child,ghost_coarse=bad),layout,order,composed_kind=kind)
bad=child.coalesce_coarse.copy();bad[0]=hole
with self.assertRaisesRegex(ValueError,'owned uncovered'):
host.blocked_base_tables(replace(child,coalesce_coarse=bad),layout,order,composed_kind=kind)
def bodies_and_jets(speed=.002):
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.body.jets import Jet, Tube, Parallel
centers=((12.,14.),(34.,14.))
bodies=[SimObject(99,geometry=CircleGeometry(*c,.8),center=c,radius=.8) for c in centers]
jets=[Jet(Tube((c[0]-1.5,c[1]),0.,3.,.3),Parallel(0.),amplitude=speed) for c in centers]
return bodies,jets
KW=dict(base_block=(4,4),fine_block=(4,4),child_block=(4,4),
child_rectangles_in_parent=CHILDREN)
def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_upload=False):
module=host_module()
context=Mock(activate=nullcontext,sm_arch='sm_70')
allocations=[]
def allocate(size):
pointer=Mock(name=f'allocation_{len(allocations)}')
allocations.append(pointer)
return pointer
module.cuda.mem_alloc.side_effect=allocate
def load(ptx):
gpu=Mock()
gpu.get_function.side_effect=lambda name: Mock(name=name)
gpu.get_global.side_effect=lambda name: (Mock(),20 if name=='d_params' else 16)
return gpu
module.compiler.load_module.side_effect=load
if fail_compile:
module.compiler.compile_kernel.side_effect=['first','second',RuntimeError('L2 compile failed')]
if fail_upload:
def upload(*args):
if len(allocations)>20:
raise RuntimeError('table upload failed')
module.cuda.memcpy_htod.side_effect=upload
objects,jets=bodies_and_jets() if bodies else ((),())
try:
run=module.FixedPatchStepper(CFG,PARENTS,context=context,
child_objects=objects,child_jets=jets,
**dict(KW,child_rectangles_in_parent=children))
except RuntimeError:
for allocation in allocations:
allocation.free.assert_called_once_with()
raise
return run,module,allocations
class ConstructorAndSchedule(unittest.TestCase):
def test_invalid_inputs_fail_before_context_or_allocations(self):
context=Mock()
cases=[dict(child=(8,8,16,16)),dict(child_rectangles_in_parent=[]),
dict(child_rectangles_in_parent=[(16,16,32)]),
dict(child_rectangles_in_parent=[(16.5,16,32,40)]),
dict(child_rectangles_in_parent=[(16,16,32,24),(16,27,32,40)]),
dict(child_rectangles_in_parent=[(40,16,48,40)]),
dict(child_rectangles_in_parent=[(8,16,32,40)]),
dict(child_block=None),dict(fine_block=None),dict(base_block=None),
dict(child_block=(5,4)),dict(fine_block=(5,4)),
dict(child_block=(4,)),dict(child_rectangles_in_parent=(16,16,32,40))]
for changes in cases:
with self.subTest(changes=changes),self.assertRaises((ValueError,TypeError)):
host.FixedPatchStepper(CFG,PARENTS,context=context,**dict(KW,**changes))
with self.assertRaisesRegex(ValueError,'closer'):
host.FixedPatchStepper(CFG,[(4,4,20,28),(23,4,44,28)],context=context,**KW)
objects,jets=bodies_and_jets()
with self.assertRaisesRegex(ValueError,'only on L2'):
host.FixedPatchStepper(CFG,PARENTS,context=context,fine_objects=objects,**KW)
from CelerisLab.body.jets import Jet,Tube,Parallel
spanning=Jet(Tube((10.5,14.),0.,25.,.3),Parallel(0.),amplitude=0.)
with self.assertRaisesRegex(ValueError,'one jet'):
host.FixedPatchStepper(CFG,PARENTS,context=context,child_objects=objects,
child_jets=[spanning],**KW)
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
def test_aggregate_schedule_independent_of_region_count(self):
for children,bodies in ((CHILDREN,True),(CHILDREN[:1],False),
(((16,16,32,24),(16,28,32,40)),False)):
run,_,allocations=mock_backend(children=children,bodies=bodies)
self.assertEqual(len(run._levels),3)
c,f,g=run._levels
self.assertEqual(len({id(level.current) for level in run._levels} |
{id(level.spare) for level in run._levels}),6)
events=[]
initial_c=c.current
def record(name):
return lambda source,*a,**kw: events.append((name,source))
run._explode.side_effect=record('explode01')
run._child.explode.side_effect=record('explode12')
for i,level in enumerate(run._levels):
level.kernel.side_effect=record(f'step{i}')
if bodies:
g.boundary.kernel.side_effect=record('bfl2')
run._child.coalesce.side_effect=lambda src,dst,*a,**kw: events.append(('restrict21',dst))
run._coalesce.side_effect=lambda src,dst,*a,**kw: events.append(('restrict10',dst))
run._status='ready'
expected=[('explode01',initial_c)]
for middle in (f.current,f.spare):
expected.append(('explode12',middle))
for leaf in (g.current,g.spare):
expected.append(('halo2',leaf))
if bodies:
expected.append(('bfl2',leaf))
expected.append(('step2',leaf))
expected.extend([('halo1',middle),('restrict21',middle),('step1',middle)])
expected.extend([('halo0',initial_c),('restrict10',initial_c),('step0',initial_c)])
def gather(level,buffer,**kw):
events.append((f'halo{next(i for i,l in enumerate(run._levels) if l is level)}',buffer))
with patch.object(run,'_gather_halo',side_effect=gather):
run.step()
self.assertEqual(events,expected)
self.assertEqual([l.kernel.call_count for l in run._levels],[1,2,4])
# A stale-feed implementation must fail the source-identity guard.
feeds=[]
advance=run._advance_child
def checked_advance(buffer, **kw):
self.assertIs(buffer,(f.current,f.spare)[len(feeds)])
feeds.append(buffer)
advance(buffer,**kw)
with patch.object(run,'_advance_child',side_effect=lambda buffer,**kw:
checked_advance(f.current,**kw)):
with self.assertRaises(AssertionError):
run.step()
# A per-region shared-parent advance must fail the 1:2:4 guard.
run._status='ready'
counts=[0,0,0]
def count(index, multiple=1):
def step(*a,**kw):
counts[index]+=multiple
return step
c.kernel.side_effect=count(0)
f.kernel.side_effect=count(1,2) # mutant: twice per shared L1 hop
g.kernel.side_effect=count(2)
run.step()
with self.assertRaises(AssertionError):
self.assertEqual(counts,[1,2,4])
run.close();run.close()
for allocation in allocations:
allocation.free.assert_called_once_with()
def test_partial_construction_frees_every_allocation(self):
for options in (dict(fail_compile=True),dict(fail_upload=True)):
with self.subTest(options=options),self.assertRaisesRegex(RuntimeError,'failed'):
mock_backend(**options)
if __name__=='__main__':
unittest.main()
@@ -0,0 +1,178 @@
"""Frozen GPU gates for the bounded three-level separated-union backend.
Run explicitly on an available GPU; CPU geometry/mocks live in tests/unit.
Snapshots 0/1/2/8: finite public leaves, exact NaN masks, raw invalid cores;
reversed order and aligned layout changes must be bit-identical. The scalar
window comparison uses root physical coordinates, never equal array indices.
These equivalence checks supplement, not replace, independent dense CPU replay.
"""
from dataclasses import replace
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.descriptors import SOLID
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
from CelerisLab.lbm.refinement import OUTER_GHOST
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from test_refinement_blocked_base import raw, exact
CFG = LBMConfig(nx=48, ny=32, viscosity=.1)
PARENTS = ((4, 4, 20, 28), (24, 4, 44, 28))
CHILDREN = ((16, 16, 32, 40), (56, 16, 80, 40))
TOPOLOGIES = {
'twins': ((16, 16, 32, 40), (56, 16, 72, 40)),
'asymmetric': (CHILDREN[0],),
'multiple_children': ((16, 16, 32, 24), (16, 28, 32, 40)),
}
# 8-wide blocks require support edges divisible by 8, i.e. rectangle edges
# equal 2 modulo 4 in each pair's parent frame. The default fixture is 4-only.
ALIGNED_CFG = LBMConfig(nx=56, ny=40, viscosity=.1)
ALIGNED_PARENTS = ((6, 6, 22, 34), (26, 6, 50, 34))
ALIGNED_CHILDREN = ((18, 18, 34, 50), (58, 18, 90, 50))
STOPS = (0, 1, 2, 8)
def config(cfg, mode):
return cfg if mode == 'periodic' else replace(
cfg, velocity=0., wall_normal_velocity=.001, wall_slot_width=4,
y_wall_bc='on_site_velocity', top_wall_bc='reservoir')
def bodies():
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
return [SimObject(99, geometry=CircleGeometry(x, 14., .8), center=(x, 14.), radius=.8)
for x in (12., 34.)]
def jets(speed):
from CelerisLab.body.jets import Jet, Tube, Parallel
return [Jet(Tube((x-1.5, 14.), 0., 3., .3), Parallel(0.), amplitude=speed)
for x in (12., 34.)]
def frames(run):
middle_origin = np.asarray(run.patch.fine_origin)
return ((np.zeros(2), 1.), (middle_origin, .5),
(middle_origin + .5*np.asarray(run.child_patch.fine_origin), .25))
def physical_state(shape, origin, spacing):
"""Same smooth non-equilibrium G at the same physical cell centers."""
y, x = np.indices(shape, dtype=np.float64)
x, y = origin[0] + spacing*(x+.5), origin[1] + spacing*(y+.5)
equilibrium = np.asarray(compute_feq_d2q9(1., .013, -.007), np.float64)[:, None, None]
q = np.arange(9)[:, None, None]
return (equilibrium + 1e-4*np.sin(.17*x + .23*y + q)).astype(np.float32)
def physical_leaves(array, origin, spacing):
y, x = np.nonzero(np.isfinite(array[0]))
coordinates = np.stack((origin[1]+spacing*(y+.5), origin[0]+spacing*(x+.5)), axis=-1)
return coordinates, array[:, y, x]
class ThreeLevelMultipatchGPU(unittest.TestCase):
def run_case(self, ctx, cfg=CFG, parents=PARENTS, children=CHILDREN,
mode='periodic', blocks=((4, 4), (4, 4)), objects=(), drive=(), switch=False,
scalar_child=None):
kwargs = dict(child_rectangles_in_parent=children) if scalar_child is None else dict(child=scalar_child)
with FixedPatchStepper(config(cfg, mode), parents, context=ctx, coarse_boundary=mode,
base_block=(4, 4), fine_block=blocks[0], child_block=blocks[1],
child_objects=objects, child_jets=drive, **kwargs) as run:
frame = frames(run)
run.initialize(*(physical_state(level.shape[1:], *f)
for level, f in zip(run._levels, frame)))
snapshots = []
for stop in STOPS:
run.step(stop-run.step_count)
if switch and stop == 2:
for index in range(len(drive)):
run.set_jet_amplitude(index, -.002, level=2)
public, stored = run.download(), raw(run)
masks = [~run.patch.covered, run._fine_leaf, run._child_leaf]
if mode != 'periodic':
masks[0][[0, -1]] = False
invalid = [run.patch.covered.copy(), run._middle_kind == OUTER_GHOST,
run.child_patch.fine_kind == OUTER_GHOST]
if mode != 'periodic':
invalid[0][[0, -1]] = True
if run.child_boundary is not None:
invalid[2] |= (run.child_boundary.flags.reshape(run.child_patch.fine_shape) & SOLID) != 0
for level, (g, mask, dense, bad) in enumerate(zip(public, masks, stored, invalid)):
label = f'{mode} stop={stop} level={level}'
self.assertTrue(np.isfinite(g[:, mask]).all(), label)
self.assertTrue(np.isnan(g[:, ~mask]).all(), label)
self.assertTrue(np.isnan(dense[:, bad]).all(), label)
snapshots.append((public, stored, frame))
return snapshots
def compare(self, actual, expected):
for stop, a, b in zip(STOPS, actual, expected):
for kind in (0, 1):
for level, (x, y) in enumerate(zip(a[kind], b[kind])):
exact(self, x, y, f'stop={stop} kind={kind} level={level}')
def test_topologies_reversed_order_and_masks(self):
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx:
for mode in ('periodic', 'slot_reservoir'):
for name, children in TOPOLOGIES.items():
with self.subTest(mode=mode, topology=name):
expected = self.run_case(ctx, children=children, mode=mode)
actual = self.run_case(ctx, parents=PARENTS[::-1], children=children[::-1], mode=mode)
self.compare(actual, expected)
def test_independently_varied_aligned_layouts(self):
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx:
for mode in ('periodic', 'slot_reservoir'):
kwargs = dict(cfg=ALIGNED_CFG, parents=ALIGNED_PARENTS,
children=ALIGNED_CHILDREN, mode=mode)
expected = self.run_case(ctx, **kwargs)
for middle in ((4, 4), (8, 4), (4, 8)):
for leaf in ((4, 4), (8, 4), (4, 8)):
self.compare(self.run_case(ctx, blocks=(middle, leaf), **kwargs), expected)
def test_one_element_equivalent_physical_frames(self):
from CelerisLab.cuda.context import CudaContext
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
parent = (4, 4, 12, 12)
with CudaContext(0) as ctx:
for mode in ('periodic', 'slot_reservoir'):
old = self.run_case(ctx, cfg=cfg, parents=parent, mode=mode, scalar_child=(8, 8, 16, 16))
# Test both set-on-scalar and set-on-set allocated frames.
for parents, child in ((parent, (8, 8, 16, 16)), ((parent,), (12, 12, 20, 20))):
new = self.run_case(ctx, cfg=cfg, parents=parents, children=[child], mode=mode)
for a, b in zip(new, old):
for level in range(3):
ax, av = physical_leaves(a[0][level], *a[2][level])
bx, bv = physical_leaves(b[0][level], *b[2][level])
np.testing.assert_array_equal(ax, bx)
exact(self, av, bv, f'physical level={level}')
def test_two_l2_bodies_zero_driven_switched_order_and_layout(self):
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx:
for mode in ('periodic', 'slot_reservoir'):
kwargs = dict(cfg=ALIGNED_CFG, parents=ALIGNED_PARENTS,
children=ALIGNED_CHILDREN, mode=mode, objects=bodies())
static = self.run_case(ctx, **kwargs)
zero = self.run_case(ctx, drive=jets(0.), **kwargs)
self.compare(zero, static)
driven = self.run_case(ctx, drive=jets(.002), **kwargs)
switched = self.run_case(ctx, drive=jets(.002), switch=True, **kwargs)
self.assertFalse(np.array_equal(driven[-1][0][2], static[-1][0][2], equal_nan=True))
self.assertFalse(np.array_equal(driven[-1][0][2], switched[-1][0][2], equal_nan=True))
for drive, switch, expected in (((), False, static), (jets(0.), False, zero),
(jets(.002), False, driven), (jets(.002), True, switched)):
for middle, leaf in (((8, 4), (4, 8)), ((4, 8), (8, 4))):
actual = self.run_case(ctx, blocks=(middle, leaf), drive=drive, switch=switch,
**dict(kwargs, parents=ALIGNED_PARENTS[::-1], children=ALIGNED_CHILDREN[::-1]))
self.compare(actual, expected)
if __name__ == '__main__':
unittest.main()