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)