Blocked fine storage: passive stationary circles via receiver-local BFL

FixedPatchStepper(fine_block=..., fine_objects=[circle]) now accepts
passive stationary circles; any surface actuation is rejected before CUDA
(dense fine path unchanged). prepare_fine_boundary still decides legality
on the dense fine grid; its SoA is mapped by the new array-based storage
adapter blocks.blocked_link_tables (map_cut_links: unique receiver owner
and local writer, row order kept; blocked flags sized nblocks*L), which
PeriodicBlockStepper._static_circles now also uses (single-level legality
builder unchanged). Per fine hop on the same buffer: halo -> existing
BlockCurvedBoundary -> BlockStep(flags, kind). No CUDA changes.
Tests: fixture 76 links / 68 q<.5 and 8 seam writes + 8 seam donors per
decomposed layout (both modes); adapter flags sizing; mocked launch-order
contract; GPU: periodic and slot_reservoir, 8 coarse steps, dense fine
body vs 1x1/2x2/4x2 fine blocks, exact values + NaN masks on masked
downloads and raw final arrays, ACTIVE fluid finite, solid/OUTER NaN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-15 04:25:09 +00:00
1 parent 59921a0181
commit 2bbf82121e
4 files changed
+181 -21

No files matched your search

+7 -10
View File
@@ -19,7 +19,7 @@ import numpy as np
import pycuda.driver as cuda import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler from ..cuda import compiler_v2 as compiler
from .blocks import NQ, BlockLayout2D from .blocks import NQ, BlockLayout2D, blocked_link_tables
from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks) MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks)
@@ -64,15 +64,12 @@ def _static_circles(layout, objects):
for q in range(1, NQ) if solid[source(r, q)]} for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required: if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once") raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
mapped = layout.map_cut_links(links) # unique receiver owner and local writer tables, _ = blocked_link_tables(
cells = int(np.prod(layout.local_shape)) layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
blocked_flags = np.zeros(layout.nblocks * cells, np.uint16) # halo entries unused np.array([link.dir for link in links], np.uint8),
blocked_flags[layout.owner.ravel().astype(np.intp)] = flags np.array([link.q for link in links], np.float32),
return dict(solid=solid, flags=blocked_flags, block=mapped.block, np.array([link.fallback for link in links], np.uint8))
cell=(mapped.receiver % cells).astype(np.uint32), return dict(solid=solid, **tables)
dir=np.array([link.dir for link in links], np.uint8),
q=np.array([link.q for link in links], np.float32),
fallback=np.array([link.fallback for link in links], np.uint8))
class PeriodicBlockStepper: class PeriodicBlockStepper:
+28
View File
@@ -27,6 +27,7 @@ No moving geometry is implied.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from numbers import Integral from numbers import Integral
from types import SimpleNamespace
import numpy as np import numpy as np
@@ -266,3 +267,30 @@ class BlockLayout2D:
or not blocked.flags.c_contiguous: or not blocked.flags.c_contiguous:
raise ValueError(f"expected C-contiguous blocked array {self.blocked_shape}") raise ValueError(f"expected C-contiguous blocked array {self.blocked_shape}")
return blocked return blocked
def blocked_link_tables(layout, flags, fluid_idx, dir, q, fallback):
"""Storage adapter: dense static cut-link SoA + dense flags -> blocked tables.
Legality (geometry, coverage, transfer isolation) stays with the caller's
own builder; this only maps storage via map_cut_links (unique receiver
owner and local writer, input row order kept). Returns (tables, mapped):
tables holds device-ready arrays, with flags sized nblocks*L (unused halo
entries 0) and cell = the receiver's local core cell.
"""
flags = np.asarray(flags)
if flags.shape != (int(np.prod(layout.shape)),) or flags.dtype != np.uint16:
raise ValueError("flags must be dense uint16 with one entry per grid cell")
columns = [np.asarray(fluid_idx), np.asarray(dir), np.asarray(q), np.asarray(fallback)]
if len({len(c) for c in columns}) != 1:
raise ValueError("cut-link columns must have equal length")
mapped = layout.map_cut_links(SimpleNamespace(fluid_idx=r, dir=d, q=v, fallback=f)
for r, d, v, f in zip(*columns))
cells = int(np.prod(layout.local_shape))
blocked_flags = np.zeros(layout.nblocks * cells, np.uint16)
blocked_flags[layout.owner.ravel().astype(np.intp)] = flags
tables = dict(flags=blocked_flags, block=mapped.block,
cell=(mapped.receiver % cells).astype(np.uint32),
dir=columns[1].astype(np.uint8), q=columns[2].astype(np.float32),
fallback=columns[3].astype(np.uint8))
return tables, mapped
+33 -6
View File
@@ -14,7 +14,7 @@ import numpy as np
import pycuda.driver as cuda import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler from ..cuda import compiler_v2 as compiler
from .blocks import BlockLayout2D from .blocks import BlockLayout2D, blocked_link_tables
from .block_stepper import MAX_GRID_DIM from .block_stepper import MAX_GRID_DIM
from .refinement import ACTIVE, HALO, OUTER_GHOST, FixedPatch2D, prepare_fine_boundary from .refinement import ACTIVE, HALO, OUTER_GHOST, FixedPatch2D, prepare_fine_boundary
from .descriptors import SOLID from .descriptors import SOLID
@@ -65,12 +65,15 @@ class FixedPatchStepper:
On a device/launch failure the instance becomes unusable: close and recreate On a device/launch failure the instance becomes unusable: close and recreate
it, rather than pretending the partially advanced fine buffer rolled back. it, rather than pretending the partially advanced fine buffer rolled back.
``fine_block=(by, bx)`` (opt-in, body-free only) stores the fine level in ``fine_block=(by, bx)`` (opt-in) stores the fine level in
blocks.cu storage: the ENTIRE fine_shape, including the four-cell patch blocks.cu storage: the ENTIRE fine_shape, including the four-cell patch
ghost band, is partitioned by a non-periodic BlockLayout2D with one-cell ghost band, is partitioned by a non-periodic BlockLayout2D with one-cell
same-level halos. Patch kinds stay per cell (OUTER invalid, INNER stream same-level halos. Patch kinds stay per cell (OUTER invalid, INNER stream
only, ACTIVE collide); halos are gathered before each fine hop. Public only, ACTIVE collide); halos are gathered before each fine hop. Public
arrays keep the dense (9, ny, nx) fine shape; only the allocation differs. arrays keep the dense (9, ny, nx) fine shape; only the allocation differs.
Blocked fine_objects must be passive stationary circles (no actuation):
prepare_fine_boundary decides legality on the dense fine grid, then cut
links are staged receiver-local per hop: halo -> BFL -> step, same buffer.
""" """
def __init__(self, cfg, bounds, *, context, coarse_boundary="periodic", fine_objects=(), def __init__(self, cfg, bounds, *, context, coarse_boundary="periodic", fine_objects=(),
fine_block=None): fine_block=None):
@@ -90,8 +93,8 @@ class FixedPatchStepper:
fine_objects = tuple(fine_objects) fine_objects = tuple(fine_objects)
self.fine_layout = None self.fine_layout = None
if fine_block is not None: if fine_block is not None:
if fine_objects: if any(getattr(obj, "surface_actuation", None) is not None for obj in fine_objects):
raise ValueError("fine_block storage is body-free; fine_objects need the dense fine level") raise ValueError("fine_block supports passive circles only; actuation needs the dense fine level")
if len(fine_block) != 2: if len(fine_block) != 2:
raise ValueError("fine_block must be (by, bx)") raise ValueError("fine_block must be (by, bx)")
fy, fx = self.patch.fine_shape fy, fx = self.patch.fine_shape
@@ -101,6 +104,11 @@ class FixedPatchStepper:
raise ValueError("fine blocks exceed launch limits") raise ValueError("fine blocks exceed launch limits")
self.fine_boundary = prepare_fine_boundary( self.fine_boundary = prepare_fine_boundary(
cfg, self.patch, fine_objects, wall_aware=self._wall_aware) cfg, self.patch, fine_objects, wall_aware=self._wall_aware)
self._fine_tables = None
if self.fine_layout is not None and self.fine_boundary is not None:
fluid_idx, dirs, q, _, _, _, _, fallback = self.fine_boundary.curved_host
self._fine_tables, _ = blocked_link_tables(
self.fine_layout, self.fine_boundary.flags, fluid_idx, dirs, q, fallback)
self._fine_leaf = self.patch.fine_kind == ACTIVE self._fine_leaf = self.patch.fine_kind == ACTIVE
if self.fine_boundary is not None: if self.fine_boundary is not None:
self._fine_leaf &= (self.fine_boundary.flags.reshape(self.patch.fine_shape) & SOLID) == 0 self._fine_leaf &= (self.fine_boundary.flags.reshape(self.patch.fine_shape) & SOLID) == 0
@@ -113,6 +121,7 @@ class FixedPatchStepper:
self._stream = None self._stream = None
self._fine_flags = self._fine_curved = self._curved = None self._fine_flags = self._fine_curved = self._curved = None
self._kind = self._halo_copy = self._halo_dst = self._halo_src = None self._kind = self._halo_copy = self._halo_dst = self._halo_src = None
self._link_tables = {}
self._block = (cfg.threads_per_block, 1, 1) self._block = (cfg.threads_per_block, 1, 1)
fine_cfg = replace(cfg, nx=self.patch.fine_shape[1], ny=self.patch.fine_shape[0], fine_cfg = replace(cfg, nx=self.patch.fine_shape[1], ny=self.patch.fine_shape[0],
viscosity=2 * cfg.viscosity) viscosity=2 * cfg.viscosity)
@@ -173,7 +182,12 @@ class FixedPatchStepper:
module = self._levels[1].module module = self._levels[1].module
self._explode = module.get_function("BlockExplode") self._explode = module.get_function("BlockExplode")
self._halo_copy = module.get_function("BlockHaloCopy") self._halo_copy = module.get_function("BlockHaloCopy")
if self.fine_boundary is not None: if self._fine_tables is not None:
self._fine_flags = self._upload(self._fine_tables["flags"])
self._link_tables = {k: self._upload(v) for k, v in self._fine_tables.items()
if k != "flags"}
self._curved = self._levels[1].module.get_function("BlockCurvedBoundary")
elif self.fine_boundary is not None:
from .curved_links import CurvedLinkSoA from .curved_links import CurvedLinkSoA
boundary = self.fine_boundary boundary = self.fine_boundary
self._fine_flags = self._upload(boundary.flags) self._fine_flags = self._upload(boundary.flags)
@@ -266,6 +280,15 @@ class FixedPatchStepper:
cl.link_exchange_gpu if cl.link_exchange_gpu is not None else np.uintp(0), cl.link_exchange_gpu if cl.link_exchange_gpu is not None else np.uintp(0),
grid=((cl.count + self._block[0] - 1) // self._block[0], 1), **kw) grid=((cl.count + self._block[0] - 1) // self._block[0], 1), **kw)
def _blocked_fine_boundary(self, buffer, **kw):
if self._fine_tables is None:
return
if self._curved is None or self._fine_flags is None:
raise RuntimeError("Fine boundary device buffers are missing")
t, count = self._link_tables, len(self._fine_tables["cell"])
self._curved(buffer, t["block"], t["cell"], t["dir"], t["q"], t["fallback"],
np.uint32(count), grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
def _fine_halo(self, buffer, **kw): def _fine_halo(self, buffer, **kw):
pairs = len(self.fine_layout.halo_dst) pairs = len(self.fine_layout.halo_dst)
if pairs: if pairs:
@@ -290,9 +313,12 @@ class FixedPatchStepper:
**kw) **kw)
if self.fine_layout is not None: if self.fine_layout is not None:
# Gather one-cell block halos before EACH fine hop. # Gather one-cell block halos before EACH fine hop.
# Same buffer per hop: halo -> receiver-local BFL -> step.
flags = self._fine_flags if self._fine_flags is not None else np.uintp(0)
for src, dst in ((f.current, f.spare), (f.spare, f.current)): for src, dst in ((f.current, f.spare), (f.spare, f.current)):
self._fine_halo(src, **kw) self._fine_halo(src, **kw)
f.kernel(src, dst, np.uint32(self.fine_layout.halo), np.uintp(0), self._blocked_fine_boundary(src, **kw)
f.kernel(src, dst, np.uint32(self.fine_layout.halo), flags,
self._kind, grid=f.grid, **kw) self._kind, grid=f.grid, **kw)
else: else:
flags = self._fine_flags if self._fine_flags is not None else np.uintp(0) flags = self._fine_flags if self._fine_flags is not None else np.uintp(0)
@@ -355,6 +381,7 @@ class FixedPatchStepper:
error = error or exc error = error or exc
self._fine_curved = None self._fine_curved = None
self._curved = self._fine_flags = None self._curved = self._fine_flags = None
self._link_tables = {}
for pointer in self._allocations: for pointer in self._allocations:
try: try:
pointer.free() pointer.free()
@@ -13,12 +13,14 @@ import numpy as np
import pycuda.driver as cuda import pycuda.driver as cuda
from CelerisLab.body.geometry.circle import CircleGeometry from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject from CelerisLab.body.objects import SimObject, SurfaceActuation
from CelerisLab.config import LBMConfig from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler_v2 as compiler from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm.blocks import BlockLayout2D from CelerisLab.lbm.blocks import NO_DONOR, BlockLayout2D, blocked_link_tables
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import ACTIVE, INNER_GHOST, OUTER_GHOST, FixedPatch2D from CelerisLab.lbm.descriptors import SOLID
from CelerisLab.lbm.refinement import (ACTIVE, INNER_GHOST, OUTER_GHOST, FixedPatch2D,
prepare_fine_boundary)
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, blocked_fine_tables from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, blocked_fine_tables
NX, NY, BOUNDS, STEPS = 32, 24, (8, 6, 24, 18), 8 NX, NY, BOUNDS, STEPS = 32, 24, (8, 6, 24, 18), 8
@@ -45,6 +47,27 @@ def state(ny, nx, seed):
return (feq + noise).astype(np.float32) return (feq + noise).astype(np.float32)
def circle(actuation=None):
"""Astra fixture: coarse cell-edge C=(16,12), R=2.15 -> fine C=(19.5,15.5), R=4.3."""
return SimObject(0, geometry=CircleGeometry(16.0, 12.0, 2.15), center=(16.0, 12.0),
radius=2.15, surface_actuation=actuation)
def fine_circle(mode):
cfg = config(mode)
patch = FixedPatch2D.from_config(cfg, BOUNDS)
return patch, prepare_fine_boundary(cfg, patch, (circle(),), wall_aware=mode == "slot_reservoir")
def seam_cells(layout, mapped):
"""Distinct non-core cells written / read as q<.5 donors (receiver-local halos)."""
L = int(np.prod(layout.local_shape))
core = set(layout.owner.ravel().tolist())
writes = {int(w) // (9 * L) * L + int(w) % L for w in mapped.write}
donors = {int(d) for d in mapped.donor if d != NO_DONOR}
return len(writes - core), len(donors - core)
def raw(backend): def raw(backend):
"""Unmasked device arrays; blocked fine storage unpacked from owner cores.""" """Unmasked device arrays; blocked fine storage unpacked from owner cores."""
coarse, fine = backend._levels coarse, fine = backend._levels
@@ -63,9 +86,9 @@ class BlockedFineCPU(unittest.TestCase):
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70") return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
def test_rejections_before_cuda(self): def test_rejections_before_cuda(self):
circle = SimObject(0, geometry=CircleGeometry(16.0, 12.0, 2.0), center=(16.0, 12.0), radius=2.0) actuated = circle(SurfaceActuation(0.0, 0.5, 0.002))
for kw in ({"fine_block": (7, 7)}, {"fine_block": (16,)}, for kw in ({"fine_block": (7, 7)}, {"fine_block": (16,)},
{"fine_block": (16, 20), "fine_objects": [circle]}): {"fine_block": (16, 20), "fine_objects": [actuated]}):
with self.subTest(kw=kw), self.assertRaises(ValueError): with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(config("periodic"), BOUNDS, context=self.forbidden_context(), **kw) FixedPatchStepper(config("periodic"), BOUNDS, context=self.forbidden_context(), **kw)
@@ -94,6 +117,64 @@ class BlockedFineCPU(unittest.TestCase):
backend.close() backend.close()
self.assertTrue(sizes) self.assertTrue(sizes)
def test_circle_fixture_counts_and_seam_staging(self):
for mode in ("periodic", "slot_reservoir"):
patch, boundary = fine_circle(mode)
fluid_idx, dirs, q, _, _, _, _, fallback = boundary.curved_host
self.assertEqual(len(fluid_idx), 76, mode)
self.assertEqual(int(np.count_nonzero(q < .5)), 68, mode)
for by, bx in FINE_BLOCKS[1:]:
layout = BlockLayout2D.build(*patch.fine_shape, by, bx, periodic=(False, False))
_, mapped = blocked_link_tables(layout, boundary.flags, fluid_idx, dirs, q, fallback)
self.assertEqual(seam_cells(layout, mapped), (8, 8), (mode, by, bx))
def test_storage_adapter_flags_sizing(self):
patch, boundary = fine_circle("periodic")
columns = [boundary.curved_host[i] for i in (0, 1, 2, 7)]
layout = BlockLayout2D.build(*patch.fine_shape, 16, 20, periodic=(False, False))
tables, mapped = blocked_link_tables(layout, boundary.flags, *columns)
L = int(np.prod(layout.local_shape))
self.assertEqual(tables["flags"].shape, (layout.nblocks * L,))
np.testing.assert_array_equal(tables["flags"][layout.owner.ravel().astype(np.intp)],
boundary.flags)
np.testing.assert_array_equal(tables["cell"], mapped.receiver % np.uint64(L))
for bad in (boundary.flags[:-1], boundary.flags.astype(np.uint32)):
with self.assertRaises(ValueError):
blocked_link_tables(layout, bad, *columns)
def test_same_buffer_halo_bfl_step_launch_order(self):
context = Mock(activate=nullcontext, sm_arch="sm_70")
calls, functions = [], {}
def get_function(name):
return functions.setdefault(name, Mock(side_effect=lambda *a, **k: calls.append((name, a))))
module = Mock(get_function=Mock(side_effect=get_function))
module.get_global.return_value = (Mock(), 20)
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
patch.object(cuda, "memcpy_htod"):
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
fine_objects=[circle()], fine_block=FINE_BLOCKS[1])
g_fine = state(*backend.patch.fine_shape, 2)
backend.initialize(state(NY, NX, 1), g_fine)
c, f = backend._levels
coarse, cur, spare = c.current, f.current, f.spare
flags, kind = backend._fine_flags, backend._kind
backend.step(1)
backend.close()
order = [(name, a[0]) for name, a in calls]
self.assertEqual(order, [("BlockExplode", coarse),
("BlockHaloCopy", cur), ("BlockCurvedBoundary", cur), ("BlockStep", cur),
("BlockHaloCopy", spare), ("BlockCurvedBoundary", spare),
("BlockStep", spare), ("PatchCoarseStep", coarse)])
steps = [a for name, a in calls if name == "BlockStep"]
self.assertEqual([a[1] for a in steps], [spare, cur])
for a in steps:
self.assertIs(a[3], flags) # blocked flags, not null
self.assertIs(a[4], kind)
def test_tables_map_back_to_dense_slots_in_order(self): def test_tables_map_back_to_dense_slots_in_order(self):
patch = FixedPatch2D.from_config(config("periodic"), BOUNDS) patch = FixedPatch2D.from_config(config("periodic"), BOUNDS)
fy, fx = patch.fine_shape fy, fx = patch.fine_shape
@@ -151,6 +232,33 @@ class BlockedFineGPU(unittest.TestCase):
print(f"{mode}: dense vs {len(blocked)} fine-block layouts bit-identical " print(f"{mode}: dense vs {len(blocked)} fine-block layouts bit-identical "
f"after {STEPS} coarse steps", flush=True) f"after {STEPS} coarse steps", flush=True)
def test_passive_circle_dense_and_blocked_fine_storage_are_bit_identical(self):
from CelerisLab.cuda.context import CudaContext
for mode in ("periodic", "slot_reservoir"):
cfg = config(mode)
patch, boundary = fine_circle(mode)
solid = (boundary.flags.reshape(patch.fine_shape) & SOLID) != 0
g_coarse, g_fine = state(NY, NX, 1), state(*patch.fine_shape, 2)
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
fine_objects=[circle()], fine_block=fine_block) as backend:
backend.initialize(g_coarse, g_fine)
backend.step(STEPS)
results.append((backend.download(), raw(backend)))
(dense_public, dense_raw), blocked = results[0], results[1:]
fine = dense_raw[1]
self.assertTrue(np.isfinite(fine[:, (patch.fine_kind == ACTIVE) & ~solid]).all(), mode)
self.assertTrue(np.isnan(fine[:, solid]).all(), mode)
self.assertTrue(np.isnan(fine[:, patch.fine_kind == OUTER_GHOST]).all(), mode)
for fine_block, (public, raw_arrays) in zip(FINE_BLOCKS, blocked):
msg = f"circle {mode} fine_block={fine_block}"
for a, b in zip(public + tuple(raw_arrays), dense_public + tuple(dense_raw)):
np.testing.assert_array_equal(a, b, err_msg=msg) # values and NaN masks
print(f"circle {mode}: dense vs {len(blocked)} fine-block layouts bit-identical "
f"after {STEPS} coarse steps", flush=True)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()