Add stationary passive circles across same-level block seams

blocks.cu: BlockCurvedBoundary passes the receiver-local core cell and the
block-offset fi to the unchanged apply_bouzidi_link (Uw=Vw=0, obs=nullptr),
so k_s and the q<1/2 donor follow the window image the next pull reads.
BlockStep takes nullable block-cell flags; solid cores store NaN each step.
Adds core/flags.cuh, core/obs.cuh and boundary/curved_boundary.cuh.
block_stepper.py: objects=() accepts stationary passive circles only; host
preparation rejects before CUDA: non-circle/moving/actuated/sensor roles,
overlap, domain-edge contact, and any fluid core (all rows/columns, periodic
neighbors) whose pull from a solid lacks exactly one cut link. Uses
map_cut_links for unique receiver owner/local writer. Order per step:
halo copy -> BFL -> pull/collide, no copy in between. Fluid-only
initialize validation; solids NaN.
Tests: CPU rejections/tables; GPU 32x24, nu .1, 8 steps, circle
(15.5,11.5) R4.3 with halo-write and halo-donor presence asserted,
1x1/2x2/4x2 bit-identical, finite fluid/NaN solid, FP64 BFL+pull/SRT
<= 3e-6 on fluid; drift reported. Body-free gate unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-15 03:25:36 +00:00
co-authored by Claude Opus 5.5
parent 865bb0d6e0
commit 1a2d58312d
3 changed files with 222 additions and 23 deletions
+77 -10
View File
@@ -1,12 +1,14 @@
# CelerisLab/lbm/block_stepper.py
"""Opt-in body-free periodic backend for one BlockLayout2D level.
"""Opt-in periodic backend for one BlockLayout2D level, optional static circles.
D2Q9/SRT/FP32 unforced double buffers. Each step refreshes every halo from
its owner core, then pulls/collides core cells into the spare buffer, then
swaps. Only halo pair tables are uploaded; dense ownership stays on the host.
The caller owns the CudaContext and must close this backend before it.
No bodies, walls, refinement, checkpoint or runtime parameter mutation;
wall/slot/inlet fields in cfg are ignored (fully periodic level).
Optional ``objects`` are stationary passive circles (static linear BFL via
receiver-local staging: halo refresh -> cut links -> pull, no copy between).
No force/actuation readout, motion, walls, refinement, checkpoint or runtime
parameter mutation; wall/slot/inlet fields in cfg are ignored.
"""
from dataclasses import replace
from numbers import Integral
@@ -18,17 +20,64 @@ import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler
from .blocks import NQ, BlockLayout2D
from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks)
def _static_circles(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
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
cells = int(np.prod(layout.local_shape))
blocked_flags = np.zeros(layout.nblocks * cells, np.uint16) # halo entries unused
blocked_flags[layout.owner.ravel().astype(np.intp)] = flags
return dict(solid=solid, flags=blocked_flags, block=mapped.block,
cell=(mapped.receiver % cells).astype(np.uint32),
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:
"""G->G stepping of Q-major (9, ny, nx) postcollision populations.
On a device/launch failure the instance becomes unusable: close and
recreate it rather than assuming a partially advanced buffer rolled back.
"""
def __init__(self, cfg, layout, *, context):
def __init__(self, cfg, layout, *, context, objects=()):
if not isinstance(layout, BlockLayout2D) or layout.periodic != (True, True):
raise ValueError("PeriodicBlockStepper requires a doubly periodic BlockLayout2D")
checked = replace(cfg)
@@ -44,8 +93,14 @@ class PeriodicBlockStepper:
tpb = checked.threads_per_block
if layout.nblocks > MAX_GRID_DIM or ly > MAX_GRID_DIM or not 0 < pairs < 2**31:
raise ValueError("block count, block rows or halo pairs exceed launch limits")
self.boundary = _static_circles(layout, tuple(objects)) if objects else None
links = 0 if self.boundary is None else len(self.boundary["cell"])
if links >= 2**31:
raise ValueError("cut links exceed launch limits")
self.layout = layout
self.ctx = context
self._links = links
self._link_grid = ((links + tpb - 1) // tpb, 1)
# Wall/slot fields are not applied; neutralize them like the fine patch level.
self._local_cfg = replace(checked, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
@@ -57,11 +112,13 @@ class PeriodicBlockStepper:
self._status = "new"
self._step_count = 0
self._allocations = []
self._stream = self._module = self._copy = self._step = None
self._stream = self._module = self._copy = self._step = self._curved = None
self._tables = {}
with self.ctx.activate():
try:
free, _ = cuda.mem_get_info()
if 2 * self._bytes + layout.halo_dst.nbytes + layout.halo_src.nbytes > free:
tables = sum(a.nbytes for k, a in (self.boundary or {}).items() if k != "solid")
if 2 * self._bytes + layout.halo_dst.nbytes + layout.halo_src.nbytes + tables > free:
raise MemoryError("blocked buffers exceed free device memory")
self._stream = cuda.Stream()
config = self._local_cfg
@@ -85,6 +142,9 @@ class PeriodicBlockStepper:
self._src = self._upload(layout.halo_src)
self._copy = module.get_function("BlockHaloCopy")
self._step = module.get_function("BlockStep")
if self.boundary is not None:
self._tables = {k: self._upload(a) for k, a in self.boundary.items() if k != "solid"}
self._curved = module.get_function("BlockCurvedBoundary")
except BaseException:
try:
self.close()
@@ -118,8 +178,10 @@ class PeriodicBlockStepper:
dense = np.array(g, dtype=np.float32, order="C", copy=True)
if dense.shape != (NQ, *self.layout.shape):
raise ValueError("Expected a Q-major (9, ny, nx) array")
if not np.isfinite(dense).all() or (dense.sum(axis=0) <= 0).any():
raise ValueError("Populations must be finite with positive density")
fluid = np.ones(self.layout.shape, bool) if self.boundary is None else ~self.boundary["solid"]
if not np.isfinite(dense[:, fluid]).all() or (dense[:, fluid].sum(axis=0) <= 0).any():
raise ValueError("Fluid populations must be finite with positive density")
dense[:, ~fluid] = np.nan # Solid cores are deliberately invalid.
blocked = self.layout.to_blocked(dense)
spare = np.full_like(blocked, np.nan)
try:
@@ -143,8 +205,12 @@ class PeriodicBlockStepper:
for _ in range(n):
self._copy(self._current, self._dst, self._src,
np.uint32(len(self.layout.halo_dst)), grid=self._copy_grid, **kw)
if self._curved is not None:
t = self._tables
self._curved(self._current, t["block"], t["cell"], t["dir"], t["q"],
t["fallback"], np.uint32(self._links), grid=self._link_grid, **kw)
self._step(self._current, self._spare, np.uint32(self.layout.halo),
grid=self._grid, **kw)
self._tables.get("flags", np.uintp(0)), grid=self._grid, **kw)
self._stream.synchronize()
self._current, self._spare = self._spare, self._current
self._step_count += 1
@@ -179,7 +245,8 @@ class PeriodicBlockStepper:
self._stream.synchronize()
except Exception as exc:
error = exc
self._copy = self._step = self._module = None
self._copy = self._step = self._curved = self._module = None
self._tables = {}
for pointer in self._allocations:
try:
pointer.free()
+33 -11
View File
@@ -4,14 +4,18 @@
// and linear_index address one block; blockIdx.z selects the block, whose
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#include "streaming/pull_double_buffer.cuh"
#include "boundary/curved_boundary.cuh"
#if DIM != 2 || NQ != 9 || COLLISION_MODEL != 0 || STORE_PRECISION != 0 || USE_DDF_SHIFTING || USE_LES || STREAMING_MODEL != 0
#error "Blocks require unshifted FP32 D2Q9 SRT double buffers without LES"
@@ -30,20 +34,38 @@ extern "C" __global__ void BlockHaloCopy(
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
}
// Static cut links, receiver-local staging: cell is the receiver's local core
// cell, so the existing BFL writes the solid-source window image that this
// block's next pull reads (no halo copy between this kernel and BlockStep).
extern "C" __global__ void BlockCurvedBoundary(
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
const float* q, const unsigned char* fallback, unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
0.0f, 0.0f, 0.0f, 0.0f, fallback[row], nullptr, -1);
}
// Pull/collide/store core cells only; halo cells of out are left untouched.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo) {
// flags (block cell ids, nullable): solid cores store NaN and never collide.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
const uint16_t* flags) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
in += base;
out += base;
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
float f[NQ], rho, ux, uy, feq[NQ];
stream_pull_load(k, f, in, j);
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
collide_srt_no_force(f, feq, d_params.omega);
stream_pull_store(k, f, out);
float f[NQ];
if (flags != nullptr && is_solid(flags[(unsigned long)blockIdx.z * TOTAL_CELLS + k])) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
float rho, ux, uy, feq[NQ];
stream_pull_load(k, f, in + base, j);
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
collide_srt_no_force(f, feq, d_params.omega);
}
stream_pull_store(k, f, out + base);
}
@@ -2,7 +2,11 @@
Frozen GPU gates (32x24, 8 steps, positive low-Mach non-equilibrium state):
finite cores; bit-identical 1x1, 2x2 and 4x2 splits; max |G - FP64 oracle|
<= 3e-6. Mass/momentum drift is reported, not gated.
<= 3e-6. Static circle (centre (15.5, 11.5), R=4.3, on the 2x2 corner seam), same
case: halo writes and q<.5 halo donors asserted present; finite fluid / NaN
solid cores; bit-identical splits; max |G - FP64 BFL+pull/SRT| <= 3e-6 on
fluid cells, from the same FP32-packed link data and initial populations.
Mass/momentum drift is reported, not gated.
"""
from contextlib import nullcontext
from pathlib import Path
@@ -14,10 +18,12 @@ from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm import block_stepper as implementation
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.blocks import NO_DONOR, BlockLayout2D
from CelerisLab.lbm.block_stepper import PeriodicBlockStepper
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
@@ -59,6 +65,48 @@ def oracle(g, steps):
return g
CENTER, RADIUS = (15.5, 11.5), 4.3
def circle(center=CENTER, radius=RADIUS):
return SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
def dense_flags():
"""FLUID everywhere, circle flags on its solids (as the stepper builds them)."""
mask = circle().get_flag_mask(NX, NY)
flags = np.full(NX * NY, 1, np.uint16)
flags[(mask & 2) != 0] = mask[(mask & 2) != 0]
return flags
def circle_oracle(g, steps, solid, links):
"""FP64 periodic static linear BFL -> pull -> SRT on fluid; solids NaN."""
g = g.astype(np.float64)
g[:, solid] = np.nan
omega = 1 / (3 * NU + .5)
rows = [(*divmod(int(link.fluid_idx), NX), int(link.dir), float(np.float32(link.q)),
int(link.fallback)) for link in links]
for _ in range(steps):
staged = g.copy()
for y, x, d, q, fallback in rows:
ey, ex, o = D2Q9_EY[d], D2Q9_EX[d], d + 1 if d % 2 else d - 1
toward = g[d, y, x]
if fallback:
value = toward
elif q < .5:
value = 2 * q * toward + (1 - 2 * q) * g[d, (y - ey) % NY, (x - ex) % NX]
else:
value = toward / (2 * q) + (1 - 1 / (2 * q)) * g[o, y, x]
staged[o, (y + ey) % NY, (x + ex) % NX] = value
f = np.stack([np.roll(staged[q], (D2Q9_EY[q], D2Q9_EX[q]), axis=(0, 1)) for q in range(9)])
rho = f.sum(axis=0)
ux, uy = (CX[:, None, None] * f).sum(axis=0) / rho, (CY[:, None, None] * f).sum(axis=0) / rho
g = f + omega * (feq(rho, ux, uy) - f)
g[:, solid] = np.nan
return g
def totals(g):
g = g.astype(np.float64)
return g.sum(), (CX[:, None, None] * g).sum(), (CY[:, None, None] * g).sum()
@@ -80,6 +128,17 @@ class CpuContracts(unittest.TestCase):
with self.subTest(cfg=cfg.collision, layout=getattr(lay, "periodic", lay)):
with self.assertRaises(ValueError):
PeriodicBlockStepper(cfg, lay, context=self.forbidden_context())
moving = circle()
moving.state.vx = .01
build_links = CircleGeometry.build_cut_links
for objects in ([moving], [circle((1.5, 11.5), 2.0)], [circle(), circle()]):
with self.assertRaises(ValueError):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(), objects=objects)
with patch.object(CircleGeometry, "build_cut_links",
lambda geometry, *a, **k: build_links(geometry, *a, **k)[1:]):
with self.assertRaisesRegex(ValueError, "cover every fluid pull"):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(),
objects=[circle()])
with patch.object(implementation, "MAX_GRID_DIM", 3):
with self.assertRaisesRegex(ValueError, "launch limits"):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context())
@@ -107,6 +166,16 @@ class CpuContracts(unittest.TestCase):
alloc.assert_not_called()
upload.assert_not_called()
def test_static_circle_tables(self):
layout = BlockLayout2D.build(NY, NX, 12, 16)
tables = implementation._static_circles(layout, (circle(),))
links = CircleGeometry(*CENTER, RADIUS).build_cut_links(NX, NY, domain_flags=dense_flags())
self.assertEqual(len(tables["cell"]), len(links))
np.testing.assert_array_equal(tables["q"], np.array([l.q for l in links], np.float32))
solid_cells = layout.owner[tables["solid"]].astype(np.intp)
self.assertTrue((tables["flags"][solid_cells] & 2).all())
self.assertEqual(int(np.count_nonzero(tables["flags"] & 2)), int(tables["solid"].sum()))
def fake_backend(self):
backend = PeriodicBlockStepper.__new__(PeriodicBlockStepper)
backend.layout = BlockLayout2D.build(NY, NX, 12, 16)
@@ -117,6 +186,7 @@ class CpuContracts(unittest.TestCase):
backend._stream = Mock()
backend._module = Mock()
backend._copy = backend._step = Mock()
backend.boundary, backend._curved, backend._tables, backend._links = None, None, {}, 0
backend._dst, backend._src = Mock(), Mock()
backend._block, backend._grid, backend._copy_grid = (32, 1, 1), (1, 1, 1), (1, 1)
backend._allocations = [Mock(), Mock()]
@@ -211,6 +281,46 @@ class PeriodicBlockGPU(unittest.TestCase):
*(a - b for a, b in zip(after, before))), flush=True)
self.assertLessEqual(error, ORACLE_ATOL)
def test_static_circle_across_block_seams(self):
from CelerisLab.cuda.context import CudaContext
flags = dense_flags()
solid = ((flags & 2) != 0).reshape(NY, NX)
links = CircleGeometry(*CENTER, RADIUS).build_cut_links(NX, NY, domain_flags=flags)
for by, bx in SPLITS[1:]: # fixture must exercise receiver-local halo staging
layout = BlockLayout2D.build(NY, NX, by, bx)
mapped = layout.map_cut_links(links)
cells = int(np.prod(layout.local_shape))
core = set(layout.owner.ravel().tolist())
writes = {int(w) // (9 * cells) * cells + int(w) % cells for w in mapped.write}
self.assertTrue(writes - core, (by, bx))
self.assertTrue({int(d) for d in mapped.donor if d != NO_DONOR} - core, (by, bx))
g0 = initial_state()
results = []
with CudaContext(0) as context:
for by, bx in SPLITS:
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context, objects=[circle()]) as backend:
for key, values, dtype in (("q", [l.q for l in links], np.float32),
("dir", [l.dir for l in links], np.uint8),
("fallback", [l.fallback for l in links], np.uint8)):
np.testing.assert_array_equal(backend.boundary[key], np.array(values, dtype))
backend.initialize(g0)
backend.step(STEPS)
results.append(backend.download())
for (by, bx), g in zip(SPLITS, results):
self.assertTrue(np.isfinite(g[:, ~solid]).all(), (by, bx))
self.assertTrue(np.isnan(g[:, solid]).all(), (by, bx))
np.testing.assert_array_equal(g, results[0], err_msg=f"split {(by, bx)}")
expected = circle_oracle(g0, STEPS, solid, links)
error = np.abs(results[0][:, ~solid].astype(np.float64) - expected[:, ~solid]).max()
start = g0.copy()
start[:, solid] = 0
before, after = totals(start), totals(np.nan_to_num(results[0]))
print(f"circle max_abs_oracle_error={error:.3e} gate={ORACLE_ATOL:.0e} links={len(links)}", flush=True)
print("circle drift mass={:.3e} jx={:.3e} jy={:.3e}".format(
*(a - b for a, b in zip(after, before))), flush=True)
self.assertLessEqual(error, ORACLE_ATOL)
if __name__ == "__main__":
unittest.main()