Files
CelerisLab/tests/validation/multigrid/test_refinement_gpu.py
T
ClaudeandClaude Opus 5.5 2b10b01c49 test: retire dense-route tests, require tiles everywhere (D2)
Retired with the dense refinement route (results recorded at 1573942 and
2469af7 or named as accepted losses):
- test_refinement_blocked_base: BlockedBaseGPU exact parity (3 tests),
  DenseBlockedRouteParity (6 tests incl. MRT and two bodies),
  DenseBlockedRouteCPU (capability matrix, ledger comparator);
- test_refinement_blocked_fine: BlockedFineGPU dense-vs-blocked (3 tests);
- test_refinement_mrt_gpu: test_dense_versus_blocked_mrt_two_level;
- test_raw_exchange_gpu: test_dense_fine_storage_reference (private
  link_exchange cut-link oracle, accepted loss);
- test_refinement_gpu: test_bounded_equivalence_and_ownership (dense
  CELERIS_REFINEMENT_AUDIT ownership counters, accepted loss); its physics
  stays in test_bounded_equivalence_blocked; eight_steps loses the audit
  branch and the misleading 'ownership/counts PASS' label;
- test_fine_surface_gpu: test_fine_surface_four_hops (dense per-hop flux
  ledger, CurvedLinkSoA reflection oracle, audit counts: accepted losses;
  raw exchange covers the boundary part only, not full flux closure) and the
  dense SoA fault tests (missing surface buffer, close frees SoA); blocked
  equivalents remain in test_refinement_blocked_fine/_base;
- test_raw_exchange.test_dense_observation_rejected_before_device_access.
New TileContractCPU: level_blocks omitted/None/wrong length/None entry/
wrong arity/non-integer/bool/non-positive/non-tuple/not dividing all raise
before CUDA; whole-level tiles reach CUDA. Rejection tests and fixtures pass
tiles; launch-order and kernel-text tests follow the blocked root;
examples/fixed_periodic_patch uses whole-level tiles (+ host tile test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:12:20 +00:00

239 lines
13 KiB
Python

"""Bounded FP32 GPU / pinned FP64 CPUREF-0.3 equivalence, not accuracy validation.
Run explicitly in pycuda_3_10 with PYTHONPATH=<this repo>/src, one BLAS thread,
and CUDA_VISIBLE_DEVICES=0 (or 1) only after checking the physical GPU is idle.
At most eight coarse steps per case. No files, plots or flow arrays are saved.
"""
import hashlib
import importlib.util
from contextlib import nullcontext
from dataclasses import replace
from pathlib import Path
import resource
import sys
from threading import RLock
import unittest
from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
import CelerisLab
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, PeriodicPatchStepper
ROOT = Path(__file__).resolve().parents[3]
POP_ATOL = 3e-6
MOMENT_ATOL = 2e-4
REFERENCE_SHA = "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e"
REF_PATH = Path(__file__).with_name("cpu_reference.py")
assert hashlib.sha256(REF_PATH.read_bytes()).hexdigest() == REFERENCE_SHA
loader = importlib.util.spec_from_file_location("pinned_patch_reference", REF_PATH)
ref = importlib.util.module_from_spec(loader)
sys.modules[loader.name] = ref
loader.loader.exec_module(ref)
def postcollision(state, spec, maps):
# Observation conversion only. Never applied to production G.
return tuple(ref._collide(spec, a, np.isfinite(a), tau, mask,
np.zeros(mask.shape, dtype=np.int64))
for a, tau, mask in ((state.coarse_f, spec.tau_coarse, maps.coarse_active),
(state.fine_f, spec.tau_fine, maps.fine_active)))
def totals(arrays, maps):
# Leaf sum independent of the reference's flux/budget bookkeeping helpers.
c, f = arrays
populations = c[:, maps.coarse_active].astype(np.float64).sum(axis=1)
populations += .25 * f[:, maps.fine_active].astype(np.float64).sum(axis=1)
return np.array([populations.sum(), populations @ ref.CX, populations @ ref.CY])
def level_storage(level):
"""Raw device storage of one blocked level as dense (9, ny, nx)."""
blocked = np.empty(level.layout.blocked_shape, np.float32)
cuda.memcpy_dtoh(blocked, level.current)
return level.layout.to_dense(blocked)
class CoupledGPU(unittest.TestCase):
def test_actual_tg_rates_one_interval(self):
"""Algebraic rate/phase check, not a new physical Taylor--Green benchmark.
The N64/Re100 TG rates are exercised on the existing small patch with
a nonequilibrium state. One interval checks g=C(f), including the
shifted blocked path; no accuracy gate or production source changes.
"""
self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py")
self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels")
spec = replace(ref.make_spec(collision="srt"),
tau_coarse=.5576, tau_fine=.6152)
maps = ref.build_maps(spec)
state = 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 (state.coarse_f, state.fine_f)))
initial = postcollision(state, spec, maps)
next_state, _ = ref.advance_coarse_once(spec, maps, state)
expected = postcollision(next_state, spec, maps)
arrivals = (next_state.coarse_f, next_state.fine_f)
cases = (("blocked_unshifted", False, dict(level_blocks=[(16, 16), (24, 24)])),
("blocked_shifted", True,
dict(level_blocks=[(4, 4), (6, 6)])))
with CudaContext(0) as context:
for label, shifted, storage in cases:
with self.subTest(storage=label):
cfg = LBMConfig(nx=16, ny=16, viscosity=.0192,
ddf_shifting=shifted)
with FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
**storage) as backend:
self.assertAlmostEqual(backend.patch.tau_coarse, spec.tau_coarse)
self.assertAlmostEqual(backend.patch.tau_fine, spec.tau_fine)
self.assertEqual(tuple(level.shape for level in backend._levels),
((9, 16, 16), (9, 24, 24)))
# Physical FP64 g is encoded by initialize before FP32 rounding.
backend.initialize(*initial)
before = totals(backend.download(), maps)
backend.step(1)
actual = backend.download()
self.assertEqual(backend.step_count, 1)
backend.patch.check_synchronization(
1, next_state.tick, phase="postcollision_g")
for got, want, arrival in zip(actual, expected, arrivals):
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
np.testing.assert_allclose(got, want, rtol=0, atol=POP_ATOL,
equal_nan=True)
# A raw arrival-f comparison must fail on BOTH levels.
# Equilibrium-only data would make this control vacuous.
with self.assertRaises(AssertionError):
np.testing.assert_allclose(got, arrival, rtol=0,
atol=POP_ATOL, equal_nan=True)
np.testing.assert_allclose(totals(actual, maps), before,
rtol=0, atol=MOMENT_ATOL)
np.testing.assert_allclose(totals(actual, maps),
totals(expected, maps),
rtol=0, atol=MOMENT_ATOL)
error = max(float(np.nanmax(np.abs(a - e)))
for a, e in zip(actual, expected))
print(f"actual-TG-rate algebraic check: {label} steps=1 "
f"tau=.5576/.6152 max_population={error:.9g}; "
"wrong-phase controls PASS", flush=True)
def test_cleanup_attempts_every_allocation(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "failed"
backend._stream = Mock()
backend._stream.synchronize.side_effect = RuntimeError("original stream failure")
first, second = Mock(), Mock()
first.free.side_effect = RuntimeError("secondary free failure")
backend._allocations = [first, second]
backend._levels = [Mock()]
with self.assertRaisesRegex(RuntimeError, "original stream failure"):
backend.close()
first.free.assert_called_once()
second.free.assert_called_once()
self.assertEqual(backend._allocations, [])
self.assertEqual(backend._levels, [])
backend.close()
def test_partial_advance_cannot_be_published(self):
spec = ref.make_spec(collision="srt")
maps = ref.build_maps(spec)
with CudaContext(0) as context:
with PeriodicPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1),
context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as backend:
backend.initialize(*postcollision(ref.make_state(spec), spec, maps))
coarse, fine = backend._levels
def fail_after_fine(*args, **kwargs):
# Ensure both queued fine hops actually completed before failing.
backend._stream.synchronize()
raise RuntimeError("coarse launch failed after two fine hops")
with patch.object(coarse, "kernel", side_effect=fail_after_fine):
with self.assertRaisesRegex(RuntimeError, "after two fine hops"):
backend.step()
self.assertEqual(backend.step_count, 0)
for operation in (backend.download, backend.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
raw = level_storage(fine)
self.assertTrue(np.isfinite(raw.ravel()[backend.patch.coalesce_fine]).all())
self.assertFalse(context.is_closed)
print("partial failure: two fine hops completed, no coarse step published; cleanup PASS", flush=True)
def eight_steps(self, backend, spec, maps):
"""Rest and seeded nonequilibrium, 8 steps against the scalar reference."""
p = backend.patch
for case in ("rest", "nonequilibrium_seed1"):
with self.subTest(case=case):
state = ref.make_state(spec, velocity=(0, 0) if case == "rest" else (.03, -.02))
if case != "rest":
rng = np.random.default_rng(1)
state = ref.State(*(a * (1 + 1e-3 * rng.standard_normal(a.shape))
for a in (state.coarse_f, state.fine_f)))
initial = postcollision(state, spec, maps)
backend.initialize(*initial)
before = totals(backend.download(), maps)
max_error = max_drift = max_moment_error = max_corner = 0.0
for step in range(1, 9):
state, diagnostic = ref.advance_coarse_once(spec, maps, state)
backend.step()
actual = backend.download()
expected = postcollision(state, spec, maps)
self.assertEqual(backend.step_count, step)
p.check_synchronization(step, state.tick, phase="postcollision_g")
for got, want in zip(actual, expected):
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
error = float(np.nanmax(np.abs(got - want)))
self.assertLessEqual(error, POP_ATOL)
max_error = max(max_error, error)
drift = float(np.max(np.abs(totals(actual, maps) - before)))
moment_error = float(np.max(np.abs(totals(actual, maps) - totals(expected, maps))))
self.assertLessEqual(drift, MOMENT_ATOL)
self.assertLessEqual(moment_error, MOMENT_ATOL)
max_drift, max_moment_error = max(max_drift, drift), max(max_moment_error, moment_error)
f = backend._levels[1]
raw = level_storage(f)
np.testing.assert_array_equal(np.isfinite(raw), diagnostic["hop2_valid"])
self.assertTrue(np.isfinite(raw.ravel()[p.coalesce_fine]).all())
self.assertEqual(len(diagnostic["corner_packets"]), 8)
for packet in diagnostic["corner_packets"].values():
sx, sy = packet["source_coarse_xy"]
self.assertTrue(maps.coarse_active[sy, sx])
values = [raw[packet["direction"], y, x]
for y, x in packet["children_fine_yx"]]
error = float(np.max(np.abs(np.array(values) - packet["packets"])))
self.assertLessEqual(error, POP_ATOL)
max_corner = max(max_corner, error)
print(f"{case}: steps=8 max_population={max_error:.9g} "
f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment_error:.9g} "
f"max_corner_packet={max_corner:.9g}; PASS", flush=True)
def test_bounded_equivalence_blocked(self):
"""The same eight-step reference and launch-failure checks on whole-level tiles."""
spec = ref.make_spec(collision="srt")
maps = ref.build_maps(spec)
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
with CudaContext(0) as context:
with PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as backend:
self.eight_steps(backend, spec, maps)
with patch.object(backend._pairs[0], "explode", side_effect=RuntimeError("injected launch failure")):
with self.assertRaisesRegex(RuntimeError, "injected"):
backend.step()
for operation in (backend.download, backend.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
self.assertFalse(context.is_closed)
if __name__ == "__main__":
unittest.main(verbosity=2)