Files
CelerisLab/research/refinement_followup_20261013/incremental.patch
T

341 lines
16 KiB
Diff

diff --git a/src/CelerisLab/lbm/refinement_stepper.py b/src/CelerisLab/lbm/refinement_stepper.py
--- a/src/CelerisLab/lbm/refinement_stepper.py
+++ b/src/CelerisLab/lbm/refinement_stepper.py
@@ -14,7 +14,7 @@
import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler
-from .refinement import ACTIVE, FixedPatch2D
+from .refinement import ACTIVE, HALO, FixedPatch2D
@dataclass
@@ -37,6 +37,8 @@
it, rather than pretending the partially advanced fine buffer rolled back.
"""
def __init__(self, cfg, bounds, *, context):
+ if HALO != 4:
+ raise RuntimeError("refinement.cu requires a four-cell fine halo")
self.patch = FixedPatch2D.from_config(cfg, bounds)
self.ctx = context
self._lock = RLock()
@@ -51,9 +53,11 @@
with self.ctx.activate():
try:
self._stream = cuda.Stream()
- for config, name in ((replace(cfg), "PatchCoarseStep"),
- (fine_cfg, "PatchFineStep")):
+ for config, name, tau in ((replace(cfg), "PatchCoarseStep", self.patch.tau_coarse),
+ (fine_cfg, "PatchFineStep", self.patch.tau_fine)):
config.validate()
+ if not np.isclose(config.omega, 1 / tau, rtol=1e-14, atol=0):
+ raise RuntimeError("level omega/tau mismatch")
build = compiler.generate_config(config, n_objects=0)
try:
ptx = compiler.compile_kernel(self.ctx.sm_arch, source="refinement.cu",
@@ -61,7 +65,9 @@
module = compiler.load_module(ptx)
finally:
compiler.cleanup_build_dir(build)
- ptr, _ = module.get_global("d_params")
+ ptr, size = module.get_global("d_params")
+ if size != struct.calcsize("=ffffI"):
+ raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", config.omega, 0, 0, 0, 0))
shape = (9, config.ny, config.nx)
size = int(np.prod(shape)) * 4
@@ -166,18 +172,23 @@
with self._lock, self.ctx.activate():
self._require("ready")
arrays = [np.empty(level.shape, np.float32) for level in self._levels]
- for level, array in zip(self._levels, arrays):
- cuda.memcpy_dtoh(array, level.current)
+ try:
+ for level, array in zip(self._levels, arrays):
+ cuda.memcpy_dtoh(array, level.current)
+ except BaseException:
+ self._status = "failed"
+ raise
self._mask_nonleaves(*arrays)
return tuple(arrays)
def close(self):
- """Release owned allocations/modules; the caller's context stays open."""
+ """Release owned resources; retain them for retry if context activation fails."""
with self._lock:
if self._status == "closed":
return
- self._status = "closed"
+ self._status = "failed"
with self.ctx.activate():
+ self._status = "closed"
error = None
try:
if self._stream is not None:
diff --git a/tests/validation/multigrid/test_refinement_gpu.py b/tests/validation/multigrid/test_refinement_gpu.py
--- a/tests/validation/multigrid/test_refinement_gpu.py
+++ b/tests/validation/multigrid/test_refinement_gpu.py
@@ -214,7 +214,7 @@
np.testing.assert_allclose(got, expected, rtol=0, atol=POP_ATOL, equal_nan=True)
print("normal-build rest: steps=1 PASS; source/kernel paths pinned", flush=True)
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
- self.assertLess(rss, 512)
+ self.assertLess(rss, 256)
print(f"host_peak_rss={rss:.3f} MiB", flush=True)
diff --git a/tests/validation/multigrid/test_refinement_followup.py b/tests/validation/multigrid/test_refinement_followup.py
new file mode 100644
--- /dev/null
+++ b/tests/validation/multigrid/test_refinement_followup.py
@@ -0,0 +1,249 @@
+"""Only review gaps: CPU fault guards, normal-build seed1/8 and rectangle/1.
+
+Use the existing bounded runner/environment; no audit-build rerun. The scalar
+rectangle oracle covers one interval, not a second general-purpose reference.
+"""
+from contextlib import contextmanager, nullcontext
+from dataclasses import replace
+import json
+from pathlib import Path
+import resource
+from threading import RLock
+import unittest
+from unittest.mock import MagicMock, Mock, patch
+
+import numpy as np
+import pycuda.driver as cuda
+
+import CelerisLab
+from CelerisLab.lbm import refinement_stepper as implementation
+from test_refinement_gpu import (ROOT, POP_ATOL, MOMENT_ATOL, ref, postcollision,
+ totals, LBMConfig, CudaContext, compiler,
+ PeriodicPatchStepper)
+
+
+@contextmanager
+def normal_backend(cfg, bounds):
+ """Compile before creating a CUDA context to keep combined RSS below 256MiB.
+
+ The normal entry/flags are unchanged. Backend requests must match the exact
+ generated source digest; only redundant compilation is replaced by reuse.
+ Run each GPU case in a fresh process (driver mappings can outlive a context).
+ """
+ p = implementation.FixedPatch2D.from_config(cfg, bounds)
+ cuda.init()
+ cc = cuda.Device(0).compute_capability()
+ arch = f"sm_{cc[0]}{cc[1]}"
+ builds, prepared = [], {}
+ try:
+ for config in (replace(cfg), replace(cfg, nx=p.fine_shape[1], ny=p.fine_shape[0],
+ viscosity=2*cfg.viscosity)):
+ config.validate()
+ build = compiler.generate_config(config, n_objects=0)
+ builds.append(build)
+ digest = json.loads((Path(build)/".celeris-build").read_text())["source_sha256"]
+ prepared[digest] = compiler.compile_kernel(arch, source="refinement.cu", build_dir=build)
+
+ def reuse(request_arch, *, source, build_dir):
+ assert request_arch == arch and source == "refinement.cu"
+ digest = json.loads((Path(build_dir)/".celeris-build").read_text())["source_sha256"]
+ return prepared[digest]
+
+ with CudaContext(0) as context:
+ with patch.object(compiler, "compile_kernel", side_effect=reuse):
+ backend = PeriodicPatchStepper(cfg, bounds, context=context)
+ with backend:
+ yield backend
+ finally:
+ for build in builds:
+ compiler.cleanup_build_dir(build)
+
+
+def scalar_interval(coarse, fine, bounds):
+ """Independent G→G single interval, geometric links, no production tables."""
+ _, ny, nx = coarse.shape
+ _, fy, fx = fine.shape
+ x0, y0, x1, y1 = bounds
+ cx, cy = (0, 1, -1, 0, 0, 1, -1, 1, -1), (0, 0, 0, 1, -1, 1, -1, -1, 1)
+ weights = (4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36)
+
+ def collide(values, tau):
+ rho = sum(values)
+ ux = sum(c * v for c, v in zip(cx, values)) / rho
+ uy = sum(c * v for c, v in zip(cy, values)) / rho
+ equilibrium = [w * rho * (1 + 3*(a*ux+b*uy) + 4.5*(a*ux+b*uy)**2
+ - 1.5*(ux*ux+uy*uy)) for w, a, b in zip(weights, cx, cy)]
+ return [v - (v-e)/tau for v, e in zip(values, equilibrium)]
+
+ current = fine.astype(np.float64).copy()
+ for y in range(fy):
+ for x in range(fx):
+ if not (4 <= x < fx-4 and 4 <= y < fy-4):
+ current[:, y, x] = coarse[:, y0+(y-4)//2, x0+(x-4)//2]
+ for _ in range(2):
+ out = np.full_like(current, np.nan)
+ for y in range(2, fy-2):
+ for x in range(2, fx-2):
+ values = [current[q, y-cy[q], x-cx[q]] for q in range(9)]
+ out[:, y, x] = (collide(values, 1.1)
+ if 4 <= x < fx-4 and 4 <= y < fy-4 else values)
+ current = out
+ result = np.full(coarse.shape, np.nan)
+ for y in range(ny):
+ for x in range(nx):
+ if x0 <= x < x1 and y0 <= y < y1:
+ continue
+ values = []
+ for q in range(9):
+ mx, my = x+.5-cx[q]/2, y+.5-cy[q]/2
+ if x0 <= mx <= x1 and y0 <= my <= y1:
+ a, b = 4+2*(x-x0), 4+2*(y-y0)
+ value = sum(current[q, b+dy, a+dx] for dy in (0, 1) for dx in (0, 1))/4
+ else:
+ value = coarse[q, (y-cy[q]) % ny, (x-cx[q]) % nx]
+ values.append(value)
+ result[:, y, x] = collide(values, .8)
+ return result, current
+
+
+class FaultGuardsCPU(unittest.TestCase):
+ def fake_backend(self):
+ backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
+ backend.ctx = Mock(activate=nullcontext)
+ backend._lock = RLock()
+ backend._status = "ready"
+ backend._stream = Mock()
+ backend._levels = [Mock(shape=(9, 1, 1)), Mock(shape=(9, 1, 1))]
+ backend._allocations = [Mock(), Mock()]
+ return backend
+
+ def test_failed_download_blocks_read_and_step(self):
+ # Fail on each level separately, including after coarse data was read.
+ for failed_read in (0, 1):
+ backend = self.fake_backend()
+ reads = [None] * failed_read + [RuntimeError("device read failed")]
+ with patch.object(cuda, "memcpy_dtoh", side_effect=reads) as read:
+ with self.assertRaisesRegex(RuntimeError, "device read failed"):
+ backend.download()
+ calls = read.call_count
+ for operation in (backend.download, backend.step):
+ with self.assertRaisesRegex(RuntimeError, "failed"):
+ operation()
+ self.assertEqual(read.call_count, calls)
+ backend.close()
+
+ def test_activation_failure_retains_resources_for_retry(self):
+ backend = self.fake_backend()
+ levels, allocations, stream = backend._levels[:], backend._allocations[:], backend._stream
+ unavailable = MagicMock()
+ unavailable.__enter__.side_effect = RuntimeError("activation failed")
+ backend.ctx.activate = Mock(side_effect=[unavailable, nullcontext()])
+ with self.assertRaisesRegex(RuntimeError, "activation failed"):
+ backend.close()
+ self.assertEqual(backend._status, "failed")
+ self.assertEqual(backend._levels, levels)
+ self.assertEqual(backend._allocations, allocations)
+ self.assertIs(backend._stream, stream)
+ for allocation in allocations:
+ allocation.free.assert_not_called()
+ backend.close()
+ self.assertEqual(backend._status, "closed")
+ for allocation in allocations:
+ allocation.free.assert_called_once()
+
+ def test_kernel_contracts_reject_before_upload(self):
+ cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
+ context = Mock(activate=nullcontext, sm_arch="sm_70")
+ with patch.object(implementation, "HALO", 2):
+ with self.assertRaisesRegex(RuntimeError, "four-cell"):
+ PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context)
+ p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
+ with patch.object(cuda, "Stream"), patch.object(implementation.FixedPatch2D, "from_config",
+ return_value=replace(p, tau_coarse=.9)):
+ with self.assertRaisesRegex(RuntimeError, "omega/tau"):
+ PeriodicPatchStepper(cfg, p.bounds, context=context)
+ module = Mock()
+ module.get_global.return_value = (Mock(), 16)
+ 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, "memcpy_htod") as upload:
+ with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
+ PeriodicPatchStepper(cfg, p.bounds, context=context)
+ upload.assert_not_called()
+
+
+class NormalBuildGPU(unittest.TestCase):
+ def setUp(self):
+ self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py")
+ self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels")
+ self.assertEqual(Path(implementation.__file__).resolve(),
+ ROOT / "src/CelerisLab/lbm/refinement_stepper.py")
+
+ def tearDown(self):
+ rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+ self.assertLessEqual(rss, 256)
+ print(f"host_peak_rss={rss:.3f} MiB", flush=True)
+
+ def test_nonequilibrium_seed1_eight_steps(self):
+ spec = ref.make_spec(collision="srt")
+ maps = ref.build_maps(spec)
+ initial = ref.make_state(spec, velocity=(.03, -.02))
+ rng = np.random.default_rng(1)
+ state = ref.State(*(a * (1 + 1e-3*rng.standard_normal(a.shape))
+ for a in (initial.coarse_f, initial.fine_f)))
+ max_pop = max_drift = max_moment = 0.0
+ with normal_backend(LBMConfig(nx=16, ny=16, viscosity=.1), (4, 4, 12, 12)) as backend:
+ backend.initialize(*postcollision(state, spec, maps))
+ before = totals(backend.download(), maps)
+ for step in range(1, 9):
+ state, _ = ref.advance_coarse_once(spec, maps, state)
+ backend.step()
+ actual, expected = backend.download(), postcollision(state, spec, maps)
+ self.assertEqual(backend.step_count, step)
+ for got, want in zip(actual, expected):
+ np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
+ max_pop = max(max_pop, float(np.nanmax(np.abs(got-want))))
+ max_drift = max(max_drift, float(np.max(np.abs(totals(actual, maps)-before))))
+ max_moment = max(max_moment, float(np.max(np.abs(totals(actual, maps)-totals(expected, maps)))))
+ self.assertLessEqual(max_pop, POP_ATOL)
+ self.assertLessEqual(max_drift, MOMENT_ATOL)
+ self.assertLessEqual(max_moment, MOMENT_ATOL)
+ print(f"normal seed1 steps=8: max_population={max_pop:.9g} "
+ f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment:.9g}", flush=True)
+
+ def test_offcenter_rectangle_margin_two_one_step(self):
+ cfg, bounds = LBMConfig(nx=17, ny=13, viscosity=.1), (2, 2, 6, 5)
+ rng = np.random.default_rng(7)
+ # Explicit asymmetric, nonequilibrium G0, not an arrival array to collide again.
+ initial = [ref.W[:, None, None] * (1+1e-3*rng.standard_normal((9, ny, nx)))
+ for ny, nx in ((13, 17), (14, 16))]
+ initial = [np.array(a, dtype=np.float32) for a in initial]
+ initial[0][:, 2:5, 2:6] = np.nan
+ fine_mask = np.zeros((14, 16), bool)
+ fine_mask[4:-4, 4:-4] = True
+ initial[1][:, ~fine_mask] = np.nan
+ expected = scalar_interval(*[a.astype(np.float64) for a in initial], bounds)
+ with normal_backend(cfg, bounds) as backend:
+ self.assertEqual(backend.patch.fine_shape, (14, 16))
+ p = backend.patch
+ allocated = 2 * 9 * (17*13+16*14) * 4 + sum(a.nbytes for a in
+ (p.ghost_fine, p.ghost_coarse, p.coalesce_coarse, p.coalesce_fine))
+ self.assertLess(allocated, 128*1024)
+ backend.initialize(*initial)
+ backend.step()
+ coarse, fine = backend.download()
+ raw_fine = np.empty((9, 14, 16), np.float32)
+ cuda.memcpy_dtoh(raw_fine, backend._levels[1].current)
+ max_error = 0.0
+ for got, want in zip((coarse, raw_fine), expected):
+ np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
+ max_error = max(max_error, float(np.nanmax(np.abs(got-want))))
+ self.assertLessEqual(max_error, POP_ATOL)
+ self.assertTrue(np.isnan(fine[:, ~fine_mask]).all())
+ print(f"normal rectangle 17x13 bounds={bounds} fine=16x14 steps=1: "
+ f"max_population={max_error:.9g} device_arrays={allocated}; halo/stride PASS", flush=True)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)