Compare commits

...
Author SHA1 Message Date
Frank14f 914906efd4 Merge periodic stepper alias cleanup into final stage candidate 2026-10-28 08:30:35 +08:00
Frank14f 55b0b34fc5 Merge direct Simulation Jet GPU regression 2026-10-28 08:29:52 +08:00
Frank14f 51690fa898 test: verify Simulation Jet amplitude uploads on GPU 2026-10-28 08:25:41 +08:00
ClaudeandClaude Opus 5.5 ddc4a1a6c4 lifecycle: guard Simulation.stream after a failed jet update; scope docs
A set_jet_amplitude failure while creating the internal stream marked the
Simulation failed, but the public stream property still retried
cuda.Stream(). Guard it like the other device-touching methods and add a
regression test (no retry, close still retryable). Lifecycle wording now
names the guarded device-touching methods; cached readers and host staging
stay callable. compatibility.md records the caller-visible fail-closed change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:23:56 +00:00
Frank14f a9aa12e65d refactor: remove redundant periodic patch stepper alias 2026-10-28 08:03:29 +08:00
9 changed files with 120 additions and 22 deletions
+22
View File
@@ -3,6 +3,13 @@
Changes that can make a previously accepted setup or call behave differently.
Each entry names the commit, the affected entry points and the observable result.
## Redundant periodic stepper alias removed
`PeriodicPatchStepper` is no longer exported from `lbm.refinement_stepper`.
Use `FixedPatchStepper` with the same arguments, including explicit
`level_blocks`. The old name was an alias for that exact class; periodic
boundaries remain the default. Existing imports of the old name must be updated.
## 87c3a31: cut-link ownership is validated for every body
`validate_cut_link_ownership` (`body/preprocess/flag_overlay.py`) now runs for
@@ -33,3 +40,18 @@ or negative amplitude (the aperture test is geometric). Bodies with neither
`SurfaceActuation` nor aperture links still raise
`ValueError("Body has no surface actuation")`. The value is unchanged: the latest
step's wall/aperture exchange, not total nozzle force.
## 7afb5ad: `Simulation` fails closed after a Jet amplitude device-write failure
`Simulation.set_jet_amplitude` previously only documented that a device error
leaves the per-link velocity column undefined; the instance stayed usable.
Now any error after input validation (internal stream creation or the upload)
marks the Simulation failed. Every later device-touching public method,
including the `stream` property (guard added in the follow-up repair commit on
`claude/jet-update-consistency`), raises
`RuntimeError("Simulation failed (...); close() and recreate it")`. The host
amplitude keeps its previous value. `close()` still works and stays retryable;
cached host readers and host staging calls are not blocked. Invalid input
(`ValueError`/`IndexError`) is unchanged: it raises before any change and the
instance stays usable. `FixedPatchStepper.set_jet_amplitude` already failed the
instance (status `failed`) and is unchanged.
+1 -1
View File
@@ -7,7 +7,7 @@ it is; it adds no framework. Line references are to candidate 0f8fdbc3.
|---|---|---|---|
| 1. Module and its globals | Compile-time (visible to the compiler, fixed per module): `config.h` macros incl. `NX`/`NY`, `COLLISION_MODEL`, `BLOCK_ROOT_BC`, `WALL_*`. Runtime per-module globals (not visible to the compiler; written by host code): `__constant__` `d_params`, `d_w`, `d_cx`, `d_cy`, `d_block_channel`, `d_block_wall`; plus bound kernel handles | Globals are written once by the owner right after `load_module`. Uniform grid: `Simulation._recompile` (construction, body-topology sync) loads a new module and re-uploads; `update_runtime_params` rewrites `d_params` (omega, force, n_objects) between runs. Steppers: never rewritten after construction, so they are immutable for the instance's lifetime although they are runtime values. The blocked slot/reservoir root reads its slot from the `d_block_wall` constant (the former dense route used `WALL_*` macros; both were bit-identical in the parity gates). | Every owner loads its own module (`compiler.load_module` = `module_from_file`, no cache). A loaded module is never shared between live owners: globals are per module, so sharing would share mutable `d_params`. |
| 2. Geometry, layout, tables | flags, curved-link tables, block layouts and halo pairs, transfer (explode/coalesce) tables, block kinds | Immutable for the life of an instance. Uniform grid: rebuilt by body sync. `FixedPatchStepper`: a geometry change is a new instance (`remap_child`), committed only after a successful transfer. | No in-place table edits. |
| 3. Controls and dependent boundary columns | Jet amplitude -> per-link `surface_velocity`; body actions (uniform grid) | Between steps, without a table or module rebuild (`set_jet_amplitude`). | Input (index, level, amplitude) is validated before any device write; invalid input changes nothing and the instance stays usable. The host copy commits only after the upload returned. Any error after validation leaves the device column undefined and fails the instance: `FixedPatchStepper` status `failed`, `Simulation._failed`; every later call except `close()` raises, `close()` still releases resources and stays retryable. |
| 3. Controls and dependent boundary columns | Jet amplitude -> per-link `surface_velocity`; body actions (uniform grid) | Between steps, without a table or module rebuild (`set_jet_amplitude`). | Input (index, level, amplitude) is validated before any device write; invalid input changes nothing and the instance stays usable. The host copy commits only after the upload returned. Any error after validation leaves the device column undefined and fails the instance: `FixedPatchStepper` status `failed`, `Simulation._failed`; every later device-touching public method (`Simulation`: `stream`, `set_jet_amplitude`, `initialize`, `sync_bodies`, `run`/`step`, `read_surface_exchange`, `read_body(s)`, `get_macroscopic`, `get_ddf`, `update_runtime_params`, `snapshot`, `restore`, save/load checkpoint) raises; cached host readers and host-side staging (e.g. `get_flags`, `add_body`/`remove_body` before a sync) do not touch the device and stay callable. `close()` still releases resources and stays retryable. |
| 4. Per-step state | population double buffers and their parity, raw-exchange ledgers, observations | Device-only during `step(n)` / `run(n)`; buffer swaps are host-handle swaps. | Valid on the host only after the single wait at the end of the call. |
Storage routes are separate concerns:
+2 -2
View File
@@ -16,7 +16,7 @@ from CelerisLab.config import load_lbm_config
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf
from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
def shear_equilibrium(shape, origin, dx, period):
@@ -48,7 +48,7 @@ def main():
nx=16, ny=16, viscosity=.1, velocity=1e-3, rho=1.0,
lattice_model="D2Q9", collision="SRT", streaming="double_buffer",
store_precision="FP32", ddf_shifting=False, les_enabled=False)
with CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as run:
p = run.patch
coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny)
-4
View File
@@ -910,7 +910,3 @@ class FixedPatchStepper:
_fine_leaf = property(lambda self: self._leaf[1])
_child_leaf = property(lambda self: self._leaf[2] if len(self._leaf) > 2 else None)
_middle_kind = property(lambda self: self.hierarchy.kinds[1] if self.hierarchy.depth > 2 else None)
# Preserve the original entry point and its periodic default.
PeriodicPatchStepper = FixedPatchStepper
+5 -3
View File
@@ -90,6 +90,7 @@ class Simulation:
@context_bound
def stream(self) -> cuda.Stream:
"""Internal CUDA stream for async DTOH coordination."""
self._require_usable()
if self._stream is None:
self._stream = cuda.Stream()
return self._stream
@@ -174,9 +175,10 @@ class Simulation:
"""Change one jet's signed amplitude; re-uploads per-link velocity only.
Invalid input raises before any change. Any error after validation
leaves the host amplitude unchanged but the device column undefined,
so the Simulation is marked failed: every later call except close()
raises; close and recreate it.
(stream creation or upload) leaves the host amplitude unchanged but the
device column undefined, so the Simulation is marked failed: every later
device-touching public method (incl. ``stream``) raises; close() still
works. Close and recreate it.
"""
self._require_usable()
jet = self.bodies.jets[index].with_amplitude(amplitude)
+53
View File
@@ -0,0 +1,53 @@
"""Direct Simulation Jet control upload; boundary-law accuracy is tested elsewhere."""
import numpy as np
import pycuda.driver as cuda
from CelerisLab.body.jets import Jet, Radial, Sector
from CelerisLab.lbm.descriptors import FLUID
from ._cases import make_sim
def test_simulation_jet_amplitude_upload_and_continued_steps():
# Same grid/circle/sector as the existing uniform surface-actuation fixture.
center = (16., 12.)
with make_sim(nx=32, ny=24, velocity=0) as sim:
sim.add_body("circle", center=center, radius=4.3)
index = sim.add_jet(Jet(Sector(center, .7, .85, 4.4),
Radial(center, inward=False), "quartic", .002))
sim.initialize()
curved = sim.field.curved
shape, jet_id = curved.jet_shape.copy(), curved.jet_id.copy()
flags = sim.get_flags()
driven = jet_id == index
assert driven.any() and (~driven).any()
velocity_pointer = int(curved.surface_velocity_gpu)
previous = None
for interval, amplitude in enumerate((.002, -.001)):
if interval:
count = sim.stepper.step_count
sim.set_jet_amplitude(index, amplitude)
assert sim.stepper.step_count == count # update does not step
expected = np.zeros_like(curved.surface_velocity)
expected[driven] = (amplitude * shape[driven]).astype(np.float32)
actual = np.empty_like(expected)
with sim.ctx.activate():
cuda.memcpy_dtoh(actual, curved.surface_velocity_gpu)
np.testing.assert_array_equal(actual, expected) # actual device column
np.testing.assert_array_equal(curved.surface_velocity, expected)
assert np.any(actual[driven] != 0)
if previous is not None:
assert not np.array_equal(actual[driven], previous[driven])
previous = actual.copy()
assert sim.bodies.jets[index].amplitude == amplitude
assert curved.jets[index].amplitude == amplitude
assert int(curved.surface_velocity_gpu) == velocity_pointer
np.testing.assert_array_equal(curved.jet_shape, shape)
np.testing.assert_array_equal(curved.jet_id, jet_id)
np.testing.assert_array_equal(sim.get_flags(), flags)
sim.run(2)
assert sim.stepper.step_count == 2 * (interval + 1)
assert sim._failed is None
macro = sim.get_macroscopic()
fluid = (flags.reshape(24, 32) & FLUID) != 0
assert all(np.isfinite(value[fluid]).all() for value in macro.values())
assert np.all(macro["rho"][fluid] > 0)
+25
View File
@@ -10,6 +10,7 @@ import numpy as np
import pytest
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module
from CelerisLab.body.jets import Jet, Radial, Sector
from tests.unit.test_pinned_body_remap import ready
from tests.unit.test_simulation_lifecycle import make_simulation
@@ -68,6 +69,30 @@ def test_simulation_upload_failure_keeps_host_and_blocks_reuse(monkeypatch):
assert sim._closed and sim.ctx.is_closed
def test_simulation_stream_creation_failure_fails_closed(monkeypatch):
sim, jet = simulation(monkeypatch)
created = []
def stream():
created.append(1)
raise RuntimeError("stream creation failed")
monkeypatch.setattr(module.cuda, "Stream", stream, raising=False)
sim._stream = None
with pytest.raises(RuntimeError, match="stream creation failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
assert sim.field.curved.writes == [] and created == [1]
for call in (lambda: sim.stream, lambda: sim.set_jet_amplitude(0, 2e-3)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert created == [1] # the public stream property does not retry creation
sim.ctx.detach_failure = True
with pytest.raises(RuntimeError, match="detach failed"):
sim.close()
assert not sim._closed
sim.close() # close stays retryable after the failure
assert sim._closed and sim.ctx.is_closed
def test_simulation_before_initialize_commits_host_only(monkeypatch):
sim, _ = simulation(monkeypatch)
sim._initialized = False
@@ -20,7 +20,7 @@ from CelerisLab.lbm import refinement_stepper as implementation
from CelerisLab.lbm.blocks import BlockLayout2D
from test_refinement_gpu import (ROOT, POP_ATOL, MOMENT_ATOL, ref, postcollision,
totals, LBMConfig, CudaContext, compiler,
PeriodicPatchStepper)
FixedPatchStepper)
def scalar_interval(coarse, fine, bounds, slot=None):
@@ -123,7 +123,7 @@ class FaultGuardsCPU(unittest.TestCase):
refinements=[bounds], level_blocks=[(1, 1)] * 2)
def fake_backend(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend = FixedPatchStepper.__new__(FixedPatchStepper)
layout = BlockLayout2D.build(1, 1, 1, 1, periodic=(False, False))
backend._layouts = [layout, layout] # blocked storage on both levels
backend._ddf_shifting = False # unshifted on every level, as _check_storage expects
@@ -175,7 +175,7 @@ class FaultGuardsCPU(unittest.TestCase):
context = Mock(activate=nullcontext, sm_arch="sm_70")
with patch.object(implementation, "HALO", 2):
with self.assertRaisesRegex(RuntimeError, "four-cell"):
PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)])
p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
from CelerisLab.lbm import hierarchy
@@ -187,11 +187,11 @@ class FaultGuardsCPU(unittest.TestCase):
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), bad, patch.object(cuda, "mem_alloc") as alloc, \
patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "config chain is inconsistent"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds],
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
with patch.object(hierarchy, "_check_chain"), \
self.assertRaisesRegex(RuntimeError, "omega/tau"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds],
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
alloc.assert_not_called()
upload.assert_not_called()
@@ -202,7 +202,7 @@ class FaultGuardsCPU(unittest.TestCase):
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds],
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
upload.assert_not_called()
@@ -348,7 +348,7 @@ class NormalBuildGPU(unittest.TestCase):
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 CudaContext(0) as context, PeriodicPatchStepper(
with CudaContext(0) as context, FixedPatchStepper(
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(state, spec, maps))
@@ -381,7 +381,7 @@ class NormalBuildGPU(unittest.TestCase):
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 CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[bounds],
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context, refinements=[bounds],
level_blocks=[(13, 17), (14, 16)]) as backend:
self.assertEqual(backend.patch.fine_shape, (14, 16))
p = backend.patch
@@ -22,7 +22,7 @@ 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
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
ROOT = Path(__file__).resolve().parents[3]
POP_ATOL = 3e-6
@@ -124,7 +124,7 @@ class CoupledGPU(unittest.TestCase):
"wrong-phase controls PASS", flush=True)
def test_cleanup_attempts_every_allocation(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend = FixedPatchStepper.__new__(FixedPatchStepper)
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "failed"
@@ -146,7 +146,7 @@ class CoupledGPU(unittest.TestCase):
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),
with FixedPatchStepper(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))
@@ -223,7 +223,7 @@ class CoupledGPU(unittest.TestCase):
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)],
with FixedPatchStepper(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")):