Compare commits

..
28 changed files with 2367 additions and 3200 deletions
+40 -18
View File
@@ -123,7 +123,9 @@ with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
``` ```
Scope and limits: Scope and limits:
- D2Q9, SRT, FP32, double buffer, unshifted, no LES or forcing; fixed 2:1 patch(es), two levels (or one nested third). - The examples below use D2Q9, SRT, FP32, double buffer, unshifted, no LES or
forcing. Fixed 2:1 hierarchies can use level lists of arbitrary depth; the
legacy two/three-level forms are shown below.
- `coarse_boundary="periodic"` (default) or `"slot_reservoir"` (bottom slot, top - `coarse_boundary="periodic"` (default) or `"slot_reservoir"` (bottom slot, top
reservoir; patch needs `y0 >= 4`, `y1 <= ny-4`). Periodic mode ignores the wall/slot config fields. reservoir; patch needs `y0 >= 4`, `y1 <= ny-4`). Periodic mode ignores the wall/slot config fields.
- Object centers/radii use the coarse **cell-edge** frame (cell `I` spans `[I, I+1)`), - Object centers/radii use the coarse **cell-edge** frame (cell `I` spans `[I, I+1)`),
@@ -135,16 +137,20 @@ Scope and limits:
divide the WHOLE fine allocation, i.e. `2*(y1-y0)+8` by `2*(x1-x0)+8` including the divide the WHOLE fine allocation, i.e. `2*(y1-y0)+8` by `2*(x1-x0)+8` including the
four-cell ghost band. It gives results identical to dense storage and is four-cell ghost band. It gives results identical to dense storage and is
infrastructure for future block AMR, not a speedup. infrastructure for future block AMR, not a speedup.
- Several separated patches: pass a sequence of `(x0, y0, x1, y1)` - Several regions: pass a sequence of `(x0, y0, x1, y1)` rectangles as
rectangles as `bounds`, at least 4 coarse cells apart, with both `fine_block` `bounds`, with both `fine_block` and `base_block`. Transfers are built once
and `base_block`. The fine level is then one level-wide grid `(2*ny, 2*nx)`: from their union; adjacent, overlapping and concave unions are accepted when
their actual dependencies close. There is no fixed rectangle-gap rule.
The fine level is then one level-wide grid `(2*ny, 2*nx)`:
`run.patch.fine_shape` gives it, `initialize`/`download` use it, and only the `run.patch.fine_shape` gives it, `initialize`/`download` use it, and only the
patch windows (active region plus 4-cell ghost band, origin `(2*y0-4, 2*x0-4)`) grid-aligned tiles intersecting the union plus its four-cell ghost band are stored.
are stored; each window must align with `fine_block`. Order of rectangles does Block sizes must divide the level-wide grid; rectangle edges need not align
not matter. `fine_objects`/`jets` work as for one patch: each circle (same with tiles. Order of rectangles does not matter. `fine_objects`/`jets` work
coarse cell-edge frame) must lie wholly inside one patch's active region with as for one patch: each circle (same coarse cell-edge frame) must lie wholly
inside the union's active region with
its boundary stencil clear of transfer cells; a jet may not select links of two its boundary stencil clear of transfer cells; a jet may not select links of two
different bodies, even across patches. No wall-touching patches or nesting yet. different bodies, even across regions. Wall-touching patches remain unsupported.
For nesting, use the level lists or legacy child forms below.
```python ```python
cfg = LBMConfig(nx=32, ny=16, viscosity=.1) cfg = LBMConfig(nx=32, ny=16, viscosity=.1)
@@ -158,7 +164,7 @@ Scope and limits:
coarse_g, fine_g = run.download() # coarse: NaN on every covered cell; coarse_g, fine_g = run.download() # coarse: NaN on every covered cell;
# fine: NaN on every non-ACTIVE cell # fine: NaN on every non-ACTIVE cell
``` ```
- Three levels (one nested chain): add `child=(x0, y0, x1, y1)` in - Legacy three-level scalar chain: add `child=(x0, y0, x1, y1)` in
**L1 window cells** (the patch's fine grid including its 4-cell ghost band) and **L1 window cells** (the patch's fine grid including its 4-cell ghost band) and
`child_block`, with `fine_block` and `base_block`. L2 must sit inside L1's `child_block`, with `fine_block` and `base_block`. L2 must sit inside L1's
ACTIVE cells, away from L1's ghost band and transfer footprint (checked before ACTIVE cells, away from L1's ghost band and transfer footprint (checked before
@@ -192,14 +198,30 @@ Scope and limits:
run.step(4) run.step(4)
coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN
``` ```
- Not available: per-link surface exchange/force readout in blocked mode, - Level lists: `refinements[l]` describes level `l+1` in allocated level-`l`
checkpoints, moving bodies, L1 bodies/Jets in a three-level chain, integer cells; use a rectangle list for a union, or a scalar rectangle for a
several patches per level with nesting, dynamic refinement, `Simulation` single window (a scalar child requires a scalar parent). `level_blocks`,
integration. Validation so far is storage/operator equivalence and short runs, `level_objects` and `level_jets` each have one entry per level, including L0.
not accuracy, convergence or long-time stability. The new L2-body GPU gates These lists are mutually exclusive with the legacy patch keywords. Three or
(layouts, masks, amplitude reversal, legacy equivalence) and independent more levels require all-blocked storage and bodies/Jets only on the finest
every-substep numerical replay remain required acceptance checks; CPU tests level. Every adjacent pair must preserve inherited ACTIVE coverage, valid
alone do not establish GPU numerical correctness. donors/receivers, surviving pulls and the upstream transfer footprint.
Level `l` takes `2**l` steps per base interval. This describes implemented
topology and scheduling, not accuracy or convergence qualification.
- Blocked body levels can enable `observe_raw_exchange=True` and call
`read_raw_exchange(body_id, level=...)` after a completed base interval.
It reduces unique cut-link observations into wall/aperture/total force,
torque and mass exchange in base-lattice units per unit depth, for the last
base interval (default) or `window="last_substep"`.
`observe_window_exchange=True`, alongside `observe_raw_exchange=True`, enables
`start_exchange_window()` / `read_exchange_window(body_id, level=...)` across
completed base intervals. These are raw boundary-reaction diagnostics, not a general
stress or nozzle-load accuracy claim; dense body layouts are unsupported.
- Not available: checkpoints, moving bodies, L1 bodies/Jets in a three-level
chain, automatic dynamic refinement, `Simulation` integration. Qualification
depends on the tested configuration; see [the validation scope](docs/validation/2d_scope.md).
CPU topology/schedule tests alone do not establish GPU numerical correctness,
general convergence or long-time stability.
## Installation ## Installation
@@ -0,0 +1,57 @@
# Experimental projected G explosion
`FixedPatchStepper(..., explosion="projected_linear")` changes only the existing
G-to-G explosion reconstruction. The omitted/default `"constant"` selector retains
the same kernel, launch arguments and device allocations. Projected mode adds two
compact tables per adjacent pair; recursion, collision, BFL, restriction, buffer
ownership and synchronized publication are unchanged. `remap_child` preserves it.
Runtime eligibility is periodic-root, fully blocked D2Q9/SRT/FP32, body/Jet-free,
without LES; the fixed-patch backend has no forcing interface. Shifted and unshifted
storage use the same reconstruction. One generic builder handles scalar windows,
unique union rows and all adjacent hierarchy pairs without depth/rectangle caps.
Donors are original explosion donors intersected with owned, uncovered ACTIVE
cells. An axis uses centered differences only if both neighbors are legal;
otherwise both addresses repeat the center. Nested layout periodicity is explicit.
No covered cells, holes, inactive cells or stale halo copies enter the stencil.
The 33-line kernel is unchanged from our `519473a` experiment and retains its
mathematical attribution. Host tests cover union decomposition, concavity, narrow
gaps, grazing packet paths, offsets, sparse/nested owner addresses, three/four-level
source timing, unchanged constant dispatch, remapping and shifted/unshifted actual
kernel arithmetic compiled as host C++. These are not CUDA or flow validation.
## Reviewed bounded evidence
The `1aa8110` CUDA gate, with production exactly `a135252`, passed exact-dev
omitted/default and explicit-constant equivalence, per-population reconstruction,
scalar/union/four-level scheduling, alternate layouts, and changed remapping in
both storage modes. Operator maximum error was 2.98023224e-8; the eight-step
concave replay maximum was 1.58037614e-7. Whole-step mass/momentum remain
finite diagnostics, not an FP32 conservation certificate. Reviewed evidence:
`7aa66ff0542175092753ea099f40b903ece0527e`.
The fresh four-case `2cf9853` N128/256 Taylor–Green repeat retained all `11901a7`
settings and gates. All 20 main scalar rows and 260 early-pressure samples matched
the older receipts exactly; this is not field-array equality. Projected worst
velocity was 0.001995299260844 versus 0.002 (only 0.235% margin), pressure
0.012157174904932 and energy 0.001184362645162. Constant velocity
0.004418076615223 failed its gate, so the aggregate verdict remains false.
The projected early sampled pressure peak was 0.021621453573334; it remains a
descriptive sampled maximum. Reviewed evidence: `90849a55cad4a0da9b72747da7bfe6eb75962a44`.
The old N64 projected failures remain relevant. These results support only this
bounded plumbing gate and short periodic Re100 smooth-decay experiment. They do
not establish union/multilevel physics accuracy, convergence, long-time stability,
body/Jet, boundary, forcing, MRT or LES suitability. Constant remains the default.
## Separate LES source profile
The two older LES source manifests and the historical default are unchanged and
correctly reject this different source tree. The existing LES runner now offers
`--experiment a135252`, pinning all 101 actual committed corrected source files.
Its explicit baseline-header override describes only a synthetic isolated reverse
control, never the committed a135252 header. The unchanged corrected-only uniform
LES regression (Cs=0/.16/1) is the remaining integration gate; no LES result is
inherited from the projected evidence. The separate historical jet-CV `channel.py`
pin mismatch predates this port and is not addressed here.
+21 -9
View File
@@ -1,8 +1,11 @@
# Fixed three-level separated patches # Fixed three-level rectangle unions
`FixedPatchStepper` supports exactly three static, blocked levels with separated This page describes the legacy three-level entry point of `FixedPatchStepper`,
rectangle sets on either adjacent pair. This is not general AMR: there is no with rectangle unions on either adjacent pair. The `refinements` level-list
per-region timestep, moving geometry, dynamic refinement or L1 body support. entry point also supports arbitrary depth; three or more levels require
all-blocked storage and bodies/Jets only on the finest level. This is not
general AMR: there is no per-region timestep, moving geometry, automatic
dynamic refinement or L1 body support in the three-level form.
All regions share one current/spare buffer pair per level. The existing schedule All regions share one current/spare buffer pair per level. The existing schedule
advances L0 once, L1 twice and L2 four times per base step, with aggregate transfers. advances L0 once, L1 twice and L2 four times per base step, with aggregate transfers.
No CUDA kernel changes are involved. No CUDA kernel changes are involved.
@@ -30,12 +33,15 @@ are physically equivalent to parent set `[(4,4,12,12)]` with child set
`[(8,8,16,16)]`. Reusing the same numbers after changing the parent frame can `[(8,8,16,16)]`. Reusing the same numbers after changing the parent frame can
move the physical region. move the physical region.
Both adjacent pairs require a Chebyshev gap of at least four in their own Both adjacent pairs are built from their unions, without a fixed rectangle-gap
parent-cell units. The whole child union must preserve inherited ACTIVE rule. Adjacent, overlapping and concave unions are accepted only when their
coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream actual transfer dependencies close. The whole child union must preserve
inherited ACTIVE coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
restriction footprint. Sparse holes remain unowned; inherited INNER remains restriction footprint. Sparse holes remain unowned; inherited INNER remains
stream-only and OUTER remains invalid. Block sizes must align with every stream-only and OUTER remains invalid. Block sizes must divide their allocated
support edge, including its four-cell ghost band. level grids. Sparse storage owns every grid-aligned tile intersecting the union
plus its four-cell ghost band; rectangle and support edges need not align with
tile edges.
## Example ## Example
@@ -65,6 +71,12 @@ CPU geometry, table, pre-CUDA rejection, schedule and cleanup tests:
PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_refinement*.py' -v PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_refinement*.py' -v
``` ```
`tests/unit/test_union_refinement.py` covers union dependencies and tile
ownership; `tests/unit/test_hierarchy.py` covers composed geometry through four
levels. `tests/unit/test_raw_exchange.py` checks the four-level host schedule
with CUDA mocks. These establish topology/control-flow coverage, not numerical
qualification at every depth or interface shape.
Explicit GPU gates are in Explicit GPU gates are in
`tests/validation/multigrid/test_refinement_three_level_multipatch_gpu.py`. `tests/validation/multigrid/test_refinement_three_level_multipatch_gpu.py`.
They cover physical-frame equivalence, twins, asymmetric refinement, multiple They cover physical-frame equivalence, twins, asymmetric refinement, multiple
+37 -25
View File
@@ -1,11 +1,15 @@
# Synchronized leaf G remap and manual continuation # Synchronized leaf G remap and manual continuation
This is a CPU state-transfer component, **not a runtime adaptive solver**. This is a CPU state-transfer component, **not a runtime adaptive solver**.
It changes only a separated L2 rectangle set in a three-level, body-free, It changes only an L2 rectangle union in a three-level periodic hierarchy.
periodic hierarchy. L0/L1 topology, relaxation parameters, direction order, The body-free case is shown first; stationary L2 bodies/Jets follow the bounded
support contract below. L0/L1 topology, relaxation parameters, direction order,
physical frame and completed time remain unchanged. Existing geometry checks physical frame and completed time remain unchanged. Existing geometry checks
validate child support/nesting and separation. The CPU helper itself adds no CUDA, scheduler, allocator, boundary handling, validate child support/nesting and actual transfer dependencies, without a fixed
collision or ghost refill. A bounded runtime adapter is described below. rectangle-gap rule. The three-level remap limit is unchanged by the stepper's
arbitrary-depth level-list support. The CPU helper itself adds no CUDA,
scheduler, allocator, boundary handling, collision or ghost refill. A bounded
runtime adapter is described below.
```python ```python
from CelerisLab.lbm.refinement import FixedPatchSet2D from CelerisLab.lbm.refinement import FixedPatchSet2D
@@ -29,11 +33,13 @@ origin is retained. Physical lower edges are `origin + spacing*(x,y)` on L0,
`origin + spacing*(parent.fine_origin + (x,y)/4)` on L2. `origin + spacing*(parent.fine_origin + (x,y)/4)` on L2.
Factory options `coarse_boundary` and `objects` must declare `"periodic"` and Factory options `coarse_boundary` and `objects` must declare `"periodic"` and
an empty body collection (all levels). LBMConfig's channel settings do not an empty collection; L2 bodies use `child_objects` as described below.
select this operator's boundary mode. Geometry metadata is factory-created; LBMConfig's channel settings do not select this operator's boundary mode.
Geometry metadata is factory-created;
manual mutation/forging of metadata is unsupported. Caller certifies the manual mutation/forging of metadata is unsupported. Caller certifies the
actual state has no bodies/forcing, that all device work and old readers are actual state matches the declared boundary and has no forcing, that all device
quiescent, and that inputs are the **current owned-leaf postcollision G**. work and old readers are quiescent, and that inputs are the **current owned-leaf
postcollision G**.
Count/phase stamps check consistency, not freshness or active-buffer identity. Count/phase stamps check consistency, not freshness or active-buffer identity.
`current_g` is three NumPy Q-major arrays `(9,ny,nx)`. For blocked inputs, `current_g` is three NumPy Q-major arrays `(9,ny,nx)`. For blocked inputs,
@@ -73,9 +79,10 @@ PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_synchronized_r
`replacement = current.remap_child(rectangles)` returns a new ready `replacement = current.remap_child(rectangles)` returns a new ready
`FixedPatchStepper` at the **same absolute step_count**. It supports only `FixedPatchStepper` at the **same absolute step_count**. It supports only
body-free periodic three-level runs whose existing L2 uses periodic three-level runs with no L0/L1 bodies and an existing L2
`child_rectangles_in_parent`. Scalar or union L1 parents are supported; scalar `FixedPatchSet2D`; stationary L2 bodies/Jets follow the support contract below.
`child=` is deliberately rejected. L0/L1, relaxation and block sizes stay fixed. Scalar or union L1 parents are supported; scalar `child=` is deliberately
rejected. L0/L1, relaxation and block sizes stay fixed.
No tagger, scheduler, in-place replacement, or general rollback is provided. No tagger, scheduler, in-place replacement, or general rollback is provided.
The method holds the stepper lock, validates geometry, synchronizes its stream, The method holds the stepper lock, validates geometry, synchronizes its stream,
@@ -113,13 +120,14 @@ Per-population conservation is not an advancement target under collision.
### Bounded pinned-body extension ### Bounded pinned-body extension
`replacement = current.remap_child(rectangles, child_objects=original_objects)` `replacement = current.remap_child(rectangles, child_objects=original_objects)`
also accepts stationary Circles in separated L2 rectangles. Every body’s also accepts stationary Circles inside the L2 union. Each body's **physical
**complete containing rectangle must remain identical**. Several bodies may boundary, complete BFL support and current per-link velocity must remain
share one pinned rectangle; there need not be any fluid-only rectangles. identical**. Rectangles are not pinned: they may be split, merged or resized,
Separated fluid-only rectangles may be added, removed or moved. Existing and other fluid coverage may change, including within the same connected union,
nesting, four-cell gap, full-circle containment, reverse cut-link coverage and two-hop BFL provided that physical support is unchanged. Existing nesting, union transfer
transfer-footprint checks still apply. This is manual remapping, not adaptive dependencies, full-circle containment, reverse cut-link
body tracking or runtime body editing. coverage and two-hop BFL transfer-footprint checks still apply. This is manual
remapping, not adaptive body tracking or runtime body editing.
Supply all original root edge-frame definitions in their construction order. Supply all original root edge-frame definitions in their construction order.
Do not pass `current.child_boundary.objects`: those are projected fine-local copies. Do not pass `current.child_boundary.objects`: those are projected fine-local copies.
@@ -128,8 +136,8 @@ state, remapping reprojects the supplied definition onto the old topology and
compares exact ordered circle centers/radii, flags, physical cut links, body compares exact ordered circle centers/radii, flags, physical cut links, body
IDs, q, lever arms, hits, BFL support, IDs, q, lever arms, hits, BFL support,
legacy SurfaceActuation specification and current per-link velocity against legacy SurfaceActuation specification and current per-link velocity against
the existing boundary. Omission, changed geometry/actuation and changed body the existing boundary. Omission, changed geometry/actuation or changed physical
rectangle fail without device reads or allocations; reordered bodies fail boundary/BFL support fail without device reads or allocations; reordered bodies fail
too. Root object IDs are still assigned by list position, as in the constructor. too. Root object IDs are still assigned by list position, as in the constructor.
Jets are taken from the live L2 boundary, including the most recent successful Jets are taken from the live L2 boundary, including the most recent successful
@@ -141,12 +149,16 @@ boundary and velocities must match exactly.
typed `FineBoundary2D`; it derives L2 fluid leaves as ACTIVE minus SOLID. No typed `FineBoundary2D`; it derives L2 fluid leaves as ACTIVE minus SOLID. No
caller mask is accepted. Solids, ghosts and holes are never donors and remain caller mask is accepted. Solids, ghosts and holes are never donors and remain
NaN, physical-fluid overlap is exact, and exposed L1 still restricts all four NaN, physical-fluid overlap is exact, and exposed L1 still restricts all four
old fluid children. The helper checks the old/new body pins and boundary too. old fluid children. The helper checks old/new physical boundary and velocity
Its immutable `pinned_rectangles` is a canonical, deduplicated tuple, empty for body-free layouts; rectangle input equality too. Its immutable `pinned_rectangles` is legacy containment metadata,
order does not affect it. Scalar-parent window offsets remain part of the not a remap eligibility constraint; it may be empty for a seam-spanning body.
coordinate conversion. Scalar-parent window offsets remain part of the coordinate conversion.
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`. GPU declarations: Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`.
`tests/unit/test_union_body_support.py` also checks seam-spanning circles,
rectangle repartition/resize, unchanged physical support and live-Jet
preservation. These host checks do not establish GPU evolution accuracy.
GPU declarations:
`tests/validation/multigrid/test_pinned_body_remap_gpu.py`, covering no-op `tests/validation/multigrid/test_pinned_body_remap_gpu.py`, covering no-op
next-step bitwise identity, moved-fluid A→B→A, current-amplitude preservation, next-step bitwise identity, moved-fluid A→B→A, current-amplitude preservation,
physical boundary/velocity equality, source independence and absolute counts. physical boundary/velocity equality, source independence and absolute counts.
+33
View File
@@ -63,6 +63,39 @@ extern "C" __global__ void BlockExplode(
to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells]; to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells];
} }
// Experimental Schornbaum--Ruede (arXiv:1508.07982, Algorithm 2)
// and Chen et al. (doi:10.1016/j.physa.2005.09.036) population interpolation.
// Independent implementation of the mathematical stencil; no upstream source copied.
// Stencil cells are owner cores; a disabled axis repeats its center twice.
extern "C" __global__ void BlockExplodeProjected(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells,
const uint64_t* neighbors, const float* offsets)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row], d = donor[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q) {
float v[4];
for (unsigned int a = 0; a < 4; ++a) {
const uint64_t n = neighbors[4ul * row + a];
v[a] = coarse[(n / coarse_cells) * NQ * coarse_cells
+ q * coarse_cells + n % coarse_cells];
}
float gx = .5f * (v[1] - v[0]), gy = .5f * (v[3] - v[2]);
const float ex = d_cx[q], ey = d_cy[q];
if (q != 0) {
const float along = (ex * gx + ey * gy) / (ex * ex + ey * ey);
gx -= ex * along;
gy -= ey * along;
}
to[index_f(t % TOTAL_CELLS, q)] =
coarse[(d / coarse_cells) * NQ * coarse_cells + q * coarse_cells + d % coarse_cells]
+ offsets[2ul * row] * gx + offsets[2ul * row + 1] * gy;
}
}
// Targets are receiver-local SOURCE slots for the immediately following pull. // Targets are receiver-local SOURCE slots for the immediately following pull.
// Keep child summation order identical to PatchCoarseStep; no collision here. // Keep child summation order identical to PatchCoarseStep; no collision here.
extern "C" __global__ void BlockCoalesce( extern "C" __global__ void BlockCoalesce(
+26 -3
View File
@@ -22,7 +22,7 @@ from .block_stepper import MAX_GRID_DIM
from .hierarchy import build_hierarchy, is_rectangle from .hierarchy import build_hierarchy, is_rectangle
from .refinement import ACTIVE, HALO, FixedPatch2D, FixedPatchSet2D, prepare_fine_boundary from .refinement import ACTIVE, HALO, FixedPatch2D, FixedPatchSet2D, prepare_fine_boundary
from .refinement_tables import (blocked_base_tables, blocked_fine_tables, # noqa: F401 (re-export) from .refinement_tables import (blocked_base_tables, blocked_fine_tables, # noqa: F401 (re-export)
blocked_kind, _prepare_blocked_boundary, blocked_kind, projected_explosion_tables, _prepare_blocked_boundary,
BLOCKED_BOUNDARY_DEVICE_COLUMNS) BLOCKED_BOUNDARY_DEVICE_COLUMNS)
from .descriptors import SOLID from .descriptors import SOLID
from .raw_exchange import summarize_exchange from .raw_exchange import summarize_exchange
@@ -41,6 +41,8 @@ class _Pair:
n_ghosts: int n_ghosts: int
n_rows: int n_rows: int
parent_cells: int # parent block window cells (explode donor stride) parent_cells: int # parent block window cells (explode donor stride)
neighbors: object = None # projected-only owner-core cells; constant allocates neither
offsets: object = None
@dataclass @dataclass
@@ -206,6 +208,12 @@ class FixedPatchStepper:
halo -> BFL -> step four times per base step. Runtime child Jet changes halo -> BFL -> step four times per base step. Runtime child Jet changes
require ``set_jet_amplitude(index, amplitude, level=2)``. require ``set_jet_amplitude(index, amplitude, level=2)``.
``explosion="projected_linear"`` selects experimental transverse linear
reconstruction on each adjacent pair. Default "constant" is unchanged.
Projected mode requires periodic root, all-blocked SRT levels without
bodies/Jets or LES; this backend supplies no forcing. Geometry is generic,
but union/multilevel GPU and matched-physics qualification remain pending.
Level lists (any depth): ``refinements[l]`` gives level l+1 in level-l Level lists (any depth): ``refinements[l]`` gives level l+1 in level-l
integer cells (scalar window or rectangle list, see lbm.hierarchy); integer cells (scalar window or rectangle list, see lbm.hierarchy);
``level_blocks``/``level_objects``/``level_jets`` hold one entry per level, ``level_blocks``/``level_objects``/``level_jets`` hold one entry per level,
@@ -219,7 +227,7 @@ class FixedPatchStepper:
fine_block=None, base_block=None, jets=(), child=None, child_block=None, fine_block=None, base_block=None, jets=(), child=None, child_block=None,
child_objects=(), child_jets=(), child_rectangles_in_parent=None, child_objects=(), child_jets=(), child_rectangles_in_parent=None,
refinements=None, level_blocks=None, level_objects=None, level_jets=None, refinements=None, level_blocks=None, level_objects=None, level_jets=None,
observe_raw_exchange=False, observe_window_exchange=False): observe_raw_exchange=False, observe_window_exchange=False, explosion="constant"):
if HALO != 4: if HALO != 4:
raise RuntimeError("refinement.cu requires a four-cell fine halo") raise RuntimeError("refinement.cu requires a four-cell fine halo")
if coarse_boundary not in ("periodic", "slot_reservoir", "channel"): if coarse_boundary not in ("periodic", "slot_reservoir", "channel"):
@@ -248,6 +256,15 @@ class FixedPatchStepper:
level_blocks = [None if b is None else tuple(b) for b in level_blocks] level_blocks = [None if b is None else tuple(b) for b in level_blocks]
_check_levels(refinements, level_blocks, level_objects, level_jets) _check_levels(refinements, level_blocks, level_objects, level_jets)
depth = len(refinements) + 1 depth = len(refinements) + 1
if explosion not in ("constant", "projected_linear"):
raise ValueError("explosion must be constant or projected_linear")
if explosion == "projected_linear" and (
coarse_boundary != "periodic" or any(b is None for b in level_blocks)
or any(level_objects) or any(level_jets)
or cfg.collision != "SRT" or cfg.les_enabled):
raise ValueError("projected explosion requires periodic all-blocked SRT levels "
"without bodies/Jets or LES")
self._explosion = explosion
self._ddf_shifting = bool(cfg.ddf_shifting) self._ddf_shifting = bool(cfg.ddf_shifting)
if self._ddf_shifting and any(b is None for b in level_blocks): if self._ddf_shifting and any(b is None for b in level_blocks):
raise ValueError("shifted populations require all-blocked levels; dense/mixed is unsupported") raise ValueError("shifted populations require all-blocked levels; dense/mixed is unsupported")
@@ -479,6 +496,11 @@ class FixedPatchStepper:
self._upload(children), len(ghosts), len(targets), self._upload(children), len(ghosts), len(targets),
int(np.prod(pair.coarse_shape if parent.layout is None int(np.prod(pair.coarse_shape if parent.layout is None
else parent.layout.local_shape))) else parent.layout.local_shape)))
if self._explosion == "projected_linear":
neighbors, offsets = projected_explosion_tables(
pair, parent.layout, self.hierarchy.kinds[index])
result.explode = child.module.get_function("BlockExplodeProjected")
result.neighbors, result.offsets = self._upload(neighbors), self._upload(offsets)
if child.layout is not None: if child.layout is not None:
child.kind = self._upload(blocked_kind(child.layout, self.hierarchy.kinds[index + 1])) child.kind = self._upload(blocked_kind(child.layout, self.hierarchy.kinds[index + 1]))
if index == 0 and base is not None: if index == 0 and base is not None:
@@ -653,7 +675,7 @@ class FixedPatchStepper:
child_rectangles_in_parent=new.child.bounds, child_rectangles_in_parent=new.child.bounds,
child_objects=child_objects, child_jets=jets, child_objects=child_objects, child_jets=jets,
observe_raw_exchange=self._observe_raw_exchange, observe_raw_exchange=self._observe_raw_exchange,
observe_window_exchange=self._observe_window) observe_window_exchange=self._observe_window, explosion=self._explosion)
try: try:
if self._ddf_shifting: if self._ddf_shifting:
candidate._initialize_raw(_StoredPopulations(transferred.g, transferred.shifted)) candidate._initialize_raw(_StoredPopulations(transferred.g, transferred.shifted))
@@ -746,6 +768,7 @@ class FixedPatchStepper:
pair, level = self._pairs[index - 1], self._levels[index] pair, level = self._pairs[index - 1], self._levels[index]
pair.explode(parent_buffer, level.current, pair.ghosts, pair.donors, np.uint32(pair.n_ghosts), pair.explode(parent_buffer, level.current, pair.ghosts, pair.donors, np.uint32(pair.n_ghosts),
np.uint64(pair.parent_cells), np.uint64(pair.parent_cells),
*((pair.neighbors, pair.offsets) if pair.neighbors is not None else ()),
grid=((pair.n_ghosts + self._block[0] - 1) // self._block[0], 1), **kw) grid=((pair.n_ghosts + self._block[0] - 1) // self._block[0], 1), **kw)
self._hop(index, level.current, level.spare, **kw) self._hop(index, level.current, level.spare, **kw)
self._hop(index, level.spare, level.current, **kw) self._hop(index, level.spare, level.current, **kw)
+44
View File
@@ -66,6 +66,50 @@ def blocked_base_tables(patch, layout, order, wall_aware=False, *, composed_kind
blocked_kind(layout, dense_kind)) blocked_kind(layout, dense_kind))
def projected_explosion_tables(pair, parent_layout, composed_parent_kind):
"""Experimental transverse linear explosion, in the pair's existing row order.
Schornbaum & Rüde (2016), arXiv:1508.07982, Algorithm 2, following
Chen et al., doi:10.1016/j.physa.2005.09.036. Independently implemented
from the mathematics: centered gradients, perpendicular projection and
child offsets +/-1/4. The kernel applies the projection per population.
Only original explosion donors that are owned, uncovered ACTIVE cells
may supply gradients. Both axis neighbors must be legal; otherwise repeat
the center twice for zero slope. Owner-core addresses avoid stale halos.
The same rule applies to scalar windows, unions and every adjacent pair;
nested layouts retain their own per-axis (usually nonperiodic) topology.
"""
ny, nx = pair.coarse_shape
kind = np.asarray(composed_parent_kind)
if parent_layout.shape != pair.coarse_shape or kind.shape != pair.coarse_shape:
raise ValueError("projected explosion parent layout/kind has the wrong shape")
donor = pair.ghost_coarse.astype(np.intp)
legal = np.zeros(ny * nx, bool)
legal[donor] = True
legal &= (parent_layout.owned.ravel() & ~pair.covered.ravel()
& (kind.ravel() == ACTIVE))
if not legal[donor].all():
raise ValueError("projected explosion donors need owned uncovered parent ACTIVE cells")
y, x = np.divmod(donor, nx)
neighbors = np.repeat(donor[:, None], 4, axis=1)
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
minus = ((y - dy) % ny) * nx + (x - dx) % nx
plus = ((y + dy) % ny) * nx + (x + dx) % nx
valid = legal[minus] & legal[plus]
if not parent_layout.periodic[axis]:
coordinate, extent = (x, nx) if axis == 0 else (y, ny)
valid &= (coordinate > 0) & (coordinate < extent - 1)
neighbors[valid, 2 * axis] = minus[valid]
neighbors[valid, 2 * axis + 1] = plus[valid]
fy, fx = np.divmod(pair.ghost_fine, pair.fine_shape[1])
ox, oy = pair.fine_origin
offsets = np.column_stack((ox + (fx + .5) / 2 - (x + .5),
oy + (fy + .5) / 2 - (y + .5)))
return (np.ascontiguousarray(parent_layout.owner.ravel()[neighbors], dtype=np.uint64),
np.ascontiguousarray(offsets, dtype=np.float32))
# Required device columns plus optional actuation; observation arms are uploaded # Required device columns plus optional actuation; observation arms are uploaded
# separately only when enabled. body_id/aperture remain host reduction metadata. # separately only when enabled. body_id/aperture remain host reduction metadata.
BLOCKED_BOUNDARY_DEVICE_COLUMNS = ("block", "cell", "dir", "q", "fallback", "surface_velocity") BLOCKED_BOUNDARY_DEVICE_COLUMNS = ("block", "cell", "dir", "q", "fallback", "surface_velocity")
+39 -18
View File
@@ -43,12 +43,12 @@ class GuardTests(unittest.TestCase):
def test_current_corrected_pins(self): def test_current_corrected_pins(self):
self.assertEqual( self.assertEqual(
source_guard(ROOT, "corrected", "dev8403956")["base_commit"], source_guard(ROOT, "corrected", "a135252")["base_commit"],
"8403956a9afc0b0bb89265cec374392013d89016", "a135252ae78b47b57d7e77d19001700b3bedac8b",
) )
def test_corrected_and_tamper_guard(self): def test_corrected_and_tamper_guard(self):
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text()) pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
for name in pins["sources"]: for name in pins["sources"]:
@@ -59,24 +59,45 @@ class GuardTests(unittest.TestCase):
"run_uniform_les_flow.subprocess.check_output", "run_uniform_les_flow.subprocess.check_output",
return_value="isolated-fixture\n", return_value="isolated-fixture\n",
): ):
source_guard(root, "corrected", "dev8403956") source_guard(root, "corrected", "a135252")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"): with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "baseline", "dev8403956") source_guard(root, "baseline", "a135252")
target = root / "src/CelerisLab/lbm/kernels/step/one_step_double.cu" for name in ("kernels/step/one_step_double.cu", "kernels/blocks.cu",
target.write_bytes(target.read_bytes() + b"\n// tamper\n") "refinement_stepper.py", "refinement_tables.py"):
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"): target = root / "src/CelerisLab/lbm" / name
source_guard(root, "corrected", "dev8403956") original = target.read_bytes()
target.write_bytes(original + b"\n// tamper\n")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "corrected", "a135252")
target.write_bytes(original)
def test_historical_default_and_manifest_unchanged(self): def test_historical_default_and_manifest_unchanged(self):
manifest = HERE / "data/les_flow/source_pins.json" historical = {
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(), "historical": ("source_pins.json", "87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39"),
"87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39") "dev8403956": ("source_pins_dev8403956.json", "58187908485f2af7c78677d53de98015878372444265ce9e667683b8ef5d410f"),
for variant in ("baseline", "corrected"): }
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"): for experiment, (name, expected) in historical.items():
source_guard(ROOT, variant) manifest = HERE / "data/les_flow" / name
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(), expected)
for variant in ("baseline", "corrected"):
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, variant, experiment)
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, "corrected") # Omission still uses the historical profile.
def test_current_manifest_pins_actual_corrected_tree(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
tracked = subprocess.check_output(["git", "-C", str(ROOT), "ls-files", "src"], text=True)
self.assertEqual(set(pins["sources"]), set(tracked.splitlines()))
for name, expected in pins["sources"].items():
self.assertEqual(hashlib.sha256((ROOT / name).read_bytes()).hexdigest(), expected)
self.assertEqual(pins["sources"][HEADER], pins["candidate_header_sha256"])
self.assertNotEqual(pins["sources"][HEADER], pins["baseline_header_sha256"])
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, "baseline", "a135252")
def test_named_baseline_accepts_only_reversed_header(self): def test_named_baseline_accepts_only_reversed_header(self):
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text()) pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
for name in pins["sources"]: for name in pins["sources"]:
@@ -85,9 +106,9 @@ class GuardTests(unittest.TestCase):
shutil.copyfile(ROOT / name, dest) shutil.copyfile(ROOT / name, dest)
subprocess.run(["git", "apply", "--reverse", str(HERE / "les/data/smagorinsky_normalization.patch")], cwd=root, check=True) subprocess.run(["git", "apply", "--reverse", str(HERE / "les/data/smagorinsky_normalization.patch")], cwd=root, check=True)
with patch("run_uniform_les_flow.subprocess.check_output", return_value="baseline-fixture\n"): with patch("run_uniform_les_flow.subprocess.check_output", return_value="baseline-fixture\n"):
source_guard(root, "baseline", "dev8403956") source_guard(root, "baseline", "a135252")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"): with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "corrected", "dev8403956") source_guard(root, "corrected", "a135252")
def test_gpu_requires_opt_in_before_import(self): def test_gpu_requires_opt_in_before_import(self):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
+394
View File
@@ -0,0 +1,394 @@
"""CPU stencil and actual closed-interface packet budgets; no CUDA claims."""
import numpy as np
import unittest
from pathlib import Path
from dataclasses import replace
import shutil
import subprocess
import tempfile
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D, ACTIVE, OUTER_GHOST
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
from CelerisLab.lbm.hierarchy import build_hierarchy
from tests.unit.test_hierarchy import CFG, FOUR
E = np.array([D2Q9_EX, D2Q9_EY]).T
W = np.array([4/9] + [1/9]*4 + [1/36]*4)
def setup(union=False, bounds=(4, 4, 12, 12)):
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
p = (FixedPatchSet2D.from_config(cfg, [bounds]) if union else
FixedPatch2D.from_config(cfg, bounds))
layout = BlockLayout2D.build(16, 16, 4, 4, periodic=(True, True))
neighbors, offsets = projected_explosion_tables(p, layout, np.where(p.covered, OUTER_GHOST, ACTIVE))
inverse = np.full(layout.nblocks * np.prod(layout.local_shape), -1, dtype=int)
inverse[layout.owner.ravel()] = np.arange(256)
return p, inverse[neighbors], offsets
def reconstruct(c, p, neighbors, offsets, project=True):
# Independent NumPy evaluation of the documented stencil; neighbors have
# been decoded from actual blocked owner IDs, not recomputed geometry.
values = c.reshape(9, -1)[:, neighbors]
g = .5 * (values[:, :, 1::2] - values[:, :, ::2])
if project:
g[1:] -= (np.einsum('qra,qa->qr', g[1:], E[1:]) /
np.sum(E[1:]**2, axis=1)[:, None])[:, :, None] * E[1:, None, :]
out = c.reshape(9, -1)[:, p.ghost_coarse] + np.einsum('qra,ra->qr', g, offsets)
return out, g
def check_legal_footprint_mean_and_characteristic(union, bounds):
p, neighbors, offsets = setup(union, bounds)
assert set(neighbors.ravel()) <= set(p.ghost_coarse)
assert not p.covered.ravel()[neighbors].any()
assert (neighbors >= 0).all()
c = np.random.default_rng(72).normal(size=(9,16,16))
c[:, p.covered] = np.nan # poison every forbidden donor
out, g = reconstruct(c, p, neighbors, offsets)
assert np.isfinite(out).all()
np.testing.assert_allclose(np.einsum('qra,qa->qr', g, E), 0, atol=5e-16)
for d in np.unique(p.ghost_coarse):
rows = p.ghost_coarse == d
assert rows.sum() == 4
np.testing.assert_allclose(out[:, rows].mean(axis=1), c.reshape(9,-1)[:,d], atol=5e-16)
for q in range(1,9):
delta = offsets[rows, None, :] - offsets[None, rows, :]
# Parallel displacements have zero 2D cross product.
parallel = np.isclose(delta[...,0]*E[q,1]-delta[...,1]*E[q,0], 0)
diff = out[q, rows][:,None]-out[q, rows][None,:]
np.testing.assert_allclose(diff[parallel], 0, atol=5e-16)
const = np.broadcast_to(W[:,None,None], (9,16,16)).copy()
constant, _ = reconstruct(const,p,neighbors,offsets)
np.testing.assert_array_equal(constant, np.broadcast_to(W[:,None], constant.shape))
def collide(f, tau):
rho = f.sum(axis=0)
u = np.einsum('qa,q...->a...', E, f) / rho
cu = np.einsum('qa,a...->q...', E, u)
eq = W[:,None] * rho * (1 + 3*cu + 4.5*cu**2 - 1.5*np.sum(u*u,axis=0))
return f + (eq-f)/tau
def totals(c, f, p):
q = c[:,~p.covered].sum(axis=1)+.25*f[:,p.fine_kind==ACTIVE].sum(axis=1)
return np.r_[q.sum(), E.T@q]
def check_full_two_hop_closed_corner_budget(union, collision, bounds):
p, neighbors, offsets = setup(union,bounds)
rng=np.random.default_rng(17)
c=W[:,None,None]*(1+rng.normal(0,.002,(9,16,16)))
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*p.fine_shape)))
start=totals(c,f,p)
# Count exactly the eight coarse diagonal links whose source is also
# uncovered, but whose midpoint lies on the closed rectangular interface.
q, k=np.divmod(p.coalesce_coarse.astype(np.int64),256)
y,x=np.divmod(k,16)
grazing=~p.covered[y-E[q,1],x-E[q,0]]
assert grazing.sum()==8
c[:,p.covered]=np.nan
for _ in range(8):
f.reshape(9,-1)[:,p.ghost_fine]=reconstruct(c,p,neighbors,offsets)[0]
for _ in range(2):
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
f[:,p.fine_kind==OUTER_GHOST]=np.nan
if collision:
f[:,p.fine_kind==ACTIVE]=collide(f[:,p.fine_kind==ACTIVE],p.tau_fine)
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
packets=f.reshape(-1)[p.coalesce_fine].mean(axis=1)
assert np.isfinite(packets[grazing]).all()
out.reshape(-1)[p.coalesce_coarse]=packets
if collision:out[:,~p.covered]=collide(out[:,~p.covered],p.tau_coarse)
out[:,p.covered]=np.nan
c=out
np.testing.assert_allclose(totals(c,f,p),start,rtol=0,atol=2e-12)
def check_projection_is_distinguished_by_characteristic_not_global_mass():
p,n,o=setup()
c=np.random.default_rng(8).normal(size=(9,16,16))
_,g=reconstruct(c,p,n,o,project=False)
assert np.max(abs(np.einsum('qra,qa->qr',g,E))) > .1
class ProjectedExplosionTests(unittest.TestCase):
def test_stencil(self):
for union in (False, True):
for bounds in ((4,4,12,12),(2,3,9,10),(2,2,3,3)):
with self.subTest(union=union,bounds=bounds):
check_legal_footprint_mean_and_characteristic(union,bounds)
def test_closed_interface(self):
for union in (False,True):
for collision in (False,True):
for bounds in ((4,4,12,12),(2,3,14,12),(2,2,3,3)):
with self.subTest(union=union,collision=collision,bounds=bounds):
check_full_two_hop_closed_corner_budget(union,collision,bounds)
def test_projection(self):
check_projection_is_distinguished_by_characteristic_not_global_mass()
def test_affine_and_shift_invariance(self):
p,n,o=setup()
y,x=np.indices((16,16))
c=np.array([.7+q*.01+x*.03+y*.05 for q in range(9)])
out,g=reconstruct(c,p,n,o)
available=n[:,::2]!=n[:,1::2]
gradient=np.array([.03,.05])*available
expected=np.broadcast_to(gradient,(9,*gradient.shape)).copy()
expected[1:]-=(np.einsum('qra,qa->qr',expected[1:],E[1:])/
np.sum(E[1:]**2,axis=1)[:,None])[:,:,None]*E[1:,None,:]
np.testing.assert_allclose(g,expected,atol=2e-16)
shifted,_=reconstruct(c-W[:,None,None],p,n,o)
np.testing.assert_allclose(out-W[:,None],shifted,atol=5e-16)
class ProjectedKernelTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("g++"), "g++ is required for actual-kernel CPU arithmetic")
def test_actual_kernel_fp32_shifted_and_unshifted(self):
p, neighbors, offsets = setup(True)
parent = BlockLayout2D.build(16,16,4,4,periodic=(True,True))
child = p.fine_layout((8,4)) # deliberately unequal parent/child strides
ids, offsets = projected_explosion_tables(p,parent,np.where(p.covered,OUTER_GHOST,ACTIVE))
cells = int(np.prod(parent.local_shape))
targets = child.owner.ravel()[p.ghost_fine]
donors = parent.owner.ravel()[p.ghost_coarse]
source = (Path(__file__).resolve().parents[2] /
"src/CelerisLab/lbm/kernels/blocks.cu").read_text()
kernel = source.split('extern "C" __global__ void BlockExplodeProjected(',1)[1]
kernel = 'void BlockExplodeProjected(' + kernel.split('// Targets are receiver-local SOURCE slots',1)[0]
def array(name, values, kind):
v = np.asarray(values).ravel()
text = ','.join(str(int(x)) if kind=='uint64_t' else float(x).hex()+'f' for x in v)
return f"{kind} {name}[] = {{{text}}};\n"
for shifted in (False,True):
c=np.random.default_rng(11).normal(0,.001,(9,16,16))
if not shifted:c+=W[:,None,None]
c=c.astype(np.float32)
packed=np.full(parent.nblocks*9*cells,np.nan,np.float32)
for q in range(9):packed[parent.slot(parent.owner.ravel(),q)]=c[q].ravel()
# All halo/covered inputs are poisoned; kernel must use legal cores.
for q in range(9):packed[parent.slot(parent.owner.ravel()[p.covered.ravel()],q)]=np.nan
# Hex initializer needs a portable expression for poisoned NaNs.
data=array('coarse',packed,'float').replace('nanf','NAN')
code = '#include <cstdint>\n#include <cmath>\n#include <cstdio>\nusing fpxx=float;\n'
code += f'constexpr unsigned NQ=9, TOTAL_CELLS={int(np.prod(child.local_shape))};\n'
code += 'struct Id {unsigned x;}; Id blockIdx{0},blockDim{1},threadIdx{0};\n'
code += 'unsigned index_f(unsigned k,unsigned q){return q*TOTAL_CELLS+k;}\n'
code += 'int d_cx[]={0,1,-1,0,0,1,-1,1,-1},d_cy[]={0,0,0,1,-1,1,-1,-1,1};\n'+kernel
code += data+array('target',targets,'uint64_t')+array('donor',donors,'uint64_t')
code += array('neighbor',ids,'uint64_t')+array('offset',offsets,'float')
code += f'float fine[{child.nblocks*9*int(np.prod(child.local_shape))}]={{}};\n'
code += f'int main(){{for(unsigned r=0;r<{len(targets)};++r){{threadIdx.x=r;BlockExplodeProjected(coarse,fine,target,donor,{len(targets)},{cells},neighbor,offset);}}'
code += f'for(unsigned q=0;q<9;++q)for(unsigned r=0;r<{len(targets)};++r){{auto t=target[r];printf("%.9g\\n",fine[(t/TOTAL_CELLS)*9*TOTAL_CELLS+q*TOTAL_CELLS+t%TOTAL_CELLS]);}}}}'
with tempfile.TemporaryDirectory() as folder:
src=Path(folder)/'kernel.cpp'; binary=Path(folder)/'kernel'
src.write_text(code)
subprocess.run(['g++','-std=c++17','-O2',str(src),'-o',str(binary)],check=True,capture_output=True)
result=np.fromstring(subprocess.check_output([str(binary)],text=True),sep=' ').reshape(9,-1)
expected,_=reconstruct(c.astype(float),p,neighbors,offsets)
np.testing.assert_allclose(result,expected,atol=6e-8,rtol=2e-7)
UNIONS = [
[(8, 8, 24, 20)],
[(8, 8, 16, 20), (16, 8, 24, 20)], # abutting
[(8, 8, 18, 20), (14, 8, 24, 20)], # overlap
[(8, 8, 16, 20), (17, 8, 24, 20)], # gap 1
[(8, 8, 16, 20), (18, 8, 24, 20)], # gap 2
[(8, 8, 16, 20), (19, 8, 24, 20)], # gap 3
[(8, 8, 20, 14), (8, 14, 14, 22)], # L concavity
[(4, 4, 10, 10), (18, 16, 26, 26)], # separated
[(4, 4, 5, 7), (4, 5, 7, 7)], # known concave exit/re-entry path
]
def decode(layout, neighbors):
inverse = np.full(layout.hole, -1, dtype=int)
inverse[layout.owner[layout.owned]] = np.flatnonzero(layout.owned)
return inverse[neighbors]
def check_pair_stencil(pair, layout, kind):
ids, offsets = projected_explosion_tables(pair, layout, kind)
neighbors = decode(layout, ids)
legal = set(map(int, pair.ghost_coarse))
assert len(np.unique(pair.ghost_fine)) == len(pair.ghost_fine)
assert set(neighbors.ravel()) <= legal
assert layout.owned.ravel()[neighbors].all()
assert (kind.ravel()[neighbors] == ACTIVE).all()
assert not pair.covered.ravel()[neighbors].any()
ny, nx = pair.coarse_shape
for row, donor in enumerate(pair.ghost_coarse):
y, x = divmod(int(donor), nx)
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
ends = [(x - dx, y - dy), (x + dx, y + dy)]
expected = []
for xx, yy in ends:
outside = not (0 <= xx < nx and 0 <= yy < ny)
if outside and not layout.periodic[axis]:
break
cell = (yy % ny) * nx + xx % nx
if cell not in legal:
break
expected.append(cell)
if len(expected) != 2:
expected = [int(donor)] * 2
np.testing.assert_array_equal(neighbors[row, 2*axis:2*axis+2], expected)
fy, fx = divmod(int(pair.ghost_fine[row]), pair.fine_shape[1])
expected = [pair.fine_origin[0]+(fx+.5)/2-(x+.5),
pair.fine_origin[1]+(fy+.5)/2-(y+.5)]
np.testing.assert_array_equal(offsets[row], expected)
assert set(offsets.ravel()) == {-.25, .25}
assert ids.dtype == np.uint64 and ids.flags.c_contiguous
assert offsets.dtype == np.float32 and offsets.flags.c_contiguous
return neighbors, offsets
class GenericProjectedPairs(unittest.TestCase):
def test_single_window_and_singleton_union_exact_stencils(self):
for bounds in ((4,4,12,12), (2,3,9,10), (2,2,3,3)):
a, an, ao = setup(False, bounds)
b, bn, bo = setup(True, bounds)
np.testing.assert_array_equal(a.ghost_coarse, b.ghost_coarse)
np.testing.assert_array_equal(an, bn)
np.testing.assert_array_equal(ao, bo)
# Coordinates differ between scalar and union arrays; physical rows don't.
ay, ax = np.divmod(a.ghost_fine, a.fine_shape[1])
by, bx = np.divmod(b.ghost_fine, b.fine_shape[1])
np.testing.assert_array_equal(ax + 2*a.fine_origin[0], bx)
np.testing.assert_array_equal(ay + 2*a.fine_origin[1], by)
def test_union_decomposition_permutation_and_topologies(self):
cfg = LBMConfig(nx=30, ny=30, viscosity=.1)
layout = BlockLayout2D.build(30,30,6,5,periodic=(True,True))
stencils = []
for rectangles in UNIONS:
pair = FixedPatchSet2D.from_config(cfg, rectangles)
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
n, o = check_pair_stencil(pair, layout, kind)
other = FixedPatchSet2D.from_config(cfg, rectangles[::-1])
on, oo = projected_explosion_tables(other, layout, kind)
np.testing.assert_array_equal(n, decode(layout, on))
np.testing.assert_array_equal(o, oo)
stencils.append((pair.ghost_fine, pair.ghost_coarse, n, o))
c = np.random.default_rng(92).normal(size=(9,30,30))
c[:, pair.covered] = np.nan
values, gradients = reconstruct(c, pair, n, o)
self.assertTrue(np.isfinite(values).all())
np.testing.assert_allclose(np.einsum('qra,qa->qr', gradients, E),0,atol=1e-15)
# q0 keeps its full gradient; no division by zero or forced zero.
expected_q0 = .5*(c[0].ravel()[n[:,1::2]]-c[0].ravel()[n[:,::2]])
np.testing.assert_array_equal(gradients[0], expected_q0)
self.assertGreater(np.max(abs(gradients[0])), .1)
for donor in np.unique(pair.ghost_coarse):
rows = pair.ghost_coarse == donor
self.assertEqual(rows.sum(),4)
np.testing.assert_allclose(values[:,rows].mean(axis=1),c.reshape(9,-1)[:,donor],atol=1e-15)
for q in range(1,9):
delta = o[rows,None,:]-o[None,rows,:]
parallel = delta[...,0]*E[q,1] == delta[...,1]*E[q,0]
diff = values[q,rows][:,None]-values[q,rows][None,:]
np.testing.assert_allclose(diff[parallel],0,atol=1e-15)
for split in stencils[1:3]:
for expected, actual in zip(stencils[0],split):
np.testing.assert_array_equal(actual,expected)
def test_nested_scalar_origins_sparse_holes_and_unequal_strides(self):
cases = [(CFG, FOUR), (LBMConfig(nx=16,ny=16,viscosity=.02),
[(4,4,12,12),(8,8,16,16),(8,8,16,16)])]
for cfg, refs in cases:
hierarchy = build_hierarchy(cfg,refs)
for index,pair in enumerate(hierarchy.pairs):
if index == 0:
layout = BlockLayout2D.build(cfg.ny,cfg.nx,4,4,periodic=(True,True))
else:
prior = hierarchy.pairs[index-1]
layout = (prior.fine_layout((4,8)) if isinstance(prior,FixedPatchSet2D)
else BlockLayout2D.build(*prior.fine_shape,4,8,periodic=(False,False)))
kind = hierarchy.kinds[index]
n,o = check_pair_stencil(pair,layout,kind)
c = np.random.default_rng(index).normal(size=(9,*pair.coarse_shape))
c[:,~layout.owned | (kind != ACTIVE)] = np.nan
self.assertTrue(np.isfinite(reconstruct(c,pair,n,o)[0]).all())
if index and isinstance(pair,FixedPatchSet2D):
self.assertTrue((~layout.owned).any())
self.assertEqual(layout.periodic, (index == 0, index == 0))
def test_periodicity_is_per_axis_and_missing_pairs_repeat_center(self):
cfg=LBMConfig(nx=16,ny=16,viscosity=.1)
p=FixedPatch2D.from_config(cfg,(2,2,14,14))
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
results=[]
for periodic in ((False,False),(True,False),(False,True),(True,True)):
layout=BlockLayout2D.build(16,16,4,4,periodic=periodic)
n,_=check_pair_stencil(p,layout,kind)
results.append(n)
y,x=np.divmod(p.ghost_coarse,16)
for axis,coordinate in enumerate((x,y)):
edge=(coordinate==0)|(coordinate==15)
if not periodic[axis]:
np.testing.assert_array_equal(n[edge,2*axis:2*axis+2],
np.repeat(p.ghost_coarse[edge,None],2,axis=1))
self.assertTrue((results[0]!=results[1]).any())
self.assertTrue((results[0]!=results[2]).any())
self.assertTrue((results[1]!=results[3]).any())
def test_invalid_parent_donors_reject_before_owner_lookup(self):
p,_,_=setup()
layout=BlockLayout2D.build(16,16,4,4)
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
for nonactive in (1,2,255):
bad=kind.copy(); bad.ravel()[p.ghost_coarse[0]]=nonactive
with self.assertRaisesRegex(ValueError,'ACTIVE'):
projected_explosion_tables(p,layout,bad)
with self.assertRaisesRegex(ValueError,'shape'):
projected_explosion_tables(p,layout,kind[:1])
hole_layout=BlockLayout2D.build(16,16,4,4,origins=[(0,0)])
with self.assertRaisesRegex(ValueError,'owned'):
projected_explosion_tables(p,hole_layout,kind)
def test_generic_two_hop_mass_and_momentum_budget(self):
cfg=LBMConfig(nx=30,ny=30,viscosity=.1)
layout=BlockLayout2D.build(30,30,6,5)
grazing_counts=set()
for rectangles in UNIONS:
pair=FixedPatchSet2D.from_config(cfg,rectangles)
ids,o=projected_explosion_tables(pair,layout,np.where(pair.covered,OUTER_GHOST,ACTIVE))
n=decode(layout,ids)
q,k=np.divmod(pair.coalesce_coarse.astype(np.int64),900)
y,x=np.divmod(k,30)
grazing=~pair.covered[(y-E[q,1])%30,(x-E[q,0])%30]
grazing_counts.add(int(grazing.sum()))
self.assertTrue(grazing.any())
for collision in (False,True):
rng=np.random.default_rng(17)
c=W[:,None,None]*(1+rng.normal(0,.002,(9,30,30)))
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*pair.fine_shape)))
start=totals(c,f,pair)
c[:,pair.covered]=np.nan
for _ in range(4):
f.reshape(9,-1)[:,pair.ghost_fine]=reconstruct(c,pair,n,o)[0]
for _ in range(2):
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
f[:,pair.fine_kind==OUTER_GHOST]=np.nan
if collision:
f[:,pair.fine_kind==ACTIVE]=collide(f[:,pair.fine_kind==ACTIVE],pair.tau_fine)
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
packets=f.reshape(-1)[pair.coalesce_fine].mean(axis=1)
self.assertTrue(np.isfinite(packets[grazing]).all())
out.reshape(-1)[pair.coalesce_coarse]=packets
if collision:
out[:,~pair.covered]=collide(out[:,~pair.covered],pair.tau_coarse)
out[:,pair.covered]=np.nan
c=out
np.testing.assert_allclose(totals(c,f,pair),start,rtol=0,atol=4e-12)
self.assertGreater(len(grazing_counts),1) # not a hard-coded eight-corner oracle
+162
View File
@@ -0,0 +1,162 @@
"""Host-only checks for the opt-in CUDA gate; never imports or compiles CUDA."""
from copy import deepcopy
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.hierarchy import build_hierarchy
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
from tests.unit.test_hierarchy import CFG, FOUR
from tests.unit.test_projected_explosion import decode, reconstruct, totals
PATH = Path(__file__).resolve().parents[1] / 'validation/multigrid/check_projected_pairs.py'
spec = importlib.util.spec_from_file_location('projected_pair_gate', PATH)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class ProjectedGateOracle(unittest.TestCase):
def check_pair(self, pair, layout, kind):
ids, offsets = projected_explosion_tables(pair, layout, kind)
neighbors = decode(layout, ids)
coarse = np.random.default_rng(731).normal(size=(9, *pair.coarse_shape))
legal = np.zeros(pair.coarse_shape, bool)
legal.ravel()[pair.ghost_coarse] = True
coarse[:, ~legal] = np.nan
expected, _ = reconstruct(coarse, pair, neighbors, offsets)
# The coordinate oracle must not accidentally share production tables.
with patch('CelerisLab.lbm.refinement_tables.projected_explosion_tables',
side_effect=AssertionError('oracle used production stencil')):
actual = gate.independent_explosion(coarse, pair, layout, kind)
np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-15)
center = coarse.reshape(9, -1)[:, pair.ghost_coarse]
self.assertTrue(np.isfinite(actual).all())
self.assertGreater(np.max(abs(actual[0] - center[0])), .01)
self.assertGreater(np.max(abs(actual[1:] - center[1:])), .01)
return coarse, neighbors, offsets, actual
def test_scalar_union_nested_sparse_and_unequal_strides(self):
def fine_layout(pair, block):
return (pair.fine_layout(block) if isinstance(pair, FixedPatchSet2D) else
BlockLayout2D.build(*pair.fine_shape, *block, periodic=(False,False)))
small = LBMConfig(nx=16, ny=16, viscosity=.02)
fixtures = [(small, [(4,4,12,12), (8,8,16,16), (8,8,16,16)]),
(CFG, FOUR), (small, [[(4,4,5,7), (4,5,7,7)]])]
for cfg, refs in fixtures:
hierarchy = build_hierarchy(cfg, refs)
for index, pair in enumerate(hierarchy.pairs):
block, child_block = ((4,4), (4,8)) if index % 2 == 0 else ((4,8), (4,4))
layout = (BlockLayout2D.build(cfg.ny, cfg.nx, *block) if index == 0
else fine_layout(hierarchy.pairs[index-1], block))
with self.subTest(refs=refs, pair=index):
self.assertNotEqual(np.prod(layout.local_shape),
np.prod(fine_layout(pair, child_block).local_shape))
if index and isinstance(pair, FixedPatchSet2D):
self.assertTrue((~layout.owned).any())
self.check_pair(pair, layout, hierarchy.kinds[index])
def test_axis_periodicity_and_mean_preserving_mutants(self):
pair = FixedPatch2D.from_config(LBMConfig(nx=16,ny=16,viscosity=.1), (2,2,14,14))
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
outputs = []
for periodic in ((False,False), (True,False), (False,True), (True,True)):
layout = BlockLayout2D.build(16,16,4,4,periodic=periodic)
coarse, neighbors, offsets, actual = self.check_pair(pair, layout, kind)
outputs.append(actual)
donors, first, inverse = np.unique(pair.ghost_coarse, return_index=True, return_inverse=True)
wrong = neighbors[first[np.roll(np.arange(len(donors)), 1)]][inverse]
for bad_neighbors, bad_offsets in ((neighbors, -offsets), (wrong, offsets)):
mutant, _ = reconstruct(coarse, pair, bad_neighbors, bad_offsets)
self.assertGreater(np.max(abs(mutant - actual)), .01)
for donor in donors:
rows = pair.ghost_coarse == donor
self.assertEqual(rows.sum(), 4)
np.testing.assert_allclose(mutant[:,rows].mean(axis=1),
coarse.reshape(9,-1)[:,donor], rtol=0, atol=1e-15)
bad_kind = kind.copy(); bad_kind.ravel()[pair.ghost_coarse[0]] = OUTER_GHOST
with self.assertRaisesRegex(AssertionError, 'illegal center donor'):
gate.independent_explosion(coarse, pair, layout, bad_kind)
for i, j in ((0,1), (0,2), (1,3), (2,3)):
self.assertGreater(np.max(abs(outputs[i] - outputs[j])), .01)
def test_concave_replay_preserves_existing_fp64_budget(self):
cfg, refs, blocks = gate.cases()['concave']
hierarchy = build_hierarchy(cfg, refs); pair = hierarchy.pairs[0]
layout = BlockLayout2D.build(cfg.ny, cfg.nx, *blocks[0])
rng = np.random.default_rng(17)
values = tuple(gate.W[:,None,None] * (1 + rng.normal(0,.002,(9,*shape)))
for shape in (pair.coarse_shape, pair.fine_shape))
start = totals(*values, pair)
values[0][:,pair.covered] = np.nan
for _ in range(8):
values = gate.replay_two_hops(values, pair, layout, hierarchy.kinds[0])
np.testing.assert_allclose(totals(*values, pair), start, rtol=0, atol=4e-12)
self.assertEqual(gate.REPLAY_ATOL, 3e-6) # CUDA replay bound is separate.
class ProjectedGateReceipts(unittest.TestCase):
def test_digest_exact_finite_bits_and_nan_masks(self):
values = np.array([0., -0., np.nan, 1.], dtype=np.float32)
self.assertEqual(gate.digest(values), gate.digest(values.copy()))
payload = values.copy(); payload.view(np.uint32)[2] = 0x7fc00001
self.assertEqual(gate.digest(values), gate.digest(payload))
variants = [np.array([-0., -0., np.nan, 1.], np.float32),
np.array([0., np.nan, -0., 1.], np.float32),
np.array([0., -0., np.nan, np.nextafter(np.float32(1), np.float32(2))], np.float32),
values.reshape(2,2), values.astype(np.float64)]
for changed in variants:
self.assertNotEqual(gate.digest(values), gate.digest(changed))
with self.assertRaisesRegex(AssertionError, 'infinite'):
gate.digest(np.array([np.inf]))
def test_snapshot_negative_controls_and_exact_dev_options(self):
snapshot = [dict(step=0, levels=[dict(raw='raw',public='public',active=4)],
mass_momentum=[1.,0.,0.],observed_level_hops=[0,0])]
gate.compare_snapshots(snapshot, deepcopy(snapshot))
diagnostic = deepcopy(snapshot); diagnostic[0]['mass_momentum'][0] = 2.
gate.compare_snapshots(snapshot, diagnostic) # Diagnostics are not bitwise gate fields.
mutants = [[]]
for key, value in (('raw','changed'), ('public','changed'), ('active',3)):
changed = deepcopy(snapshot); changed[0]['levels'][0][key] = value
mutants.append(changed)
changed = deepcopy(snapshot); changed[0]['step'] = 1; mutants.append(changed)
changed = deepcopy(snapshot); changed[0]['observed_level_hops'] = [0,1]; mutants.append(changed)
for changed in mutants:
with self.assertRaises(AssertionError):
gate.compare_snapshots(snapshot, changed)
self.assertEqual(gate.default_options('omitted'), {})
self.assertEqual(gate.default_options('constant'), {'explosion':'constant'})
self.assertEqual(gate.default_options('projected_linear'), {'explosion':'projected_linear'})
def test_baseline_receipt_rejects_provenance_and_contract_mutants(self):
keys = [f'{name}:shifted={shifted}' for name in gate.cases() for shifted in (False,True)]
good = dict(completed=True,dry_run=False,mode='baseline',production_revision=gate.BASE,
source=dict(revision=gate.BASE,dirty=False),driver_sha256='driver',
stops=list(gate.STOPS),operator_tolerance=[6e-8,2e-7],
rest_tolerance=[3e-7,1e-6],replay_tolerance=[3e-6,0],default_snapshots=dict.fromkeys(keys, []),
actual_build_sidecars=[{'ptx_sha256':'captured'}])
self.assertIs(gate.baseline_receipt(good, 'driver', keys), good)
mutants = dict(completed=False,dry_run=True,mode='candidate',production_revision='wrong',
driver_sha256='wrong',stops=[0,1,2],operator_tolerance=[1e-6,2e-7],
rest_tolerance=[3e-7,1e-5],replay_tolerance=[3e-6,1e-7],actual_build_sidecars=[],default_snapshots={})
for field, value in mutants.items():
bad = deepcopy(good); bad[field] = value
with self.subTest(field=field), self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
for changes in (dict(revision='wrong'), dict(dirty=True), dict(dirty=None)):
bad = deepcopy(good); bad['source'].update(changes)
with self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
bad = deepcopy(good); bad['default_snapshots']['unexpected'] = []
with self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
if __name__ == '__main__':
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""Production host dispatch with CUDA doubles; no CUDA compilation or flow runs."""
from contextlib import nullcontext
from dataclasses import replace
from unittest.mock import Mock
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from tests.unit.test_hierarchy import CFG, FOUR
from tests.unit.test_refinement_three_level_multipatch import host_module
def backend(**options):
host = host_module()
calls, allocations, storage = [], [], {}
context = Mock(activate=nullcontext, sm_arch='sm_70')
host.cuda.mem_alloc.side_effect = lambda size: allocate(size)
def allocate(size):
pointer = Mock(name=f'buffer{len(allocations)}', size=size)
allocations.append(pointer)
return pointer
def load(_):
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(
name=name, side_effect=lambda *a, **kw: calls.append((name, a, kw)))
return module
host.compiler.load_module.side_effect = load
host.cuda.memcpy_htod.side_effect = lambda ptr, a: storage.__setitem__(id(ptr), np.array(a, copy=True))
host.cuda.memcpy_dtoh.side_effect = lambda a, ptr: np.copyto(a, storage[id(ptr)])
cfg = options.pop('cfg', CFG)
refinements = options.pop('refinements', FOUR)
blocks = options.pop('level_blocks', [(4, 4)] * (len(refinements) + 1))
run = host.FixedPatchStepper(cfg, context=context, refinements=refinements,
level_blocks=blocks, **options)
run.initialize(*(np.full(level.shape, 1/9, np.float32) for level in run._levels))
return run, host, calls, allocations, storage
def trace(run, calls, allocations, storage):
"""Stable pointer identities expose allocations/arguments, not just kernel names."""
pointer = {id(value): index for index, value in enumerate(allocations)}
launches = [(name, tuple(('ptr', pointer[id(a)]) if id(a) in pointer else int(a)
for a in args), kw['grid'], kw['block'])
for name, args, kw in calls]
uploads = [(index, value.dtype.str, value.shape, value.tobytes())
for index, ptr in enumerate(allocations) if (value := storage.get(id(ptr))) is not None]
return [p.size for p in allocations], uploads, launches
class ProjectedPairDispatch(unittest.TestCase):
def test_omitted_and_constant_have_identical_allocations_uploads_and_launches(self):
results = []
for options in ({}, {'explosion': 'constant'}):
run, host, calls, allocations, storage = backend(**options)
self.assertEqual(run._explosion, 'constant')
self.assertTrue(all(p.neighbors is None and p.offsets is None for p in run._pairs))
run.step()
explosion = [args for name, args, _ in calls if name == 'BlockExplode']
self.assertEqual(len(explosion), 7)
self.assertTrue(all(len(args) == 6 for args in explosion))
self.assertFalse(any(name == 'BlockExplodeProjected' for name, _, _ in calls))
results.append(trace(run, calls, allocations, storage))
run.close()
self.assertEqual(*results)
def test_projected_adds_exactly_two_tables_per_adjacent_pair(self):
for depth in (2, 3, 4):
constant, _, _, ca, _ = backend(refinements=FOUR[:depth-1])
projected, _, calls, pa, storage = backend(
refinements=FOUR[:depth-1], explosion='projected_linear')
self.assertEqual(len(pa)-len(ca), 2*(depth-1))
for pair, geometry in zip(projected._pairs, projected.hierarchy.pairs):
self.assertEqual(storage[id(pair.neighbors)].shape, (len(geometry.ghost_fine), 4))
self.assertEqual(storage[id(pair.offsets)].shape, (len(geometry.ghost_fine), 2))
self.assertEqual(pair.neighbors.size, len(geometry.ghost_fine)*4*8)
self.assertEqual(pair.offsets.size, len(geometry.ghost_fine)*2*4)
projected.step()
explosions = [args for name, args, _ in calls if name == 'BlockExplodeProjected']
self.assertEqual(len(explosions), 2**(depth-1)-1)
self.assertTrue(all(len(args) == 8 for args in explosions))
self.assertFalse(any(name == 'BlockExplode' for name, _, _ in calls))
for args in explosions:
pair = next(p for p in projected._pairs if args[2] is p.ghosts)
self.assertIs(args[6], pair.neighbors)
self.assertIs(args[7], pair.offsets)
self.assertEqual(args[5], pair.parent_cells)
constant.close(); projected.close()
def test_three_and_four_level_schedule_has_fresh_midpoint_sources(self):
for depth in (3, 4):
for explosion in ('constant', 'projected_linear'):
run, host, calls, allocations, storage = backend(
refinements=FOUR[:depth-1], explosion=explosion)
before = len(allocations)
events = []
for index, pair in enumerate(run._pairs):
pair.explode.side_effect = lambda source, *a, index=index, **kw: events.append(('explode', index, source))
pair.coalesce.side_effect = lambda child, source, *a, index=index, **kw: events.append(('restrict', index, source))
for index, level in enumerate(run._levels):
level.kernel.side_effect = lambda source, *a, index=index, **kw: events.append(('step', index, source))
level.halo_copy.side_effect = lambda source, *a, index=index, **kw: events.append(('halo', index, source))
expected = []
def hop(index, source):
if index+1 < depth:
expected.append(('explode', index, source))
child = run._levels[index+1]
hop(index+1, child.current)
hop(index+1, child.spare)
expected.append(('halo', index, source))
if index+1 < depth:
expected.append(('restrict', index, source))
expected.append(('step', index, source))
hop(0, run._levels[0].current)
run.step()
self.assertEqual(events, expected)
self.assertEqual([level.kernel.call_count for level in run._levels], [2**i for i in range(depth)])
self.assertEqual(len(allocations), before)
self.assertEqual(run.step_count, 1)
for index in range(1, depth-1):
feeds = [source for name, pair, source in events if name == 'explode' and pair == index]
self.assertEqual(feeds, [run._levels[index].current, run._levels[index].spare] * (2**(index-1)))
run.close()
def test_scalar_windows_follow_the_same_pair_path(self):
run, _, _, _, _ = backend(cfg=LBMConfig(nx=16,ny=16,viscosity=.02),
refinements=[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
level_blocks=[(4,4),(4,8),(8,4),(4,4)], explosion='projected_linear')
self.assertTrue(all(pair.neighbors is not None for pair in run._pairs))
self.assertEqual([pair.parent_cells for pair in run._pairs], [36,60,60])
run.step()
self.assertEqual([level.kernel.call_count for level in run._levels], [1,2,4,8])
run.close()
def test_physics_gate_precedes_context_and_allocations(self):
host = host_module()
context = Mock()
options = dict(refinements=FOUR[:1], level_blocks=[(4,4)]*2,
explosion='projected_linear', context=context)
for cfg, changes in [(CFG, {'explosion':'unknown'}),
(CFG, {'coarse_boundary':'slot_reservoir'}),
(CFG, {'coarse_boundary':'channel'}),
(CFG, {'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}),
(CFG, {'level_objects':[(),(object(),)]}),
(CFG, {'level_jets':[(),(object(),)]}),
(replace(CFG,collision='MRT'), {}),
(replace(CFG,les_enabled=True), {})]:
with self.subTest(changes=changes, cfg=cfg), self.assertRaisesRegex(ValueError,'explosion'):
host.FixedPatchStepper(cfg, **dict(options, **changes))
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
# The legacy constant path still allows MRT and dense storage.
for options in ({'cfg':replace(CFG,collision='MRT')},
{'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}):
run, _, _, _, _ = backend(**options)
run.close()
def test_remap_preserves_selector_tables_and_constant_default(self):
for explosion in ('constant','projected_linear'):
run, host, _, _, storage = backend(refinements=FOUR[:2], explosion=explosion)
run._step_count = 7
old = run.download()
clone = run.remap_child(FOUR[1])
self.assertEqual(clone._explosion, explosion)
self.assertEqual(clone.step_count, 7)
for pair in clone._pairs:
self.assertEqual(pair.neighbors is None, explosion == 'constant')
for expected, actual in zip(old, clone.download()):
np.testing.assert_array_equal(actual,expected)
clone.step()
self.assertEqual((run.step_count,clone.step_count), (7,8))
clone.close(); run.close()
def test_changed_remap_geometry_keeps_projected_in_both_representations(self):
changed = [(22,22,42,42), (72,68,76,76)]
for shifted in (False, True):
run, _, _, _, _ = backend(cfg=replace(CFG,ddf_shifting=shifted),
refinements=FOUR[:2], explosion='projected_linear')
run._step_count = 3
before = run.download()
clone = run.remap_child(changed)
self.assertEqual(clone._explosion, 'projected_linear')
self.assertEqual(clone.hierarchy.pairs[1].bounds, tuple(changed))
self.assertNotEqual(clone._pairs[1].n_ghosts, run._pairs[1].n_ghosts)
for old, new in zip(run._pairs, clone._pairs):
self.assertIsNot(old.neighbors, new.neighbors)
self.assertIsNotNone(new.neighbors)
for expected, actual in zip(before, run.download()):
np.testing.assert_array_equal(actual, expected)
clone.step()
self.assertEqual((run.step_count, clone.step_count), (3, 4))
clone.close(); run.close()
@@ -0,0 +1,110 @@
{
"base_commit": "a135252ae78b47b57d7e77d19001700b3bedac8b",
"production_origin_commit": "a135252ae78b47b57d7e77d19001700b3bedac8b",
"source_experiment": "Explicit a135252 uniform SRT LES regression. Sources pin the committed corrected tree; baseline is a synthetic isolated reverse-header control, not the committed a135252 source. Historical profiles and receipts remain unchanged.",
"sources": {
"src/CelerisLab/__init__.py": "21cc469bd6e970a1f9ff5970c8eea4602065e41fc881854759ee565ee55c10c6",
"src/CelerisLab/body/__init__.py": "e4e0e8f0769a94428d5b02fac790443997556cfc5515a5f1ed1595d2c694d69d",
"src/CelerisLab/body/action_smoother.py": "b1a23ee6bbd4850b172cb8738c5c3546ee465a54189e6af9d8044f023d4687dc",
"src/CelerisLab/body/coupling/__init__.py": "c931f7eb318d48408c3e2febcc6917c88c8dcc1697cbf4329b1f340d94c35763",
"src/CelerisLab/body/coupling/soa_packer.py": "8ec26fd55ef74801bbd5ab6bd3d0a7dabc750cbd498aaac2ca4c61a0108ad3c9",
"src/CelerisLab/body/ddf_patch.py": "60fcfb7441e73bf290bcff41ca3a83da3e7ddad765d7247464e8f02e3043b4e2",
"src/CelerisLab/body/geometry/__init__.py": "ada88980ab672959aca9aa3a4c4aade3219524b2ab68110cd2a2f41e3e0ca90a",
"src/CelerisLab/body/geometry/base.py": "cceca14361ca2f34d34a3e43730e1a79b8ca81be00f0dd62e884db32b479419f",
"src/CelerisLab/body/geometry/circle.py": "7605ca7c21103f1d801ac73ab8dd18dffbdac425b2dee7b8858b283628a9e436",
"src/CelerisLab/body/jets.py": "4104c24af4651f4bb4f8d8fd36fb6d88f41c6e4469822f36e22e74c9ede31dac",
"src/CelerisLab/body/manager.py": "01b653d25bd9b4333379c8bfd12383596b686d2029f2e91b1c91b618540ec6b3",
"src/CelerisLab/body/objects.py": "8e63cee8faa9cc6243bcff428be628dfad73a02a9a3b8ece7d78c0a63406a08d",
"src/CelerisLab/body/preprocess/__init__.py": "dfd9d58bc07f626ab23a70053e31b88a6d9165d2e4c76a4e9f48abe674d3d2ec",
"src/CelerisLab/body/preprocess/flag_overlay.py": "831fc9e9736b758847759a085ff133eb49ec91318ace4fda76f85960138ee300",
"src/CelerisLab/body/registry.py": "fbb7327780adbdc6da15ebdb799980daf32df22ee974af0893d7409f177bbb68",
"src/CelerisLab/body/sync_plan.py": "3a15bfe9ebeb91482dbfc68850761ef39c872cc12d2efc795babd0c01e392323",
"src/CelerisLab/common/__init__.py": "d4dbde78bc97fccc9eabac9abae8f0f15c112c10ff8e9b45bd9401d4723df1ef",
"src/CelerisLab/common/_types.py": "aad2ff1a9033d4fd68783ad7123afb47201ddc58785cb6dc0246947037072a5f",
"src/CelerisLab/common/checkpoint.py": "b2f960aecd5ac27a3a7130b809ffad4709c44d522edef0359ab952654625410b",
"src/CelerisLab/common/pathline.py": "a1b6efcc0a076d0f1dc8d49a595779426d7d5626b00072a59bac710ff02ad046",
"src/CelerisLab/common/preprocess.py": "7fc78a08857cf8e98519500b094a1d896af189b6b15658ff9b388ed51bed4515",
"src/CelerisLab/common/render.py": "0e055476257eb2bcd11e733766e52195de02f8fad228f570a3511a7c831bdc29",
"src/CelerisLab/common/streakline/__init__.py": "13ff1cfa416fdd721a08a659e76b5a5a99b8d3201add10a83b7b0bc654bc1eec",
"src/CelerisLab/common/streakline/_config.py": "a9185ef68e9c7b5d5a1dc0b04a28341e2ad91d13c145a7c106636a878cf0a605",
"src/CelerisLab/common/streakline/_integrate.py": "3fbd4368b2c8d0e7fda40dc65aac5df02b085008b4c68acd83cb7ae45d33a13b",
"src/CelerisLab/common/streakline/_render.py": "236cd637414ee318c4d66f9aed32110d8dc42a100d65fde06172f7cd0ba62c65",
"src/CelerisLab/common/streakline/_streakline.py": "1a3e08daa706b5f83cdeb337e9a945a36ddf7583c40af1d0bb9d825ac954df37",
"src/CelerisLab/config.py": "2f1bba7515f5bc08cb3a8d140bcafe7fa0689e01b3b06494697bb2956a71530e",
"src/CelerisLab/configs/CONFIG.md": "41cafdbe525366c08aa9d0416ea026ccf0026ba7123b8a9b65f968a80d928e3a",
"src/CelerisLab/configs/config_body.json": "055f261f0828ca1d4f2bb694bd15b72bbcabe31d552326208f28e37f761adab1",
"src/CelerisLab/configs/config_lbm.json": "17e4311f1f250c6ece59177a8b6ff856b69a016c7c10267e774b030a0a390865",
"src/CelerisLab/configs/config_lbm_flume_mrt_zouhe.json": "8f593321db05a563a42931603a228d61cbcba06dfbe113262d5cd0ad93c0fe79",
"src/CelerisLab/configs/config_lbm_flume_trt_regularized.json": "f4bba8e6a4339fc3c4eb14f6e2f57c4b22993c73703c773d853edfbe34736e56",
"src/CelerisLab/configs/config_lbm_three_cylinder_triangle.json": "eeb8e7149dbe4aa6b3a9562802735aa950750dfbe2d972a0727e788573afa817",
"src/CelerisLab/cuda/__init__.py": "0d01dff9f0399c8baba822aab5ca0122a135f011008ed8a499ebd9f7e5b06aea",
"src/CelerisLab/cuda/compiler_v2.py": "c4cc3ec4445c8d75e6a253dd87963877117a722bc73b9d4811806fc6045d08c1",
"src/CelerisLab/cuda/context.py": "013dd4ec6d01d2fbd11afdfb103eac6a273bd9f798d663d761b851eeb27885e9",
"src/CelerisLab/cuda/obs_layout.py": "5ffb2c3aeeaaaaeb64f2cd8febfbcf4ad9919e5414020ad753cfade0683b21c2",
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
"src/CelerisLab/lbm/channel.py": "40d0354e5dc97bcdb7e7b5c1e7f9303a109b7134d8a3af88b8ff4cd75ab5a095",
"src/CelerisLab/lbm/curved_links.py": "b6957fa13d13f864dba4f7364e93a39905d062dbb3ee9b3f5191add81fff0732",
"src/CelerisLab/lbm/descriptors.py": "c9f025dfc14da43b989047b84cc22bd05d5cc92c9ec695c1da68fdfb788e3f2b",
"src/CelerisLab/lbm/equilibrium.py": "2fb2f2bb7ff326d23abce80fc2a4c0907b17740ea3e29fe6ae079c5044783826",
"src/CelerisLab/lbm/field.py": "0e727eacc3706add4d8d15c418e9b631050871f831698d3ac689dd5f2c701b9b",
"src/CelerisLab/lbm/hierarchy.py": "f234975074f6b6d289d573ff6ea6832feae0e27cb04f80b37fe81792b13a12e7",
"src/CelerisLab/lbm/initializers.py": "dacf9586963e862d6d0a98ef1a066036be6f91e9d423720661d8ef2c1fb2df6a",
"src/CelerisLab/lbm/kernels/blocks.cu": "668e5cf14e88bdcfa281c2fa6c75c0f8f8cdbbfb8ba2ca03064ae02ef7ad0057",
"src/CelerisLab/lbm/kernels/boundary/bounce_back.cuh": "aba3748c564ed91b161d268ef8b737b7c7be96a823063d201f3da684dd845233",
"src/CelerisLab/lbm/kernels/boundary/curved_boundary.cuh": "aeb16bfd483514fb03e15a2fed437d673c3b8b2fe68ab83edf4244ef593878ea",
"src/CelerisLab/lbm/kernels/boundary/esopull_curved_helpers.cuh": "b9b474bc4edaff05681c5443235472d04072d0d775fc0eb9c5967faef52b0258",
"src/CelerisLab/lbm/kernels/boundary/ibm_kernels.cuh": "e6909b547dc8d64cdd8a76a90660243a8f5fee535caa161b52b4dd8b7c29934b",
"src/CelerisLab/lbm/kernels/boundary/inlet/channel_stabilized.cuh": "efe077afcbb6b731256a987c2fd79a1dbc470c902f87070d60421ea6cb13d4e2",
"src/CelerisLab/lbm/kernels/boundary/inlet/common.cuh": "59b38a89439878e00de7de16b9c7b03feff443fcc5a421593f7d2536dcbb7ab8",
"src/CelerisLab/lbm/kernels/boundary/inlet/equilibrium.cuh": "f0964c811b2c41a5aaa1c6d99c490b06e850c634eb8749f1f05ef61f93c2de1d",
"src/CelerisLab/lbm/kernels/boundary/inlet/regularized.cuh": "943d4a571818bacb89c70bccfaac202691121e3e385803ce6309123a0a03ddaf",
"src/CelerisLab/lbm/kernels/boundary/inlet/zou_he_local.cuh": "e73e66b853b1e4e0e30d8f4185d832aa414ce670a1813ce2b5459f2887f71f89",
"src/CelerisLab/lbm/kernels/boundary/inlet_outlet.cuh": "de8b213a6da42c2e5e9f8966199fc66ea4541b85144a90f3f5dbad3c6ab63b97",
"src/CelerisLab/lbm/kernels/boundary/outlet/pressure_neq.cuh": "c8b2adc66d12fe602076ddc9693c5c208711fcaddb58aa9a31401a269f51c4c4",
"src/CelerisLab/lbm/kernels/boundary/planar_velocity.cuh": "501b2759d0e2e4280cb6fdd127d66a50fa89f6de48fc2cae9a7eafb11ae90bed",
"src/CelerisLab/lbm/kernels/config.h": "7d1124125e0b354558297934460e64c760fb68cdd7bf6d01a1238bb2eaab8c37",
"src/CelerisLab/lbm/kernels/config/config_grid.h": "1db3e030ebdc116cd7f02362d1fd077daac55e8f0b4329f6285948c224d3ca5d",
"src/CelerisLab/lbm/kernels/config/config_method.h": "4374e04a130f90f97d17cc20c40d2f9ea0ed0dd8603817d07f215d02266e247c",
"src/CelerisLab/lbm/kernels/config/config_objects.h": "db793af052c756561a190a3ff2cb93ee5fe28462ee4f97c6af80882b4dc8b072",
"src/CelerisLab/lbm/kernels/config/config_obs.h": "c11e2249359145b5c4cbaaf0487506fa77c1f4b82c7c169ddc574aa0babcd727",
"src/CelerisLab/lbm/kernels/config/config_physics.h": "8ac0990b168e1058588676cc05c80df8634cfd671dbc92a89246c3c372d83a4e",
"src/CelerisLab/lbm/kernels/core/descriptors.cuh": "8440bc14857da9007982650f828a23608eec9f7cc4bf772e8c8dc1239cb45356",
"src/CelerisLab/lbm/kernels/core/flags.cuh": "0025705f24710330d9d9195f713879bfb650a85a3449baef0e196b831af104ab",
"src/CelerisLab/lbm/kernels/core/layout.cuh": "1fbd820021c89ac3efcde24145996cb0147769e607e498e61f308ed3e0b13706",
"src/CelerisLab/lbm/kernels/core/obs.cuh": "b742b77c9f61e1f020fe9922c3838ae9d855e17b381154958b58f10a22938922",
"src/CelerisLab/lbm/kernels/core/params.cuh": "6e18bf730d936d940e3d85452f0714a2eaae31bf41002f9fd520ec5b372878dd",
"src/CelerisLab/lbm/kernels/core/precision.cuh": "849df14b4cce50af52399e4b8f3e997196f3ae5f1163f68e28df3e6b2507a8c4",
"src/CelerisLab/lbm/kernels/kernel_v2.cu": "c7bd3fb705b77261ba2441c52a24a7e6a7865c165cba088a1b75201c4d29c408",
"src/CelerisLab/lbm/kernels/operators/collision_mrt.cuh": "aee444eb60c47a3fd518dca975d1af05425a0818a0ec6c3fb3f6150f60c2cb44",
"src/CelerisLab/lbm/kernels/operators/collision_srt.cuh": "6b7ff842f392c8f853d2d1a97cc69bfe50f21283f73e659404da07cf6c95f5e1",
"src/CelerisLab/lbm/kernels/operators/collision_trt.cuh": "9725b9840b9ad8b4ec7be61ff5d06d3151e86d1a87585c13f2fbf934d0d2c5f6",
"src/CelerisLab/lbm/kernels/operators/forcing_guo.cuh": "969a430c1af03f32e3bd0df95d805e64f56334717c44dd4ba208ba503062a24b",
"src/CelerisLab/lbm/kernels/operators/helpers.cuh": "0b3a4d95d7d19f5fec09e5423b8f4cc99f8a226b541967425967330da5a051ec",
"src/CelerisLab/lbm/kernels/operators/macro.cuh": "2be20eb2b7d7f5522daaad7f4b57b1ff8cdf883efe4a67846a96783b2fe846a3",
"src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
"src/CelerisLab/lbm/kernels/refinement.cu": "b45aad9c022cecb64f7e968ec3cae2d8385a3cc430ee409700c404a3f4dd8cf8",
"src/CelerisLab/lbm/kernels/step/aux_kernels.cu": "578a5b20a157241ee678547059abb1372dd5a589ad06bb2c15b987eb01ca1013",
"src/CelerisLab/lbm/kernels/step/block_channel.cu": "ee8a31398b8e5233d977c816e6b8e060c1eb2ec71320e594226dbca55593c0d0",
"src/CelerisLab/lbm/kernels/step/curved_double.cu": "bfa7a1df1e939020314d49755045f7a85fe4a19cc1c68dbe62653c66c5e7c2b3",
"src/CelerisLab/lbm/kernels/step/esopull_macro.cu": "c73116020ca217ede1e4de27c8269c31f9d91e33433ac8252a21e9b14187c50f",
"src/CelerisLab/lbm/kernels/step/esopull_sync.cu": "c0ff3e72900c3ab89a05dd03684e96d3b740cf1672d565727fc2af165933d087",
"src/CelerisLab/lbm/kernels/step/init_flow.cu": "305be3bb12fc0d039acec1701baabcf0e8a35e683c49c34f2af8ca9867d2b29c",
"src/CelerisLab/lbm/kernels/step/one_step_double.cu": "f9f8242b38e8e0cf8425b75e2b0215bc15363c62eaa0fb43c761b8e0931bfdc9",
"src/CelerisLab/lbm/kernels/step/one_step_esopull.cu": "aa223b2744ae6c9125673df311a96ce588032c1ba58bebb11fd33ecb990f8ed4",
"src/CelerisLab/lbm/kernels/streaming/esopull_semantic_helpers.cuh": "dd5a9091628b079efd97e53fd49e52d09dcee88529235db77a5648e02937af6e",
"src/CelerisLab/lbm/kernels/streaming/esopull_single_buffer.cuh": "77ed4ab9a0ebc5ff62a91a7c39066ef395391ae36e9040a69fb2bd8b1626dc79",
"src/CelerisLab/lbm/kernels/streaming/pull_double_buffer.cuh": "b65d741ed78b7b62f99531ba0c0ce1936663d8dc9640e7d7d16c707f1baf6549",
"src/CelerisLab/lbm/population_storage.py": "a6a86c29c52c892edfe4802fb773c387212e3f4cc86708d80e154986ddf88044",
"src/CelerisLab/lbm/raw_exchange.py": "9031a196006a0f74dee4028e6f3bb673bc19ef7959f9f04dde9e01f93293aeb3",
"src/CelerisLab/lbm/refinement.py": "100d5e46ac894f6008b61364cdad5d37c2ca7ed949d8d71405572d6366526417",
"src/CelerisLab/lbm/refinement_stepper.py": "536cd1d3482591e4ff57c4ae8e888c19e31701687d287aa509129df5b2a43be3",
"src/CelerisLab/lbm/refinement_tables.py": "0cbc0f29aabd0ab4ba1f912bd4d0ac2b430246584cb6ddf13dd28e10582accf3",
"src/CelerisLab/lbm/stepper.py": "0dcb7cd7ae87e33e3dc67a38b6aa43042fbe673e37d9ad165342ee1d695933ce",
"src/CelerisLab/lbm/synchronized_remap.py": "7400fc4beaffd4649d6a9c524d38ce5fd6fa7eeb32eb5332e4030a4b6648f197",
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c"
},
"candidate_header_sha256": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
"baseline_header_sha256": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501"
}
@@ -0,0 +1,425 @@
"""Bounded exact-source CUDA plumbing gate; never a flow-accuracy certificate.
Run this driver externally against exact dev with --mode baseline first, then
against the experimental source with --mode candidate --baseline <receipt>.
Only scalar receipts/hashes and actual compiler sidecars are retained. Whole-step
mass/momentum are diagnostics: no verified FP32 conservation bound is claimed.
"""
import argparse
from contextlib import contextmanager
from dataclasses import replace
import hashlib
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
import numpy as np
BASE = 'ed9d8c937d446b854d6963bd92d718bb4fb98b96'
PRODUCTION = 'a135252ae78b47b57d7e77d19001700b3bedac8b'
STOPS = (0, 1, 2, 8)
OPERATOR_ATOL, OPERATOR_RTOL = 6e-8, 2e-7
REST_ATOL, REST_RTOL = 3e-7, 1e-6
REPLAY_ATOL = 3e-6
E = np.array([(0,0),(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,-1),(1,-1),(-1,1)])
W = np.array([4/9]+[1/9]*4+[1/36]*4)
def digest(array):
"""All finite bits (including signed zero) and exact NaN mask; no NaN payloads."""
array = np.asarray(array)
if np.isinf(array).any():
raise AssertionError('infinite population')
finite = np.isfinite(array)
h = hashlib.sha256()
h.update(str((array.shape, array.dtype.str)).encode())
h.update(np.ascontiguousarray(finite).tobytes())
h.update(np.ascontiguousarray(array[finite]).tobytes())
return h.hexdigest()
def independent_explosion(coarse, pair, layout, kind):
"""Coordinate oracle; never reads the production neighbor/offset tables."""
ny, nx = pair.coarse_shape
legal = np.zeros((ny,nx), bool)
legal.ravel()[pair.ghost_coarse] = True
legal &= layout.owned & ~pair.covered & (kind == 0)
if not legal.ravel()[pair.ghost_coarse].all():
raise AssertionError('illegal center donor')
out = np.empty((9,len(pair.ghost_fine)))
flat = np.asarray(coarse, np.float64).reshape(9,-1)
for row, (target, donor) in enumerate(zip(pair.ghost_fine,pair.ghost_coarse)):
y,x = divmod(int(donor),nx)
gradient = np.zeros((9,2))
for axis, (dx,dy) in enumerate(((1,0),(0,1))):
ends = [(x-dx,y-dy),(x+dx,y+dy)]
if not layout.periodic[axis] and any(not (0<=xx<nx and 0<=yy<ny) for xx,yy in ends):
continue
ends = [(xx%nx,yy%ny) for xx,yy in ends]
if all(legal[yy,xx] for xx,yy in ends):
(xm,ym),(xp,yp) = ends
gradient[:,axis] = (flat[:,yp*nx+xp]-flat[:,ym*nx+xm])/2
for q in range(1,9):
gradient[q] -= E[q]*np.dot(E[q],gradient[q])/np.dot(E[q],E[q])
fy,fx = divmod(int(target),pair.fine_shape[1])
offset = np.array([pair.fine_origin[0]+(fx+.5)/2-(x+.5),
pair.fine_origin[1]+(fy+.5)/2-(y+.5)])
out[:,row] = flat[:,int(donor)] + gradient@offset
return out
def default_options(selector):
# Exact dev has no explosion keyword. Baseline and omitted paths pass none.
return {} if selector == 'omitted' else {'explosion':selector}
def snapshot(run, raw):
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST
stored, public = raw(run), run.download()
hashes, totals = [], np.zeros(3)
for level,(values,visible,kind) in enumerate(zip(stored,public,run.hierarchy.kinds)):
active = kind == ACTIVE
if not np.isfinite(visible[:,active]).all() or not np.isnan(visible[:,~active]).all():
raise AssertionError('public ACTIVE/invalid mask mismatch')
if not np.isnan(values[:,kind==OUTER_GHOST]).all():
raise AssertionError('OUTER owner core is not poisoned')
population = visible[:,active].sum(axis=1,dtype=np.float64)*(.25**level)
totals += np.r_[population.sum(), E.T@population]
hashes.append(dict(raw=digest(values), public=digest(visible), active=int(active.sum())))
if not np.isfinite(totals).all():
raise AssertionError('nonfinite mass/momentum diagnostic')
return dict(step=run.step_count, levels=hashes, mass_momentum=totals.tolist())
def initialize(run, rest=False):
arrays, origin, spacing = [], np.zeros(2), 1.
for index,level in enumerate(run._levels):
if index:
origin += spacing*np.asarray(run.hierarchy.pairs[index-1].fine_origin)
spacing /= 2
y,x = np.indices(level.shape[1:],dtype=np.float64)
x,y = origin[0]+spacing*(x+.5),origin[1]+spacing*(y+.5)
q = np.arange(9)[:,None,None]
values = np.broadcast_to(W[:,None,None],level.shape).copy()
if not rest:
# q-distinct smooth non-equilibrium G; same physical frame on all levels.
values += 1e-4*np.sin(.17*x+.23*y+q)
arrays.append(values)
run.initialize(*arrays)
def operator_case(run, index):
import pycuda.driver as cuda
parent,child = run._levels[index:index+2]
geometry,pair = run.hierarchy.pairs[index],run._pairs[index]
c = np.random.default_rng(19+index).normal(0,.001,(9,*geometry.coarse_shape))
if not run._ddf_shifting:
c += W[:,None,None]
c = c.astype(np.float32)
legal = np.zeros(geometry.coarse_shape,bool)
legal.ravel()[geometry.ghost_coarse] = True
c[:,~legal] = np.nan
packed = parent.layout.to_blocked(c)
output = np.full(child.layout.blocked_shape,np.nan,np.float32)
cuda.memcpy_htod(parent.current,packed)
cuda.memcpy_htod(child.current,output)
calls, hop = [],run._hop
try:
run._hop = lambda *a,**kw: calls.append(a)
run._advance(index+1,parent.current,block=run._block,stream=run._stream)
run._stream.synchronize()
finally:
run._hop = hop
if calls != [(index+1,child.current,child.spare),(index+1,child.spare,child.current)]:
raise AssertionError('explosion changed two-hop launch contract')
cuda.memcpy_dtoh(output,child.current)
target = child.layout.owner.ravel()[geometry.ghost_fine]
slots = np.array([child.layout.slot(target,q) for q in range(9)])
actual = output.ravel()[slots]
expected = independent_explosion(c,geometry,parent.layout,run.hierarchy.kinds[index])
constant = c.reshape(9,-1)[:,geometry.ghost_coarse]
if not np.isfinite(actual).all() or np.max(abs(expected-constant)) <= 1e-5:
raise AssertionError('operator must exercise finite nonzero projected slopes')
np.testing.assert_allclose(actual,expected,atol=OPERATOR_ATOL,rtol=OPERATOR_RTOL)
untouched = np.ones(output.size,bool); untouched[slots.ravel()] = False
if not np.isnan(output.ravel()[untouched]).all():
raise AssertionError('explosion wrote outside its targets')
return dict(pair=index,rows=len(target),parent_stride=pair.parent_cells,
child_stride=int(np.prod(child.layout.local_shape)),
max_error_by_q=np.max(abs(actual-expected),axis=1).tolist(),
nonzero_projection=True,poisoned_nondonors=True,untouched_preserved=True)
def replay_two_hops(values, pair, layout, kind):
"""Existing FP64 two-hop algebra, using the independent coordinate oracle."""
from tests.unit.test_projected_explosion import collide
coarse,fine = (a.copy() for a in values)
fine.reshape(9,-1)[:,pair.ghost_fine] = independent_explosion(coarse,pair,layout,kind)
for _ in range(2):
fine = np.array([np.roll(fine[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
fine[:,pair.fine_kind==2] = np.nan
active = pair.fine_kind==0
fine[:,active] = collide(fine[:,active],pair.tau_fine)
out = np.array([np.roll(coarse[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
out.reshape(-1)[pair.coalesce_coarse] = fine.reshape(-1)[pair.coalesce_fine].mean(axis=1)
out[:,~pair.covered] = collide(out[:,~pair.covered],pair.tau_coarse)
out[:,pair.covered] = np.nan
return out,fine
def run_case(ctx, case, shifted, selector, raw, projected=False, replay=False):
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
cfg,refs,blocks = case
cfg = replace(cfg,velocity=0,ddf_shifting=shifted)
with FixedPatchStepper(cfg,context=ctx,refinements=refs,level_blocks=blocks,
**default_options(selector)) as run:
operator = [operator_case(run,i) for i in range(len(run._pairs))] if projected else []
counts = [0]*len(run._levels)
for index,level in enumerate(run._levels):
def counted(*a, index=index, kernel=level.kernel, **kw):
counts[index] += 1
return kernel(*a,**kw)
level.kernel = counted
def check_counts(stop):
if counts != [stop*2**index for index in range(len(counts))]:
raise AssertionError('actual level hop counts differ from acoustic schedule')
rest = []
if projected:
initialize(run,rest=True)
for stop in STOPS:
run.step(stop-run.step_count)
check_counts(stop)
snapshot(run,raw) # actual public and raw invalid masks at every stop
maximum = 0.
for values,kind in zip(run.download(),run.hierarchy.kinds):
active = values[:,kind==0]
np.testing.assert_allclose(active,np.broadcast_to(W[:,None],active.shape),
atol=REST_ATOL,rtol=REST_RTOL)
maximum = max(maximum,float(np.max(abs(active-W[:,None]))))
rest.append(dict(step=stop,max_absolute_error=maximum))
counts[:] = [0]*len(counts)
initialize(run)
reference = tuple(a.astype(np.float64) for a in run.download()) if replay else None
snapshots,replay_errors,previous = [],[],0
for stop in STOPS:
run.step(stop-run.step_count)
if replay:
for _ in range(stop-previous):
reference = replay_two_hops(reference,run.hierarchy.pairs[0],
run._levels[0].layout,run.hierarchy.kinds[0])
errors = []
for actual,expected,kind in zip(run.download(),reference,run.hierarchy.kinds):
np.testing.assert_allclose(actual[:,kind==0],expected[:,kind==0],
atol=REPLAY_ATOL,rtol=0)
errors.append(np.max(abs(actual[:,kind==0]-expected[:,kind==0]),axis=1).tolist())
replay_errors.append(dict(step=stop,max_error_by_level_q=errors))
check_counts(stop)
snapshots.append(dict(snapshot(run,raw),observed_level_hops=counts.copy())); previous = stop
change = (np.array(snapshots[-1]['mass_momentum'])-
np.array(snapshots[0]['mass_momentum'])).tolist()
return dict(operator=operator,snapshots=snapshots,rest_fixed_point=rest,
replay_max_errors=replay_errors,mass_momentum_change_diagnostic=change,
conservation_status='diagnostic_only_no_FP32_bound')
def compare_snapshots(actual,expected):
if len(actual) != len(expected):
raise AssertionError('snapshot count differs')
for got,want in zip(actual,expected):
if (got['step'] != want['step'] or got['levels'] != want['levels']
or got.get('observed_level_hops') != want.get('observed_level_hops')):
raise AssertionError('owner bits, public values or masks differ')
def cases():
from CelerisLab.config import LBMConfig
from tests.unit.test_hierarchy import CFG, FOUR
scalar = (LBMConfig(nx=16,ny=16,viscosity=.02),
[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
[(4,4),(4,8),(6,4),(4,6)])
four = (CFG,FOUR,[(6,8),(8,6),(12,8),(16,12)])
concave = (LBMConfig(nx=30,ny=30,viscosity=.1),
[[(4,4,5,7),(4,5,7,7)]],[(6,5),(4,6)])
return dict(scalar=scalar,four=four,concave=concave)
def baseline_receipt(receipt, driver_hash, keys):
if (receipt.get('completed') is not True or receipt.get('dry_run') is not False
or receipt.get('mode') != 'baseline' or receipt.get('production_revision') != BASE
or receipt.get('source',{}).get('revision') != BASE
or receipt.get('source',{}).get('dirty') is not False
or receipt.get('driver_sha256') != driver_hash
or receipt.get('stops') != list(STOPS)
or receipt.get('operator_tolerance') != [OPERATOR_ATOL,OPERATOR_RTOL]
or receipt.get('rest_tolerance') != [REST_ATOL,REST_RTOL]
or receipt.get('replay_tolerance') != [REPLAY_ATOL,0]
or set(receipt.get('default_snapshots',{})) != set(keys)
or not receipt.get('actual_build_sidecars')):
raise ValueError('baseline receipt does not match the frozen exact-dev gate')
return receipt
def remap_case(ctx, shifted, raw):
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
cfg,refs,blocks = cases()['four']
with FixedPatchStepper(replace(cfg,velocity=0,ddf_shifting=shifted),context=ctx,
refinements=refs[:2],level_blocks=blocks[:3],explosion='projected_linear') as run:
initialize(run); run.step(1)
before = snapshot(run,raw)
with run.remap_child([(22,22,42,42),(72,68,76,76)]) as clone:
if clone._explosion != 'projected_linear' or any(p.neighbors is None for p in clone._pairs):
raise AssertionError('remap lost projected selection')
if clone.step_count != 1: raise AssertionError('remap lost synchronized count')
initial = snapshot(clone,raw)
clone.step(1)
after = snapshot(clone,raw)
if clone.step_count != 2: raise AssertionError('remap did not continue count')
compare_snapshots([snapshot(run,raw)],[before])
return dict(shifted=shifted,source_unchanged=True,initial=initial,continued=after)
@contextmanager
def capture_builds(report, save, baseline=None):
from CelerisLab.cuda import compiler_v2 as compiler
original = compiler.load_module
def load(path):
path = Path(path)
metadata = json.loads(Path(str(path)+'.json').read_text())
if metadata['ptx_sha256'] != hashlib.sha256(path.read_bytes()).hexdigest():
raise AssertionError('actual PTX and build sidecar disagree')
if baseline and (metadata['arch'],metadata['flags']) not in [
(m['arch'],m['flags']) for m in baseline['actual_build_sidecars']]:
raise AssertionError('baseline build architecture or flags differ')
report['actual_build_sidecars'].append(metadata)
save() # capture AMR metadata before production cleanup removes scratch
return original(str(path))
compiler.load_module = load
try:
yield
finally:
compiler.load_module = original
def preflight(repo, revision, mode):
git = lambda *a: subprocess.check_output(['git','-C',str(repo),*a],text=True).strip()
if git('rev-parse','HEAD') != revision or git('status','--porcelain'):
raise ValueError('requires the exact expected clean checkout')
source = BASE if mode == 'baseline' else PRODUCTION
if mode == 'baseline' and revision != BASE:
raise ValueError('baseline must be exact ed9d8c9')
if subprocess.call(['git','-C',str(repo),'diff','--quiet',source,'--','src']):
raise ValueError('production source differs from the frozen source revision')
return source
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--repo',type=Path,required=True)
parser.add_argument('--expected-revision',required=True)
parser.add_argument('--mode',choices=('baseline','candidate'),required=True)
parser.add_argument('--baseline',type=Path)
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--run-gpu',action='store_true')
parser.add_argument('--dry-run',action='store_true')
args = parser.parse_args()
if args.output.exists(): parser.error('output already exists')
selected = os.environ.get('CUDA_VISIBLE_DEVICES','')
if not args.dry_run and (not args.run_gpu or not selected or ',' in selected or selected=='-1'):
parser.error('--run-gpu and exactly one explicit CUDA_VISIBLE_DEVICES are required')
repo = args.repo.resolve()
source = preflight(repo,args.expected_revision,args.mode)
baseline = None
if args.mode == 'candidate':
if args.baseline is None: parser.error('candidate requires --baseline receipt')
baseline = json.loads(args.baseline.read_text())
sys.path[:0] = [str(repo/'src'),str(repo),str(repo/'tests/validation/multigrid'),
str(repo/'tests/validation/taylor_green')]
import CelerisLab
if Path(CelerisLab.__file__).resolve() != repo/'src/CelerisLab/__init__.py':
raise RuntimeError('wrong imported checkout')
from run_case import provenance
driver_hash = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
keys = [f'{name}:shifted={shifted}' for name in cases() for shifted in (False,True)]
if baseline is not None: baseline_receipt(baseline,driver_hash,keys)
report = dict(mode=args.mode,production_revision=source,source=provenance(repo),
driver_sha256=driver_hash,
device_selection=selected,completed=False,dry_run=args.dry_run,
operator_tolerance=[OPERATOR_ATOL,OPERATOR_RTOL],
rest_tolerance=[REST_ATOL,REST_RTOL],replay_tolerance=[REPLAY_ATOL,0],stops=STOPS,
conservation_status='diagnostic_only_no_FP32_bound',
actual_build_sidecars=[],default_snapshots={},default_equivalence=[],
projected_operator=[],union_multilevel_smoke=[],changed_remap=[])
args.output.parent.mkdir(parents=True,exist_ok=True)
def save():
text = json.dumps(report,indent=2,allow_nan=False)+'\n'
if len(text.encode()) > 8*1024*1024: raise RuntimeError('receipt exceeds 8 MiB cap')
temporary = args.output.with_suffix(args.output.suffix+'.tmp')
temporary.write_text(text); os.replace(temporary,args.output)
save()
if args.dry_run:
print('Source preflight only; no CUDA imports or execution.'); return
try:
from CelerisLab.cuda.context import CudaContext
from test_refinement_blocked_base import raw
with CudaContext(0) as ctx, capture_builds(report,save,baseline):
import pycuda.driver as cuda
report['device'] = dict(name=ctx.device.name(),pci_bus_id=ctx.device.pci_bus_id(),
capability=list(ctx.device.compute_capability()),
driver=cuda.get_driver_version(),runtime=list(cuda.get_version()))
nvcc = shutil.which('nvcc') or '/usr/local/cuda/bin/nvcc'
report['nvcc_version'] = subprocess.check_output([nvcc,'--version'],text=True).strip()
if baseline and (report['device'] != baseline.get('device')
or report['nvcc_version'] != baseline.get('nvcc_version')
or selected != baseline.get('device_selection')):
raise ValueError('baseline GPU/device selection or toolchain differs')
for name,case in cases().items():
for shifted in (False,True):
key = f'{name}:shifted={shifted}'
report['active_case'] = f'default:{key}'
result = run_case(ctx,case,shifted,'omitted',raw)
report['default_snapshots'][key] = result['snapshots']
if baseline:
compare_snapshots(result['snapshots'],baseline['default_snapshots'][key])
explicit = run_case(ctx,case,shifted,'constant',raw)
compare_snapshots(explicit['snapshots'],result['snapshots'])
report['default_equivalence'].append(dict(case=key,passed=True))
save()
if baseline:
for name,case in cases().items():
for shifted in (False,True):
report['active_case'] = f'projected:{name}:shifted={shifted}'
result = run_case(ctx,case,shifted,'projected_linear',raw,True,name=='concave')
cfg,refs,blocks = case
if name == 'four':
variant_refs = [list(reversed(rects)) for rects in refs]
elif name == 'concave':
variant_refs = [[(4,5,7,7),(4,4,5,5)]] # same union, disjoint decomposition
else:
variant_refs = refs
variant = (cfg,variant_refs,[(2,2)]*len(blocks))
alternate = run_case(ctx,variant,shifted,'projected_linear',raw,True)
compare_snapshots(alternate['snapshots'],result['snapshots'])
report['projected_operator'].append(dict(case=name,shifted=shifted,rows=result['operator']))
report['union_multilevel_smoke'].append(dict(case=name,shifted=shifted,result=result,
alternate_bits_identical=True))
save()
for shifted in (False,True):
report['active_case'] = f'changed-remap:shifted={shifted}'
report['changed_remap'].append(remap_case(ctx,shifted,raw)); save()
expected_builds = {(s['arch'],tuple(s['flags'])) for s in baseline['actual_build_sidecars']}
actual_builds = {(s['arch'],tuple(s['flags'])) for s in report['actual_build_sidecars']}
if actual_builds != expected_builds:
raise AssertionError('baseline build architecture or compiler flags differ')
report['completed'] = True
save()
except Exception as error:
report['error'] = f'{type(error).__name__}: {error}'
save(); raise
print(json.dumps(dict(completed=True,mode=args.mode,receipt=str(args.output))))
if __name__ == '__main__':
main()
@@ -1,263 +0,0 @@
"""Four opt-in shifted GPU controls: U64/U128/U256 and refined base128/fine256."""
import argparse
import hashlib
import inspect
import json
import os
from pathlib import Path
import resource
import subprocess
import time
import numpy as np
import run_spacetime_gpu_n64 as old
gpu,ref,tg,ROOT = old.gpu,old.ref,old.tg,old.ROOT
BASE='26d0d3853c9d4d3cd6ae3f27653dced56f8c5fec'
REFERENCE_SHA='9d154c989b087bf288f86d413db0f3067b73767b618c329d4b9e53c4eee00032'
CASES=(('uniform64',64,False),('uniform128',128,False),('uniform256',256,False),('refined128',128,True))
LIMITS=old.LIMITS
SECONDS,RSS_MIB=600.,512.
def setup(n, refined):
return dict(base_n=n,finest_n=2*n if refined else n,normalized_domain=[0.,1.,0.,1.],
re_L=100,U=.03,mach=.03*np.sqrt(3),viscosity=.0003*n,
tau=[.5+.0009*n]+([.5+.0018*n] if refined else []),
bounds=[n//8,n//8,7*n//8,3*n//4] if refined else None,
normalized_patch=[.125,.125,.875,.75] if refined else None,
intervals=16*n,sample_steps=[4*n,8*n,12*n,16*n],sample_times=list(old.TIMES),
early_steps=list(range(0,4*n+1,n//16)),early_times=(np.arange(65)*.001875).tolist(),
phase='arrival_f',storage='shifted_FP32',collision='SRT',
initialization='common analytic FP64 equilibrium at actual cell centers, production shifted codec')
def pressure_projection(arrays,masks,t,n,viscosity):
"""One global gauge and a weighted Gram fit, including unequal leaf areas."""
rows=[]
for level,(a,mask) in enumerate(zip(arrays,masks)):
rho=np.asarray(a[:,mask],np.float64).sum(axis=0)
x,y=tg.centers(mask.shape,level); x,y=x[mask],y[mask]
exact=tg.exact(x,y,t,n,viscosity)[2]
angle=4*np.pi/n
basis=np.column_stack((np.cos(angle*x),np.sin(angle*x),np.cos(angle*y),np.sin(angle*y)))
rows.append(((rho-1)/3,exact,np.full(len(rho),.25**level),basis))
p,exact,w,b=[np.concatenate(x) for x in zip(*rows)]
volume=w.sum(); p-=w@p/volume; exact-=w@exact/volume; e=p-exact
b-=w@b/volume
gram=b.T@(w[:,None]*b); rhs=b.T@(w*e)
coefficients=np.linalg.solve(gram,rhs); residual=e-b@coefficients
norm=float(w@(e*e)); exact_norm=float(w@(exact*exact)); remaining=float(w@(residual*residual))
projected=float(coefficients@gram@coefficients)
return dict(step=t,tstar=t*.03/n,absolute_rms=np.sqrt(norm/volume),relative_rms=np.sqrt(norm/exact_norm),
coefficients=coefficients.tolist(),basis=['cos_4pi_x','sin_4pi_x','cos_4pi_y','sin_4pi_y'],
residual_rms=np.sqrt(remaining/volume),residual_relative=np.sqrt(remaining/exact_norm),
gram_condition=float(np.linalg.cond(gram)),
orthogonality=(b.T@(w*residual)/volume).tolist(),energy_closure=(norm-projected-remaining)/volume)
def load_reference(path):
if old.sha(path)!=REFERENCE_SHA:
raise ValueError('verified shifted N64 receipt differs')
data=json.loads(Path(path).read_text())
if (not data['completed'] or not data['passed'] or data['source']['commit']!=BASE
or data['setup']['storage']!='shifted_FP32' or data['setup']['bounds']!=[8,8,56,48]
or data['setup']['interval_count']!=1024 or len(data['rows'])!=4):
raise ValueError('N64 reference identity differs')
if tuple(row['tstar'] for row in data['rows'])!=old.TIMES:
raise ValueError('N64 reference sample times differ')
return data
def source_identity(reference):
git=lambda *args:subprocess.check_output(['git',*args],cwd=ROOT,text=True).strip()
if git('status','--porcelain'):
raise RuntimeError('clean committed checkout required')
unchanged=['src','tests/validation/taylor_green/oracle.py']+[f'tests/validation/multigrid/{name}' for name in
('spacetime_gpu.cu','spacetime_reference.py','cpu_reference.py','run_spacetime_gpu_n64.py','test_spacetime_gpu.py')]
subprocess.check_call(['git','diff','--exit-code',BASE,'--',*unchanged],cwd=ROOT)
previous=subprocess.check_output(['git','show',f'{BASE}:tests/validation/multigrid/spacetime_gpu.py'],cwd=ROOT,text=True)
# The raw interval schedule remains byte-for-byte unchanged despite the new owner.
method=inspect.getsource(gpu.SpaceTimeGPU.step)
if method not in previous or np.__version__!=reference['source']['numpy']:
raise RuntimeError('raw schedule or NumPy identity changed')
files=[Path(__file__),Path(gpu.__file__),Path(old.table_guard.__file__),Path(__file__).with_name('test_spacetime_gpu_ladder.py')]
return dict(commit=git('rev-parse','HEAD'),dirty=False,numpy=np.__version__,production_tree=git('rev-parse','HEAD:src'),
hashes={p.name:old.sha(p) for p in files},cuda_sha256=old.sha(Path(gpu.__file__).with_suffix('.cu')),
raw_schedule_sha256=hashlib.sha256(method.encode()).hexdigest(),unchanged_against=BASE,
reference_hashes=gpu.PINS)
def directional(arrays,masks):
return sum(.25**level*np.asarray(a[:,m],np.float64).sum(axis=1) for level,(a,m) in enumerate(zip(arrays,masks)))
def populations(run,refined,*,raw=False):
state=run._download_stored() if raw else run.download()
if not refined:
return (state,)
if state.phase!='arrival_f' or state.tick!=2*run.step_count:
raise AssertionError('refined sample is not synchronized arrival F')
return state.coarse_f,state.fine_f
def assess(rows,health):
checks={key:all(np.isfinite(row[key]) and 0<=row[key]<=limit for row in rows) for key,limit in LIMITS.items()}
checks['population_health']=not any(health)
checks['sampled_population_positive']=all(row['minimum_population']>0 for row in rows)
return dict(worst={key:max(row[key] for row in rows) for key in LIMITS},campaign_checks=checks,
campaign_pass=all(checks.values()),accuracy_comparison_status='descriptive_only_no_new_threshold')
def ratios(a,b,*,trend=False):
out=dict(ratio=a/b if b>0 else None,ratio_status='defined' if b>0 else 'zero_denominator')
if trend:
out.update(two_point_slope=np.log2(a/b) if a>0 and b>0 else None,
slope_status='defined' if a>0 and b>0 else 'zero_error_log_undefined')
return out
def comparisons(runs,reference):
data={r['name']:r for r in runs}; data['refined64']=reference
out=[]
for refined,base,fine in (('refined64','uniform64','uniform128'),('refined128','uniform128','uniform256')):
for control in (base,fine):
out.append(dict(candidate=refined,control=control,control_spacing='base' if control==base else 'finest',
samples=[dict(tstar=a['tstar'],**{key:dict(candidate=a[key],control=b[key],**ratios(a[key],b[key]))
for key in ('velocity_l2','pressure_l2','energy_relative')})
for a,b in zip(data[refined]['rows'],data[control]['rows'])]))
trends=[]
for coarse,fine in (('uniform64','uniform128'),('uniform128','uniform256'),('refined64','refined128')):
trends.append(dict(coarse=coarse,fine=fine,samples=[dict(tstar=a['tstar'],**{
key:ratios(a[key],b[key],trend=True)
for key in ('velocity_l2','pressure_l2','energy_relative')})
for a,b in zip(data[coarse]['rows'],data[fine]['rows'])]))
return dict(controls=out,trends=trends,caveat='norm differences are not interface error; fixed Mach and two refined sizes do not certify convergence')
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--gpu-admitted',action='store_true'); p.add_argument('--reference-n64',type=Path,required=True)
p.add_argument('--output',type=Path,required=True); args=p.parse_args()
if not args.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES'):
p.error('explicit GPU admission and CUDA_VISIBLE_DEVICES required')
if args.output.exists() or args.output.with_suffix(args.output.suffix+'.tmp').exists(): p.error('output or partial receipt exists; preserve it')
reference=load_reference(args.reference_n64); source=source_identity(reference); gpu.check_sources()
started=time.monotonic()
report=dict(completed=False,passed=False,source=source,runs=[],limits=dict(seconds=SECONDS,peak_rss_mib=RSS_MIB),
campaign_limits=LIMITS,reference=dict(sha256=REFERENCE_SHA,source=reference['source'],
coverage='verified refined base64/fine128 four-time rows only; early pressure trace unavailable'),
historical_pressure_gate=dict(scope='reused_refined64_only',**reference['historical_pressure_gate']),
n64_experimental_screen=dict(scope='reused_refined64_only',checks=reference['experimental_checks']),
frozen_cases=[setup(n,r) for _,n,r in CASES],early_trace_scope='65 predeclared rows per new run, no field snapshots',
cuda_visible_devices=os.environ['CUDA_VISIBLE_DEVICES'])
run=context=ledger=None
case_started=None
def save():
report['resources']=dict(seconds=time.monotonic()-started,peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
rss_scope='Python process peak; external wrapper monitors process group and wall time')
report['resources']['within_limits']=report['resources']['seconds']<=SECONDS and report['resources']['peak_rss_mib']<=RSS_MIB
if report['completed'] and not report['resources']['within_limits']: report['passed']=False
if run is not None:
report['runs'][-1].update(completed_intervals=run.step_count,health_counts=run.health_counts.tolist(),
seconds=time.monotonic()-case_started)
if ledger is not None:
report['runs'][-1].update(ledger=ledger.snapshot(),audited_intervals=ledger.steps)
temporary=args.output.with_suffix(args.output.suffix+'.tmp')
ref.write_receipt(temporary,report); os.replace(temporary,args.output)
def cap():
if time.monotonic()-started>SECONDS or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024>RSS_MIB:
raise RuntimeError('frozen aggregate resource cap exceeded')
save()
try:
from CelerisLab.cuda.context import CudaContext
context=CudaContext(0)
if context.name!=reference['device'] or context.sm_arch!=reference['sm_arch']:
raise RuntimeError('matched reference device identity differs')
report.update(device=context.name,sm_arch=context.sm_arch)
for name,n,refined in CASES:
cap(); ledger=None; case_started=time.monotonic(); cfg=old.LBMConfig(nx=n,ny=n,viscosity=.0003*n,velocity=0.,ddf_shifting=True)
case=dict(name=name,setup=setup(n,refined),completed=False,phase='setup',rows=[],early_pressure=[],prefixes=[],health_counts=[0]*6,
ledger_scope='all intervals; fine/transfer components are mathematically absent for uniform runs')
report['runs'].append(case); save()
run=gpu.SpaceTimeGPU(cfg,tuple(case['setup']['bounds']),context=context,allow_n128=True) if refined else gpu.UniformGPU(cfg,context=context)
case.update(build_metadata=run.build_metadata,device_allocations_bytes=run.device_bytes,
device_memory_scope='owner allocations; excludes driver/context/compiler')
expected_modules=None
if not refined and n in (64,128): expected_modules=[reference['build_metadata'][0 if n==64 else 1]]
if refined: expected_modules=[report['runs'][1]['build_metadata'][0],report['runs'][2]['build_metadata'][0]]
if expected_modules is not None:
if run.build_metadata!=expected_modules: raise RuntimeError('matched source/config/PTX metadata differs')
case['matched_build_metadata']=True
if any(m['flags']!=reference['build_metadata'][0]['flags'] for m in run.build_metadata):
raise RuntimeError('compiler flags differ from reference')
if refined:
case['tables']=old.table_guard.validate_tables(run.plan,run.tables,allow_n128=True)
masks=(run.plan.maps.coarse_active,run.plan.maps.fine_active)
else: masks=(np.ones((n,n),bool),)
analytic=tuple(tg.initial(m.shape,l,n,cfg.viscosity) for l,m in enumerate(masks))
initial=ref.cpu.State(*analytic) if refined else analytic[0]
physical=lambda:populations(run,refined)
stored=lambda:populations(run,refined,raw=True)
run.initialize(initial); raw0=stored(); direction0=directional(raw0,masks)
case['phase']='warm_reset'; warm_started=time.monotonic()
for _ in range(32): cap(); run.step()
case['warmup_seconds']=time.monotonic()-warm_started
run.initialize(initial)
for a,b in zip(raw0,stored()): np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
if run.step_count or run.health_counts.any(): raise AssertionError('warm reset failed')
case['raw_reset_verified']=True; arrays=physical()
first=tg.metrics(arrays,masks,0,n,cfg.viscosity); mass0=first['mass']; moment0=ref.cpu.MOMENTS.T@directional(arrays,masks)
case['initial']=dict(mass=mass0,momentum=moment0[1:].tolist(),minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
case['initial_quantization']=[float(abs(a[:,m]-b[:,m]).max()) for a,b,m in zip(arrays,analytic,masks)]
case['early_pressure'].append(pressure_projection(arrays,masks,0,n,cfg.viscosity))
ledger=old.LedgerTotals(); case['phase']='trajectory'; trajectory_started=time.monotonic()
case['all_interval_mass']=dict(maximum_relative=0.,step=0,signed_change_at_maximum=0.,limit=LIMITS['mass_relative'],passed=True)
for step in range(1,16*n+1):
cap(); measured=run.step(); ledger.add(measured)
if run.step_count!=step: raise AssertionError('interval count differs')
mass_change=float(ledger.sums[0].sum()); drift=abs(mass_change)/mass0
if drift>case['all_interval_mass']['maximum_relative']:
case['all_interval_mass'].update(maximum_relative=drift,step=step,signed_change_at_maximum=mass_change,passed=drift<=LIMITS['mass_relative'])
if step in case['setup']['early_steps'] or step in case['setup']['sample_steps']:
arrays=physical()
if step in case['setup']['early_steps']:
case['early_pressure'].append(pressure_projection(arrays,masks,step,n,cfg.viscosity)); save()
if step in case['setup']['sample_steps']:
row=tg.metrics(arrays,masks,step,n,cfg.viscosity,mass0)
row.update(step=step,momentum_change=(ref.cpu.MOMENTS.T@directional(arrays,masks)-moment0)[1:].tolist(),
mass_change=row['mass']-mass0,minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
if not all(np.isfinite(v).all() for v in row.values()): raise FloatingPointError('nonfinite sampled metric')
case['rows'].append(row); prefix=ledger.snapshot(directional(stored(),masks)-direction0)
prefix['physical_minus_raw_endpoint_moments']=(ref.cpu.MOMENTS.T@directional(arrays,masks)-moment0-ref.cpu.MOMENTS.T@np.array(prefix['endpoint_directional_change'])).tolist()
case['prefixes'].append(prefix)
if not prefix['closure_pass'] or not prefix['component_split_pass']: raise AssertionError('cumulative budget failed')
save(); print(json.dumps(dict(case=name,**row),allow_nan=False),flush=True)
case.update(assess(case['rows'],run.health_counts))
cap(); case.update(completed=True,phase='completed',health_counts=run.health_counts.tolist(),audited_intervals=ledger.steps,
ledger=ledger.snapshot(),trajectory_seconds=time.monotonic()-trajectory_started,seconds=time.monotonic()-case_started)
case['passed']=case['campaign_pass'] and case['all_interval_mass']['passed'] and ledger.steps==16*n and len(case['early_pressure'])==65
run.close(); run=None; save()
cap(); report.update(completed=True,passed=all(c['passed'] for c in report['runs']),comparisons=comparisons(report['runs'],reference))
except Exception as exc:
report['failure']=repr(exc)
raise
finally:
if run is not None and ledger is not None:
current=report['runs'][-1]
current['failure_endpoint_status']='unavailable_owner_not_ready'
if run.status=='ready':
try:
current['failure_endpoint']=ledger.snapshot(directional(populations(run,refined,raw=True),masks)-direction0)
current['failure_endpoint_status']='measured'
except Exception as exc: current['failure_endpoint_status']=repr(exc)
for owner in (run,context):
if owner is not None:
try: owner.close()
except Exception as exc: report.setdefault('cleanup_errors',[]).append(repr(exc)); report['passed']=False
save()
return 0 if report['passed'] else 1
if __name__=='__main__':
raise SystemExit(main())
@@ -1,392 +0,0 @@
"""Opt-in, frozen N64 space-time GPU trajectory; no convergence certification."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import resource
import subprocess
import sys
import time
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT/'src'))
sys.path.insert(0, str(Path(__file__).parents[1]/'taylor_green'))
from CelerisLab.config import LBMConfig
import oracle as tg
import spacetime_gpu as gpu
import spacetime_table_guard as table_guard
ref = gpu.ref
CPU_SHA = '180eef90a1c3f43113fe18cc54981ee4723cfc0cc2724334907ff188022fa770'
UNIFORM_SHA = '01e7415ae39456f2de7f0c3d331afb971c49d19353b43038650e32e80737f1e7'
CUDA_SHA = 'afea11ea4a1c2c3800b2f6a7a8e81f5568989fc9e1cc02fb52d7e1ea3864e003'
AUDIT_SHA = 'fba340b1533bec308b666ee84666df0ac9d08e2872be31ee379e2d1447565158'
STEPS = (256, 512, 768, 1024)
TIMES = (.12, .24, .36, .48)
FACTORS = dict(velocity_l2=.8, pressure_l2=1., energy_relative=1.)
LIMITS = dict(velocity_l2=.05, pressure_l2=.15, energy_relative=.05, mass_relative=1e-5)
HEALTH = ('boundary_pre_nonfinite', 'boundary_pre_nonpositive', 'boundary_stored_nonfinite',
'boundary_stored_nonpositive', 'physical_population_nonfinite', 'physical_population_nonpositive')
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def load_references(cpu_path, uniform_path):
for path, expected in ((cpu_path, CPU_SHA), (uniform_path, UNIFORM_SHA)):
if sha(path) != expected:
raise ValueError(f'pinned reference receipt differs: {path}')
cpu, uniform = [json.loads(Path(p).read_text()) for p in (cpu_path, uniform_path)]
assert cpu['completed'] and cpu['experimental_pass'] and not cpu['source']['dirty']
assert cpu['source']['commit'] == '07879bc52bfef8d95503146c55d3823c8b6f8d0a'
assert [r['mode'] for r in cpu['runs']] == ['constant_ivp', 'spacetime']
assert all(tuple(row['tstar'] for row in r['rows']) == TIMES for r in cpu['runs'])
assert uniform['completed'] and not uniform['source']['dirty']
assert uniform['source']['revision'] == '9805383f9c50d867e8555471baf5dbda8333a652'
assert (uniform['n'], uniform['depth'], uniform['viscosity']) == (64, 1, .0192)
assert uniform['limits'] == tg.LIMITS == LIMITS
return cpu, uniform
def total(state, maps):
arrays = [a.astype(np.float64) for a in (state.coarse_f, state.fine_f)]
return ref.cpu.MOMENTS.T @ ref.directional(ref.cpu.State(*arrays), maps)
def differing_fields(actual, expected):
"""Compare serialized sample numerics exactly, including the sign of zero."""
if actual.keys() != expected.keys():
return ['field_set']
return [key for key in actual if np.asarray(actual[key]).dtype != np.asarray(expected[key]).dtype
or np.asarray(actual[key]).shape != np.asarray(expected[key]).shape
or np.asarray(actual[key]).tobytes() != np.asarray(expected[key]).tobytes()]
def sample_difference(actual, expected):
"""Descriptive comparison only; never an equivalence gate for different codecs."""
return {key:dict(reference=value,signed=(np.asarray(actual[key])-value).tolist(),
absolute=np.abs(np.asarray(actual[key])-value).tolist()) for key,value in expected.items()}
class LedgerTotals:
"""Compensated directional sums; retain absolute step sums to expose cancellation."""
names = ('net_change','physical_collision','coarse_collision','fine0_collision','fine1_collision',
'child_rounding','child_interpolation','child_cast','register_rounding','receiver_rounding','unexplained')
def __init__(self):
self.sums = np.zeros((len(self.names),9)); self.correction = self.sums.copy()
self.absolute = self.sums.copy(); self.absolute_moments = np.zeros((len(self.names),3))
self.maxima = self.sums.copy(); self.max_steps = np.zeros(self.sums.shape,np.int64)
self.cells = np.zeros((3,4)); self.cell_correction = self.cells.copy()
self.steps = 0; self.bound = 0.; self.maximum = 0.
def add(self, ledger):
values = np.array([ledger[name] for name in self.names],np.float64)
cells = np.asarray(ledger['collision_cell_mass'],np.float64)
if (values.shape != self.sums.shape or not np.isfinite(values).all()
or cells.shape != (3,4) or not np.isfinite(cells).all()
or not np.isfinite(ledger['unexplained_bound']) or ledger['unexplained_bound'] <= 0):
raise AssertionError('invalid diagnostic ledger')
delta = values-self.correction
updated = self.sums+delta
self.correction = (updated-self.sums)-delta
self.sums = updated
self.absolute += abs(values)
self.absolute_moments += abs(values @ ref.cpu.MOMENTS)
larger = abs(values) > self.maxima
self.max_steps[larger] = self.steps+1; self.maxima = np.maximum(self.maxima,abs(values))
delta = cells-self.cell_correction
updated = self.cells+delta
self.cell_correction = (updated-self.cells)-delta; self.cells = updated
self.steps += 1; self.bound += ledger['unexplained_bound']
self.maximum = max(self.maximum,float(abs(values[-1]).max()))
def snapshot(self, endpoint_change=None):
values = dict(zip(self.names,self.sums))
accounted = sum(values[key] for key in ('physical_collision','child_rounding','register_rounding','receiver_rounding'))
closure = None if endpoint_change is None else endpoint_change-accounted
telescoping = None if endpoint_change is None else endpoint_change-values['net_change']
closure_pass = None if closure is None else max(float(abs(closure).max()),float(abs(telescoping).max())) <= self.bound
collision_split = values['physical_collision']-sum(values[key] for key in ('coarse_collision','fine0_collision','fine1_collision'))
child_split = values['child_rounding']-values['child_interpolation']-values['child_cast']
split_pass = max(float(abs(collision_split).max()),float(abs(child_split).max())) <= self.bound
return dict(intervals=self.steps, summed_per_step_bound=self.bound, max_step_unexplained=self.maximum,
closure_pass=closure_pass,component_split_pass=split_pass,
endpoint_status='not_measured' if endpoint_change is None else 'measured',
endpoint_directional_change=None if endpoint_change is None else endpoint_change.tolist(),
closure_residual=None if closure is None else closure.tolist(),
telescoping_residual=None if telescoping is None else telescoping.tolist(),
closure_mass_momentum=None if closure is None else (ref.cpu.MOMENTS.T@closure).tolist(),
collision_split_residual=collision_split.tolist(),child_split_residual=child_split.tolist(),
collision_cell_mass=dict(levels=['coarse','fine0','fine1'],columns=['net','positive','negative','l1'],
applied_volume_weights=[1.,.25,.25],values=self.cells.tolist()),
components={key:dict(directional=self.sums[i].tolist(),mass_momentum=(self.sums[i]@ref.cpu.MOMENTS).tolist(),
absolute_step_directional_sum=self.absolute[i].tolist(),
absolute_step_mass_momentum_sum=self.absolute_moments[i].tolist(),
max_absolute_interval=self.maxima[i].tolist(),max_absolute_interval_step=self.max_steps[i].tolist())
for i,key in enumerate(self.names)})
def warm_and_reset(run, initial, check_cap, *, audit=False):
"""Time 32 synchronized intervals, then restore exact uploaded arrival F."""
run.initialize(initial)
before = run.download()
started = time.monotonic()
for _ in range(32):
check_cap()
run.step(audit=audit)
seconds = time.monotonic()-started
warm_health = run.health_counts.tolist()
run.initialize(initial)
after = run.download()
for a, b in zip((before.coarse_f, before.fine_f), (after.coarse_f, after.fine_f)):
np.testing.assert_array_equal(a, b)
if run.step_count != 0 or after.tick != 0 or run.health_counts.any():
raise AssertionError('warm-up reset did not restore tick/counters')
return after, dict(intervals=32, seconds=seconds, estimated_1024_seconds=32*seconds,
health_counts=warm_health, reset_verified=True, ledger_audit=audit)
def assess(rows, cpu, uniform, health_counts):
if tuple(row['tstar'] for row in rows) != TIMES:
raise ValueError('four frozen sample times required')
worst = {key: max(row[key] for row in rows) for key in LIMITS}
campaign = {key: all(np.isfinite(row[key]) and 0 <= row[key] <= limit for row in rows)
for key, limit in LIMITS.items()}
campaign['population_health'] = not any(health_counts)
campaign['sampled_positive_finite'] = all(
np.isfinite(row[key]) and row[key] > 0 for row in rows
for key in ('minimum_population', 'minimum_boundary_pre', 'minimum_boundary_stored'))
baseline, candidate = cpu['runs']
thresholds = {key: factor*baseline['worst'][key] for key, factor in FACTORS.items()}
experimental = {key: all(np.isfinite(row[key]) and row[key] >= 0 for row in rows)
and worst[key] <= threshold for key, threshold in thresholds.items()}
differences = []
for actual, expected in zip(rows, candidate['rows']):
delta = {}
for key, value in expected.items():
d = np.asarray(actual[key])-np.asarray(value)
delta[key] = dict(cpu=value, signed=d.tolist(), absolute=np.abs(d).tolist())
differences.append(dict(tstar=actual['tstar'], fields=delta))
historical_worst = max(row['pressure_l2'] for row in uniform['rows'] if row['tstar'] > 0)
historical_limit = max(2*historical_worst, .02)
assert historical_limit == .024506347612699403
return dict(worst=worst, campaign_checks=campaign, campaign_pass=all(campaign.values()),
experimental_thresholds=thresholds, experimental_checks=experimental,
experimental_pass=all(experimental.values()),
gpu_minus_cpu=dict(status='descriptive_only_no_equivalence_tolerance', rows=differences),
historical_pressure_gate=dict(status='historical_shifted_FP32_uniform_vs_unshifted_FP32_candidate',
matched_storage=False, uniform_worst=historical_worst, threshold=historical_limit,
candidate_worst=worst['pressure_l2'], passed=worst['pressure_l2'] <= historical_limit))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--gpu-admitted', action='store_true')
parser.add_argument('--cpu-receipt', type=Path, required=True)
parser.add_argument('--uniform-receipt', type=Path, required=True)
parser.add_argument('--audit-against', type=Path, help='audit every interval and compare exact saved 1779cf2 samples')
parser.add_argument('--shifted', action='store_true', help='opt-in shifted storage with all interval ledgers')
parser.add_argument('--unshifted-receipt', type=Path, help='pinned1779 comparison, descriptive only')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
if not args.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES'):
parser.error('explicit --gpu-admitted and CUDA_VISIBLE_DEVICES required')
if args.output.exists():
parser.error('output exists; preserve evidence and choose a new path')
if args.shifted != (args.unshifted_receipt is not None) or (args.shifted and args.audit_against):
parser.error('--shifted requires --unshifted-receipt and cannot use exact --audit-against')
exact_repeat = args.audit_against is not None
cpu, uniform = load_references(args.cpu_receipt, args.uniform_receipt)
previous = None
comparison_path = args.audit_against or args.unshifted_receipt
if comparison_path is not None:
if sha(comparison_path) != AUDIT_SHA:
raise ValueError('pinned GPU trajectory receipt differs')
previous = json.loads(comparison_path.read_text())
if previous['source']['numpy'] != np.__version__:
raise RuntimeError('audited repeat requires unchanged NumPy environment')
gpu.check_sources()
if sha(Path(gpu.__file__).with_suffix('.cu')) != CUDA_SHA:
raise RuntimeError('reviewed CUDA source changed')
source = dict(commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
dirty=bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True)),
hashes={p.name: sha(p) for p in (Path(__file__), Path(gpu.__file__), Path(gpu.__file__).with_suffix('.cu'),
Path(__file__).with_name('test_spacetime_gpu.py'), Path(table_guard.__file__))},
reference_hashes=gpu.PINS, numpy=np.__version__, checkout=str(ROOT))
if source['dirty']:
raise RuntimeError('clean committed checkout required')
started = time.monotonic()
report = dict(scope='N64 experimental space-time GPU; no grid-convergence qualification',
completed=False, passed=False, source=source, rows=[], health_counts=[0]*6, health_labels=HEALTH,
setup=dict(n=64, bounds=[8,8,56,48], re_L=100, U=.03, viscosity=.0192, tau=[.5576,.6152],
phase='arrival_f', storage='shifted_FP32' if args.shifted else 'unshifted_FP32', collision='SRT', interval_count=1024,
initialization='common analytic FP64 field, encoded using production FP64 weights before FP32 upload' if args.shifted
else 'same physical FP64 equilibrium as CPU, rounded to FP32 on upload',
cpu_storage='FP64', sample_steps=STEPS, sample_times=TIMES),
references=dict(cpu_receipt_sha256=CPU_SHA, cpu_source=cpu['source'],
cpu_archive_drive_id='197F9nwDOGf_YROK1a46YpVvNrSldnH_w',
cpu_archive_sha256='850ddce3f5203ef38e76e5153e718a9b92e137d1fc388ba7cd12f5d47eab83b4',
uniform_receipt_sha256=UNIFORM_SHA, uniform_source=uniform['source'],
uniform_storage='historical_shifted_FP32'),
frozen_screen=dict(campaign_limits=LIMITS, experimental_factors=FACTORS,
gpu_cpu_equivalence_tolerance=None, historical_pressure_threshold=.024506347612699403),
boundary_budget_status='pending_measurement' if previous else 'not_measured', max_directional_residual=None,
minima_scope='initial and four sampled physical arrival states; boundary minima at four samples',
limits=dict(seconds=180., peak_rss_mib=512.), cuda_visible_devices=os.environ['CUDA_VISIBLE_DEVICES'])
run = context = None
ledger_totals = LedgerTotals() if previous else None
if previous:
report['diagnostic'] = dict(completed=False, passed=False, reference_sha256=AUDIT_SHA,
reference_source=previous['source']['commit'], sample_checks=[], prefixes=[],
scope='net drift attribution; exact saved diagnostics only, not full-field trajectory certification' if exact_repeat
else 'raw stored-value ledger; unshifted and CPU comparisons descriptive, initial codecs differ')
phase = 'setup'
def save():
report['resources'] = dict(seconds=time.monotonic()-started,
peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
device_allocations_bytes=None if run is None else run.device_bytes,
device_memory_scope='owner allocations; excludes compiler, driver and context')
if run is not None:
report['health_counts'] = run.health_counts.tolist()
report['completed_intervals'] = run.step_count
if ledger_totals is not None:
report['audited_intervals'] = ledger_totals.steps
if ledger_totals.steps:
report['boundary_budget_status'] = 'measured' if ledger_totals.steps==1024 else 'measured_prefix'
report['diagnostic']['accumulated'] = ledger_totals.snapshot()
ref.write_receipt(args.output, report)
def check_cap():
if time.monotonic()-started > 180 or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024 > 512:
raise RuntimeError('frozen resource cap exceeded')
save()
try:
from CelerisLab.cuda.context import CudaContext
context = CudaContext(0)
config = LBMConfig(nx=64, ny=64, viscosity=.0192, velocity=0., ddf_shifting=args.shifted)
run = gpu.SpaceTimeGPU(config, (8,8,56,48), context=context)
report.update(device=context.name, sm_arch=context.sm_arch, build_metadata=run.build_metadata,
table_sha256=gpu.table_hash(run.tables))
report['table_validation'] = table_guard.validate_tables(run.plan,run.tables)
if previous:
for key in ('device','sm_arch','table_sha256') + (('build_metadata',) if exact_repeat else ()):
if report[key] != previous[key]:
raise AssertionError(f'audited repeat identity differs: {key}')
analytic = ref.cpu.State(tg.initial((64,64),0,64,.0192), tg.initial((128,128),1,64,.0192))
initial = analytic
phase = 'warm_reset'
initial, report['warmup'] = warm_and_reset(run, initial, check_cap, audit=previous is not None)
maps = run.plan.maps
masks = (maps.coarse_active, maps.fine_active)
arrays = (initial.coarse_f, initial.fine_f)
mass0 = tg.metrics(arrays, masks, 0, 64, .0192)['mass']
moment0 = total(initial, maps)
report['initial'] = dict(mass=mass0, momentum=moment0[1:].tolist(),
minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
if exact_repeat and differing_fields(report['initial'],previous['initial']):
raise AssertionError('audited repeat initial state diagnostics differ')
raw_initial = run._download_stored() if args.shifted else initial
direction0 = ref.directional(ref.cpu.State(*(a.astype(np.float64) for a in
(raw_initial.coarse_f,raw_initial.fine_f))),maps)
if args.shifted:
errors=[]; unshifted_errors=[]
for raw,actual,wanted,mask in zip((raw_initial.coarse_f,raw_initial.fine_f),arrays,
(analytic.coarse_f,analytic.fine_f),masks):
encoded=gpu._encode_physical(wanted,True)
np.testing.assert_array_equal(raw[:,mask].view(np.uint32),encoded[:,mask].view(np.uint32))
errors.append(float(abs(actual[:,mask]-wanted[:,mask]).max()))
unshifted_errors.append(float(abs(gpu._encode_physical(wanted,False)[:,mask]-wanted[:,mask]).max()))
report['warmup']['raw_reset_verified']=True
report['initial_quantization']=dict(maximum_population_error_by_level=errors,
unshifted_codec_error_by_level=unshifted_errors,
moment_change_vs_analytic=(moment0-total(analytic,maps)).tolist(),
cpu_start='pinned analytic FP64 trajectory, not reinitialized to decoded GPU upload',
matched_cpu_initial_bits=False)
report['unshifted_comparison']=dict(status='descriptive_only_codecs_have_different_initial_bits',
reference_sha256=AUDIT_SHA,initial=sample_difference(report['initial'],previous['initial']),rows=[])
save()
check_cap()
phase = 'trajectory'
trajectory_started = time.monotonic()
for step in range(1,1025):
check_cap()
ledger = run.step(audit=previous is not None)
if ledger_totals is not None:
ledger_totals.add(ledger)
report['max_directional_residual'] = ledger_totals.maximum
if step in STEPS:
state = run.download()
if state.tick != 2*step:
raise AssertionError('sample is not synchronized arrival state')
arrays = (state.coarse_f, state.fine_f)
row = tg.metrics(arrays, masks, step, 64, .0192, mass0)
row.update(step=step, momentum_change=(total(state,maps)-moment0)[1:].tolist(),
mass_change=row['mass']-mass0,
minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
if ledger_totals is not None:
row['minimum_boundary_pre'] = ledger['minimum_pre']
row['minimum_boundary_stored'] = ledger['minimum_stored']
else:
with context.activate():
row['minimum_boundary_pre'] = float(run.read(run.pre,(len(run.plan.groups),4),np.float64).min())
row['minimum_boundary_stored'] = float(run.read(run.h,(len(run.plan.groups),4)).min())
if not all(np.isfinite(value).all() for value in row.values()):
report['failed_sample'] = {key: repr(value) for key,value in row.items()}
raise FloatingPointError('nonfinite sample diagnostic')
report['rows'].append(row)
if previous:
expected=previous['rows'][len(report['rows'])-1]
changed = differing_fields(row,expected) if exact_repeat else []
report['diagnostic']['sample_checks'].append(dict(step=step,
status='exact_repeat' if exact_repeat else 'descriptive_only',differing_fields=changed if exact_repeat else None))
if args.shifted:
report['unshifted_comparison']['rows'].append(dict(step=step,fields=sample_difference(row,expected)))
raw_state=run._download_stored() if args.shifted else state
endpoint = ref.directional(ref.cpu.State(*(a.astype(np.float64) for a in
(raw_state.coarse_f,raw_state.fine_f))),maps)-direction0
prefix = ledger_totals.snapshot(endpoint)
if args.shifted:
prefix['physical_minus_raw_endpoint_moments'] = (
total(state,maps)-moment0-ref.cpu.MOMENTS.T@endpoint).tolist()
report['diagnostic']['prefixes'].append(prefix)
if not prefix['closure_pass'] or not prefix['component_split_pass']:
raise AssertionError('cumulative ledger does not close within summed existing per-step bounds')
if changed:
raise AssertionError(f'audited repeat sample differs: {changed}')
save()
print(json.dumps(row, allow_nan=False), flush=True)
report['trajectory_seconds'] = time.monotonic()-trajectory_started
check_cap()
report.update(assess(report['rows'], cpu, uniform, run.health_counts))
if args.shifted:
report['historical_pressure_gate'].update(status='historical_shifted_FP32_uniform_vs_shifted_FP32_candidate',
matched_storage=True,matched_control=False)
report['completed'] = True
report['passed'] = report['campaign_pass'] and report['experimental_pass']
if previous:
report['diagnostic'].update(completed=True,passed=ledger_totals.steps==1024 and len(report['rows'])==4)
except Exception as exc:
report['failure'] = dict(phase=phase, error=repr(exc))
raise
finally:
# Preserve the primary failure and every completed sample even if cleanup fails.
for owner in (run, context):
if owner is not None:
try:
owner.close()
except Exception as exc:
report.setdefault('cleanup_errors', []).append(repr(exc))
report['passed'] = False
save()
return 0 if report['passed'] else 1
if __name__ == '__main__':
raise SystemExit(main())
-165
View File
@@ -1,165 +0,0 @@
// Test-only arrival-f space-time coupling. No production scheduler changes.
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#include "streaming/pull_double_buffer.cuh"
#if DIM != 2 || NQ != 9 || COLLISION_MODEL != 0 || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
#error "Space-time experiment requires FP32 D2Q9 SRT double buffers without LES"
#endif
__device__ __forceinline__ void collide(float* f, float omega) {
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
#if USE_DDF_SHIFTING
// Same perturbation-preserving SRT branch as production BlockStep.
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
#else
compute_feq(rho, ux, uy, feq);
#endif
collide_srt_no_force(f, feq, omega);
}
__device__ __forceinline__ double physical(double value, unsigned int q) {
#if USE_DDF_SHIFTING
// Match population_storage.py's FP64 weights, not promoted FP32 d_w.
return value + (q == 0 ? 4.0/9.0 : (q < 5 ? 1.0/9.0 : 1.0/36.0));
#else
return value;
#endif
}
__device__ __forceinline__ void health(float stored, unsigned int q, unsigned int* counts) {
const double value = physical((double)stored, q);
if (!isfinite(value)) atomicAdd(counts + 4, 1u);
if (value <= 0.0f) atomicAdd(counts + 5, 1u);
}
extern "C" __global__ void CoarseStart(
const float* coarse, const float* fine, float* out, float omega,
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
unsigned int* counts)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
const bool covered = x >= x0 && x < x1 && y >= y0 && y < y1;
float f[NQ];
for (unsigned int q = 0; q < NQ; ++q) {
const unsigned long s = q * 4ul * TOTAL_CELLS + 2ul * y * (2u * NX) + 2u * x;
f[q] = covered ? 0.25f * (fine[s] + fine[s+1] + fine[s+2u*NX] + fine[s+2u*NX+1])
: coarse[index_f(k, q)];
}
collide(f, omega);
for (int q = 0; q < NQ; ++q) if (!covered) health(f[q], q, counts);
stream_pull_store(k, f, out);
}
extern "C" __global__ void Collide(
const float* in, float* out, const unsigned char* active, float omega,
unsigned int* counts)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
float f[NQ];
if (active == nullptr || active[k]) {
for (int q = 0; q < NQ; ++q) f[q] = in[index_f(k, q)];
collide(f, omega);
if (active != nullptr) for (int q = 0; q < NQ; ++q) health(f[q], q, counts);
} else {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
}
stream_pull_store(k, f, out);
}
extern "C" __global__ void Pull(
const float* in, float* out, const unsigned char* active, unsigned int* counts)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
float f[NQ];
if (active == nullptr || active[k]) {
unsigned long j[NQ];
compute_neighbors(x, y, j);
stream_pull_load(k, f, in, j);
if (active != nullptr) for (int q = 0; q < NQ; ++q) health(f[q], q, counts);
} else {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
}
stream_pull_store(k, f, out);
}
extern "C" __global__ void Interpolate(
const float* g0, const float* gp, const uint32_t* samples,
const uint32_t* origins, const int* sizes, const double* weights,
double* pre, float* h, unsigned int groups, unsigned int frozen,
unsigned int* counts)
{
unsigned int group = blockIdx.x * blockDim.x + threadIdx.x;
if (group >= groups) return;
const int size = sizes[group];
const unsigned int q = origins[group] / TOTAL_CELLS;
for (int child = 0; child < 4; ++child) {
double value = frozen ? (double)g0[origins[group]] : 0.0;
if (!frozen) for (int j = 0; j < size; ++j) {
const float* source = j < size/2 ? g0 : gp;
value += weights[(4u*group+child)*10u+j] * (double)source[samples[10u*group+j]];
}
const float stored = __double2float_rn(value);
pre[4u*group+child] = value;
h[4u*group+child] = stored;
if (!isfinite(physical(value, q))) atomicAdd(counts, 1u);
if (physical(value, q) <= 0.0) atomicAdd(counts+1, 1u);
if (!isfinite(physical((double)stored, q))) atomicAdd(counts+2, 1u);
if (physical((double)stored, q) <= 0.0) atomicAdd(counts+3, 1u);
}
}
extern "C" __global__ void Inject(
float* fine, const float* h, const uint32_t* rows, unsigned int count,
unsigned int omit_first)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count || (omit_first && row == 0)) return;
fine[rows[2u*row]] = h[rows[2u*row+1]];
}
extern "C" __global__ void Register(
const float* predicted, const float* fine, const float* h,
const uint32_t* targets, const uint32_t* sources, const int* times,
double* registers, unsigned int count, int substep, int mutation)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
double value = substep == 0 ? -(double)predicted[targets[row]] : registers[row];
for (int j = 0; j < 4; ++j) {
const unsigned int slot = 4u*row+j;
const int when = times[slot];
if (when != substep && !(substep == 0 && when == -1)) continue;
if ((mutation == 1 && when == -1) || (mutation == 2 && when == 0)
|| (mutation == 3 && when == 1)) continue;
value += 0.25 * (double)(when == -1 ? h[sources[slot]] : fine[sources[slot]]);
}
registers[row] = value;
}
extern "C" __global__ void ApplyRegisters(
float* predicted, const double* registers, const uint32_t* targets,
unsigned int count, unsigned int* counts)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const unsigned int slot = targets[row];
predicted[slot] = __double2float_rn((double)predicted[slot] + registers[row]);
health(predicted[slot], slot / TOTAL_CELLS, counts);
}
-472
View File
@@ -1,472 +0,0 @@
"""Test-only arrival-f GPU owner; CUDA compilation/execution requires admission."""
from dataclasses import replace
import hashlib
from numbers import Integral
from pathlib import Path
import numpy as np
import spacetime_reference as ref
from CelerisLab.lbm.population_storage import _encode_physical, _decode_stored, _WEIGHTS
PINS = {"spacetime_reference.py": "1fe13242b9a41062a1227db4a6f9b695165aefab7f4c0b3b2adff0c9023552bf",
"cpu_reference.py": "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e",
"../taylor_green/oracle.py": "83e4d7425d45538278e9fb181af450a36358bd9a5ed6be57ff611dd9ae5eb35b"}
def check_sources():
for name, expected in PINS.items():
if hashlib.sha256((Path(__file__).parent/name).read_bytes()).hexdigest() != expected:
raise RuntimeError(f"reference source changed: {name}")
def validate_config(config, *, uniform=False, allow_n128=False):
if type(uniform) is not bool or type(allow_n128) is not bool:
raise ValueError("admission flags must be bool")
cfg = replace(config)
cfg.validate()
if (cfg.nx != cfg.ny or not 8 <= cfg.nx <= (256 if uniform else (128 if allow_n128 else 64)) or cfg.nz != 1
or cfg.collision != "SRT" or not cfg.is_d2q9
or cfg.streaming != "double_buffer" or cfg.store_precision != "FP32"
or cfg.data_type != "FP32" or type(cfg.ddf_shifting) is not bool or cfg.les_enabled
or cfg.velocity != 0 or cfg.wall_slot_width or cfg.wall_normal_velocity):
raise ValueError("experiment requires periodic unforced square FP32 D2Q9/SRT within admitted size")
if uniform:
return (cfg,)
fine = replace(cfg, nx=2*cfg.nx, ny=2*cfg.ny, viscosity=2*cfg.viscosity)
fine.validate()
return cfg, fine
def pack_plan(plan):
"""Pack reviewed coordinate events; no production transfer builder imports."""
n, nf = plan.n, plan.nf
groups, receivers = plan.groups, plan.receivers
data = dict(samples=np.zeros((len(groups), 10), np.uint32),
origins=np.empty(len(groups), np.uint32), sizes=np.empty(len(groups), np.int32),
weights=np.zeros((len(groups), 4, 10), np.float64),
targets=(receivers[:, 0]*n*n+receivers[:, 1]).astype(np.uint32))
lookup = {tuple(row): i for i, row in enumerate(receivers)}
packets = [[] for _ in receivers]; incoming = [[], []]
for i, g in enumerate(groups):
size = len(g.samples)
if size not in (6, 10):
raise ValueError("unexpected interpolation stencil")
data['samples'][i, :size] = g.q*n*n+g.samples
data['origins'][i] = g.q*n*n+g.source
data['sizes'][i] = size
data['weights'][i, :, :size] = g.weights
for k, event in enumerate(g.events):
if event is None:
packets[lookup[(g.q, g.receiver)]].append((-1, 4*i+k))
else:
m, slot = event
incoming[m].append((g.q*nf*nf+slot, 4*i+k))
for m, outgoing in enumerate(plan.outgoing):
for q, source, target in outgoing:
packets[lookup[(int(q), int(target))]].append((m, int(q*nf*nf+source)))
if any(len(row) != 4 or len(set(row)) != 4 for row in packets):
raise ValueError("each receiver needs four distinct quarter-packets")
if len({packet for row in packets for packet in row}) != 4*len(receivers):
raise ValueError("packet has multiple receiver owners")
data['times'] = np.array([[t for t, _ in row] for row in packets], np.int32)
data['sources'] = np.array([[s for _, s in row] for row in packets], np.uint32)
for m, rows in enumerate(incoming):
data[f'incoming{m}'] = np.array(rows, np.uint32)
if len({slot for slot, _ in rows}) != len(rows):
raise ValueError("duplicate incoming writer")
return {key: np.ascontiguousarray(value) for key, value in data.items()}
def table_hash(data):
digest = hashlib.sha256()
for key, value in sorted(data.items()):
digest.update(f"{key}:{value.dtype}:{value.shape}".encode()+value.tobytes())
return digest.hexdigest()
def rounding_ledger(plan, tables, before, after, g0, predicted, fine_phases, pre, h, registers, *, shifted=False):
"""Audit actual stored FP32 phases; never call a missing packet 'rounding'."""
maps, n = plan.maps, plan.n
f64 = lambda a: np.asarray(a, dtype=np.float64)
difference = f64(g0[:, maps.coarse_active])-f64(before.coarse_f[:, maps.coarse_active])
coarse_collision = difference.sum(axis=1)
cell_mass = [difference.sum(axis=0)]
collision = coarse_collision.copy(); fine_collision = []
for f, g in fine_phases:
difference = f64(g[:, maps.fine_active])-f64(f[:, maps.fine_active])
fine_collision.append(.25*difference.sum(axis=1))
cell_mass.append(.25*difference.sum(axis=0))
collision += fine_collision[-1]
np.testing.assert_array_equal(h, pre.astype(np.float32))
origins = f64(g0).ravel()[tables['origins']]
np.testing.assert_allclose(pre.mean(axis=1), origins, rtol=0, atol=2e-14)
group = np.bincount(tables['origins']//(n*n), weights=f64(h).mean(axis=1)-origins, minlength=9)
interpolation = np.bincount(tables['origins']//(n*n), weights=pre.mean(axis=1)-origins, minlength=9)
child_cast = np.bincount(tables['origins']//(n*n), weights=f64(h).mean(axis=1)-pre.mean(axis=1), minlength=9)
packet = np.empty(tables['times'].shape, np.float64)
for when, source in ((-1, h), (0, fine_phases[0][1]), (1, fine_phases[1][1])):
mask = tables['times'] == when
packet[mask] = f64(source).ravel()[tables['sources'][mask]]
p = f64(predicted).ravel()[tables['targets']]
ideal = -p + .25*packet.sum(axis=1)
reg_error = registers-ideal
bound = 16*np.finfo(float).eps*(abs(p)+.25*abs(packet).sum(axis=1))
if np.any(abs(reg_error) > bound):
raise AssertionError("receiver packet register mismatch (not roundoff)")
actual = after.coarse_f.ravel()[tables['targets']]
np.testing.assert_array_equal(actual, (p+registers).astype(np.float32))
q = tables['targets']//(n*n)
store_error = np.bincount(q, weights=f64(actual)-(p+registers), minlength=9)
register_error = np.bincount(q, weights=reg_error, minlength=9)
# directional() must sum converted arrays, not accumulate in FP32.
total = lambda s: ref.directional(ref.cpu.State(f64(s.coarse_f), f64(s.fine_f)), maps)
net = total(after)-total(before)
raw = net-collision
unexplained = raw-group-store_error-register_error
limit = float(64*np.finfo(float).eps*(total(before).sum()+int(shifted)*n*n))
np.testing.assert_allclose(unexplained, 0, rtol=0, atol=limit)
offset = _WEIGHTS.ravel()[tables['origins']//(n*n),None] if shifted else 0.
return dict(raw=raw.tolist(), net_change=net.tolist(), physical_collision=collision.tolist(),
coarse_collision=coarse_collision.tolist(), fine0_collision=fine_collision[0].tolist(),
fine1_collision=fine_collision[1].tolist(), child_interpolation=interpolation.tolist(),
child_cast=child_cast.tolist(),
collision_cell_mass=[[float(a.sum()),float(a[a>0].sum()),float(a[a<0].sum()),float(abs(a).sum())]
for a in cell_mass],
child_rounding=group.tolist(), receiver_rounding=store_error.tolist(),
register_rounding=register_error.tolist(), unexplained=unexplained.tolist(),
unexplained_bound=limit, minimum_pre=float((pre+offset).min()),
minimum_stored=float((f64(h)+offset).min()))
class SpaceTimeGPU:
"""Caller-owned context, one stream, explicit synchronized arrival state."""
def __init__(self, config, bounds, *, context, mode='spacetime', identity=False, allow_n128=False):
check_sources()
self.configs = validate_config(config,allow_n128=allow_n128)
if mode not in ('spacetime', 'frozen') or type(identity) is not bool:
raise ValueError("unsupported interpolation or debug collision mode")
self.bounds = tuple(bounds)
if len(self.bounds) != 4 or any(isinstance(v, bool) or not isinstance(v, Integral) for v in self.bounds):
raise ValueError("rectangle bounds must be four integers")
self.plan = ref.Plan(config.nx, self.bounds)
self.tables = pack_plan(self.plan)
self.ctx, self.mode, self.identity = context, mode, identity
self.allocations, self.modules, self.build_metadata = [], [], []
self.status, self.step_count, self.device_bytes = 'new', 0, 0
self.health_counts = np.zeros(6, np.uint64)
import pycuda.driver as cuda
from CelerisLab.cuda import compiler_v2 as compiler
root = Path(__file__).resolve().parents[3]
if Path(compiler._KERNEL_DIR).resolve() != root/'src/CelerisLab/lbm/kernels':
raise RuntimeError("wrong production kernel checkout")
self.cuda = cuda
with context.activate():
self.stream = cuda.Stream()
try:
source = Path(__file__).with_suffix('.cu')
for cfg in self.configs:
build = compiler.generate_config(cfg)
try:
Path(build, source.name).write_bytes(source.read_bytes())
ptx = compiler.compile_kernel(context.sm_arch, source=source.name, build_dir=build)
self.modules.append(compiler.load_module(ptx))
import json
metadata = json.loads(Path(ptx+'.json').read_text())
metadata['test_entry_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
self.build_metadata.append(metadata)
finally:
compiler.cleanup_build_dir(build)
self.device_tables = {key: self.upload(value) for key, value in self.tables.items()}
self.active = self.upload(self.plan.maps.fine_active.astype(np.uint8))
self.coarse = [self.allocate(9*config.nx**2*4) for _ in range(4)]
self.fine = [self.allocate(9*(2*config.nx)**2*4) for _ in range(3)]
self.pre = self.allocate(len(self.plan.groups)*4*8)
self.h = self.allocate(len(self.plan.groups)*4*4)
self.registers = self.allocate(len(self.plan.receivers)*8)
self.counts = self.allocate(6*4)
except BaseException:
try:
self.close()
except Exception:
pass
raise
def allocate(self, size):
pointer = self.cuda.mem_alloc(size)
self.allocations.append(pointer); self.device_bytes += size
return pointer
def upload(self, array):
pointer = self.allocate(array.nbytes)
self.cuda.memcpy_htod(pointer, array)
return pointer
def read(self, pointer, shape, dtype=np.float32):
self.stream.synchronize()
out = np.empty(shape, dtype)
self.cuda.memcpy_dtoh(out, pointer)
return out
def launch(self, level, name, *args, count=None):
cfg = self.configs[level]
nt = cfg.threads_per_block
grid = ((cfg.nx+nt-1)//nt, cfg.ny) if count is None else ((count+nt-1)//nt, 1)
self.modules[level].get_function(name)(*args, block=(nt,1,1), grid=grid, stream=self.stream)
def initialize(self, state):
if (self.status not in ('new', 'ready') or state.phase != 'arrival_f'
or isinstance(state.tick, bool) or not isinstance(state.tick, Integral) or state.tick != 0):
raise ValueError("initial synchronized arrival state with tick=0 required")
shifted = self.configs[0].ddf_shifting
arrays = []
for values, mask in zip((state.coarse_f,state.fine_f),
(self.plan.maps.coarse_active,self.plan.maps.fine_active)):
if np.shape(values) != (9,*mask.shape):
raise ValueError("positive finite leaf populations and exact shapes required")
a = _encode_physical(values,shifted)
physical = _decode_stored(a,shifted)
if not np.isfinite(physical[:,mask]).all() or (physical[:,mask] <= 0).any():
raise ValueError("positive finite leaf populations and exact shapes required")
a[:,~mask] = np.nan
arrays.append(a)
with self.ctx.activate():
try:
self.cuda.memcpy_htod(self.coarse[0], arrays[0])
self.cuda.memcpy_htod(self.fine[0], arrays[1])
except BaseException:
self.status = 'failed'
raise
self.status, self.step_count = 'ready', 0
self.health_counts.fill(0)
def _download_stored(self):
"""Private audit/reset snapshot; values remain in their storage representation."""
if self.status != 'ready':
raise RuntimeError(f"GPU owner is {self.status}")
try:
with self.ctx.activate():
arrays = [self.read(self.coarse[0], (9,self.plan.n,self.plan.n)),
self.read(self.fine[0], (9,self.plan.nf,self.plan.nf))]
except BaseException:
self.status = 'failed'
raise
for a, mask in zip(arrays, (self.plan.maps.coarse_active,self.plan.maps.fine_active)):
a[:,~mask] = np.nan
return ref.cpu.State(*arrays, tick=2*self.step_count)
def download(self):
"""Physical arrival populations; shifted values are reconstructed in FP64."""
stored = self._download_stored()
return ref.cpu.State(*(_decode_stored(a,self.configs[0].ddf_shifting)
for a in (stored.coarse_f,stored.fine_f)),tick=stored.tick)
def step(self, *, mutation=0, audit=True):
"""One interval; audit=False skips only host ledger snapshots, never health checks."""
if (self.status != 'ready' or type(mutation) is not int or mutation not in range(5)
or type(audit) is not bool or (mutation and not audit)):
raise RuntimeError("ready owner and known test mutation required")
before = self._download_stored() if audit else None
n, nf, t = self.plan.n, self.plan.nf, self.device_tables
ng, nr = len(self.plan.groups), len(self.plan.receivers)
u32, null = np.uint32, np.uintp(0)
omega = [np.float32(0 if self.identity else cfg.omega) for cfg in self.configs]
with self.ctx.activate():
try:
self.status = 'advancing'
self.cuda.memset_d32_async(self.counts, 0, 6, self.stream)
fc, g0, predicted, gp = self.coarse
self.launch(0, 'CoarseStart', fc,self.fine[0],g0,omega[0],
*(u32(v) for v in self.bounds),self.counts)
self.launch(0, 'Pull', g0,predicted,null,self.counts)
self.launch(0, 'Collide', predicted,gp,null,omega[0],self.counts)
self.launch(0, 'Interpolate', g0,gp,t['samples'],t['origins'],t['sizes'],t['weights'],
self.pre,self.h,u32(ng),u32(self.mode=='frozen'),self.counts,count=ng)
if audit:
coarse_g = self.read(g0,(9,n,n)); prediction = self.read(predicted,(9,n,n))
fine_phases = []
for m in (0,1):
f, out, g = self.fine
if audit:
old_f = self.read(f,(9,nf,nf))
self.launch(1, 'Collide', f,g,self.active,omega[1],self.counts)
if audit:
fine_phases.append((old_f,self.read(g,(9,nf,nf))))
self.launch(0, 'Register', predicted,g,self.h,t['targets'],t['sources'],t['times'],
self.registers,u32(nr),np.int32(m),np.int32(mutation),count=nr)
rows = self.tables[f'incoming{m}']
self.launch(1, 'Inject', g,self.h,t[f'incoming{m}'],u32(len(rows)),u32(mutation==4),count=len(rows))
self.launch(1, 'Pull', g,out,self.active,self.counts)
self.fine[0], self.fine[1] = out, f
self.launch(0, 'ApplyRegisters', predicted,self.registers,t['targets'],u32(nr),self.counts,count=nr)
counts = self.read(self.counts,(6,),np.uint32)
self.health_counts += counts.astype(np.uint64)
if counts.any():
raise AssertionError(f"boundary/leaf health failed: {counts.tolist()}")
ledger = dict(boundary_budget_status='not_measured',unexplained=None)
if audit:
coarse_after = self.read(predicted,(9,n,n))
coarse_after[:,~self.plan.maps.coarse_active] = np.nan
after = ref.cpu.State(coarse_after,self.read(self.fine[0],(9,nf,nf)),tick=2*(self.step_count+1))
ledger = rounding_ledger(self.plan,self.tables,before,after,coarse_g,prediction,fine_phases,
self.read(self.pre,(ng,4),np.float64),self.read(self.h,(ng,4)),
self.read(self.registers,(nr,),np.float64),
shifted=self.configs[0].ddf_shifting)
ledger['boundary_budget_status'] = 'measured'
ledger['health_counts'] = counts.tolist()
self.coarse[0], self.coarse[2] = predicted, fc
self.step_count += 1
self.status = 'ready'
return ledger
except BaseException:
self.status = 'failed'
raise
def close(self):
if self.status == 'closed':
return
with self.ctx.activate():
error = None
try:
self.stream.synchronize()
except Exception as exc:
error = exc
failed = []
for pointer in self.allocations:
try:
pointer.free()
except Exception as exc:
error = error or exc
failed.append(pointer)
self.allocations = failed
self.status = 'cleanup_failed' if failed else 'closed'
if not failed:
self.modules.clear()
if error:
raise error
def __enter__(self):
return self
def __exit__(self, kind, value, traceback):
try:
self.close()
except Exception:
if kind is None:
raise
def uniform_ledger(before, after, collided, *, shifted):
"""Periodic streaming permutes each direction; only collision changes its sum."""
a,b,g = [np.asarray(x,np.float64) for x in (before,after,collided)]
collision=(g-a).sum(axis=(1,2)); net=b.sum(axis=(1,2))-a.sum(axis=(1,2))
residual=net-collision
bound=float(64*np.finfo(float).eps*(a.sum()+int(shifted)*a.shape[1]*a.shape[2]))
np.testing.assert_allclose(residual,0,rtol=0,atol=bound)
cells=(g-a).sum(axis=0)
out={key:[0.]*9 for key in ('fine0_collision','fine1_collision','child_rounding',
'child_interpolation','child_cast','register_rounding','receiver_rounding')}
out.update(net_change=net.tolist(),physical_collision=collision.tolist(),coarse_collision=collision.tolist(),
unexplained=residual.tolist(),unexplained_bound=bound,
collision_cell_mass=[[float(cells.sum()),float(cells[cells>0].sum()),
float(cells[cells<0].sum()),float(abs(cells).sum())],[0.]*4,[0.]*4],
boundary_budget_status='not_applicable_uniform',inactive_components='fine, child, register, receiver')
return out
class UniformGPU(SpaceTimeGPU):
"""One explicit arrival-F level; reuse the reviewed kernels and owner helpers."""
def __init__(self, config, *, context, identity=False):
check_sources()
self.configs=validate_config(config,uniform=True)
if type(identity) is not bool:
raise ValueError('identity must be bool')
self.ctx,self.identity=context,identity
self.allocations,self.modules,self.build_metadata=[],[],[]
self.status,self.step_count,self.device_bytes='new',0,0
self.health_counts=np.zeros(6,np.uint64)
import pycuda.driver as cuda
from CelerisLab.cuda import compiler_v2 as compiler
if Path(compiler._KERNEL_DIR).resolve()!=Path(__file__).resolve().parents[3]/'src/CelerisLab/lbm/kernels':
raise RuntimeError('wrong production kernel checkout')
self.cuda=cuda
with context.activate():
self.stream=cuda.Stream()
try:
source=Path(__file__).with_suffix('.cu')
build=compiler.generate_config(self.configs[0])
try:
Path(build,source.name).write_bytes(source.read_bytes())
ptx=compiler.compile_kernel(context.sm_arch,source=source.name,build_dir=build)
self.modules.append(compiler.load_module(ptx))
import json
metadata=json.loads(Path(ptx+'.json').read_text())
metadata['test_entry_sha256']=hashlib.sha256(source.read_bytes()).hexdigest()
self.build_metadata.append(metadata)
finally:
compiler.cleanup_build_dir(build)
n=config.nx
self.buffers=[self.allocate(9*n*n*4) for _ in range(3)]
self.active=self.upload(np.ones((n,n),np.uint8))
self.counts=self.allocate(6*4)
except BaseException:
try:
self.close()
except BaseException:
pass
raise
def initialize(self, physical):
cfg=self.configs[0]
if self.status not in ('new','ready') or np.shape(physical)!=(9,cfg.ny,cfg.nx):
raise ValueError('ready owner and exact uniform population shape required')
stored=_encode_physical(physical,cfg.ddf_shifting)
decoded=_decode_stored(stored,cfg.ddf_shifting)
if not np.isfinite(decoded).all() or (decoded<=0).any():
raise ValueError('positive finite physical populations required')
try:
with self.ctx.activate():
self.cuda.memcpy_htod(self.buffers[0],stored)
self.health_counts[:]=0; self.step_count=0; self.status='ready'
except BaseException:
self.status='failed'
raise
def _download_stored(self):
if self.status!='ready':
raise RuntimeError('uniform owner is not ready')
try:
with self.ctx.activate():
n=self.configs[0].nx
return self.read(self.buffers[0],(9,n,n))
except BaseException:
self.status='failed'
raise
def download(self):
return _decode_stored(self._download_stored(),self.configs[0].ddf_shifting)
def step(self, *, audit=True):
if self.status!='ready' or type(audit) is not bool:
raise RuntimeError('ready owner and bool audit required')
cfg=self.configs[0]; shape=(9,cfg.ny,cfg.nx)
before=self._download_stored() if audit else None
with self.ctx.activate():
try:
self.status='advancing'
self.cuda.memset_d32_async(self.counts,0,6,self.stream)
f,g,out=self.buffers
self.launch(0,'Collide',f,g,self.active,np.float32(0 if self.identity else cfg.omega),self.counts)
self.launch(0,'Pull',g,out,self.active,self.counts)
counts=self.read(self.counts,(6,),np.uint32)
self.health_counts+=counts.astype(np.uint64)
if counts.any():
raise AssertionError(f'uniform population health failed: {counts.tolist()}')
ledger=uniform_ledger(before,self.read(out,shape),self.read(g,shape),shifted=cfg.ddf_shifting) if audit else dict(
boundary_budget_status='not_measured',unexplained=None)
ledger['health_counts']=counts.tolist()
self.buffers[0],self.buffers[2]=out,f
self.step_count+=1; self.status='ready'
return ledger
except BaseException:
self.status='failed'
raise
@@ -1,334 +0,0 @@
"""Experimental FP64 BVP coupling: one periodic rectangle, D2Q9, ratio two.
Guzik et al., https://www.osti.gov/servlets/purl/1226215, sections 3.2--3.5.
Coordinate-generated stream events; CPUREF collision only, no production imports.
This is a mathematical prototype, not a certified accuracy implementation.
"""
from dataclasses import dataclass
from functools import lru_cache
import argparse
import hashlib
import json
from pathlib import Path
import resource
import sys
import subprocess
import time
import numpy as np
import cpu_reference as cpu
class Spec(cpu.Spec):
@property
def n_fine(self):
return 2 * self.n_coarse
def geometry(n, bounds):
x0, y0, x1, y1 = bounds
if not (2 <= x0 < x1 <= n - 2 and 2 <= y0 < y1 <= n - 2):
raise ValueError("one interior rectangle with two coarse cells of clearance required")
y, x = np.indices((n, n))
covered = (x >= x0) & (x < x1) & (y >= y0) & (y < y1)
y, x = np.indices((2*n, 2*n))
active = (x >= 2*x0) & (x < 2*x1) & (y >= 2*y0) & (y < 2*y1)
inner = (x >= 2*x0-2) & (x < 2*x1+2) & (y >= 2*y0-2) & (y < 2*y1+2) & ~active
rows, links = [], []
for ky, kx in zip(*np.nonzero(~covered)):
for q, (cy, cx) in enumerate(zip(cpu.CY, cpu.CX)):
if any(active[2*ky+dy-cy, 2*kx+dx-cx]
for dy in (0, 1) for dx in (0, 1)
if 0 <= 2*ky+dy-cy < 2*n and 0 <= 2*kx+dx-cx < 2*n):
rows.append((q, ky, kx))
sy, sx = (ky-cy) % n, (kx-cx) % n
if not covered[sy, sx]:
links.append(cpu.CornerLink(f"{kx}_{ky}_{q}", q, (sx, sy), (kx, ky)))
kind = np.where(active, 0, np.where(inner, 1, 2))
return cpu.Maps(~covered, covered, active, inner, kind == 2, kind,
np.arange(2*n)//2, (np.arange(2*n)+.5)/2,
np.array(rows, dtype=int), tuple(links))
@lru_cache(None)
def interpolation(kind, points):
"""Return data coordinates, evaluation matrix, and constrained coefficient map."""
if kind == "face":
samples = np.array([(s, t) for t in (0., 1.) for s in (-1., 0., 1.)])
a = np.array([[1, s, t, s*t] for s, t in samples])
e = np.array([[1, s, t, s*t] for s, t in points])
b = e.mean(axis=0, keepdims=True)
d = np.zeros((1, 6)); d[0, 1] = 1
else:
cross = ((0, 0), (-1, 0), (1, 0), (0, -1), (0, 1))
samples = np.array([(x, y, t) for t in (0., 1.) for x, y in cross])
a = np.array([[1, x, y, t] for x, y, t in samples])
e = np.array([[1, x, y, t] for x, y, t in points])
b = np.stack((e.mean(axis=0), [0, 0, 0, 1]))
d = np.zeros((2, 10)); d[0, 0] = 1; d[1, 0] = -1; d[1, 5] = 1
kkt = np.block([[a.T @ a, b.T], [b, np.zeros((len(b), len(b)))]])
coeff = np.linalg.solve(kkt, np.vstack((a.T, d)))[:4]
if not np.allclose(b @ coeff, d, rtol=0, atol=2e-14):
raise AssertionError("invalid constrained interpolation matrix")
return samples, a, e, coeff
@dataclass
class Group:
q: int
source: int
receiver: int
kind: str
points: tuple
samples: np.ndarray
weights: np.ndarray
events: tuple # (substep, fine ghost slot), or None for a leftover
class Plan:
def __init__(self, n, bounds):
self.n, self.nf = n, 2*n
self.maps = geometry(n, bounds)
active = self.maps.fine_active
lo, hi = np.array(bounds[1::-1]), np.array(bounds[3:1:-1])
self.groups = []
for iy, ix in zip(*np.nonzero(self.maps.coarse_active)):
origin = np.array((iy, ix))
for q in range(1, 9):
c = np.array((cpu.CY[q], cpu.CX[q]))
starts = [2*origin + (dy, dx) for dy in (0, 1) for dx in (0, 1)]
events = []
for p in starts:
event = None
for m in (0, 1):
old, new = p+m*c, p+(m+1)*c
inside = lambda z: bool(np.all(z >= 0) and np.all(z < 2*n) and active[tuple(z)])
if not inside(old) and inside(new):
event = (m, int(old[0]*2*n+old[1]))
break
events.append(event)
if all(event is None for event in events):
continue
axes = [j for j in range(2) if origin[j] < lo[j] or origin[j] >= hi[j]]
kind = "corner" if len(axes) == 2 else "face"
points = []
for p in starts:
x = (p+.5)/2
times = [(float(lo[j] if c[j] > 0 else hi[j])-x[j])/c[j] for j in axes]
time = max(times)
offset = x+c*time-(origin+.5+c*.5)
points.append((float(offset[1]), float(offset[0]), float(time-.5))
if kind == "corner" else (float(offset[1-axes[0]]), float(time-.5)))
points = tuple(points)
samples, _, e, coeff = interpolation(kind, points)
indices = []
for row in samples:
shift = np.array((row[1], row[0]), dtype=int) if kind == "corner" else np.eye(2, dtype=int)[1-axes[0]]*int(row[0])
pos = (origin+shift) % n
indices.append(int(pos[0]*n+pos[1]))
target = (origin+c) % n
self.groups.append(Group(q, int(iy*n+ix), int(target[0]*n+target[1]), kind,
points, np.array(indices), e @ coeff, tuple(events)))
self.outgoing = []
# m is zero-based: a substep-0 exit travels two fine spacings by
# synchronization; a substep-1 exit travels one.
for m in (0, 1):
records = []
for q in range(1, 9):
c = np.array((cpu.CY[q], cpu.CX[q]))
for y, x in zip(*np.nonzero(active)):
p = np.array((y, x)); endpoint = p+c
if not active[tuple(endpoint)]:
target = (p+(2-m)*c)//2
assert self.maps.coarse_active[tuple(target)]
records.append((q, int(y*2*n+x), int(target[0]*n+target[1])))
self.outgoing.append(np.array(records, dtype=int))
incoming = [(m, g.q, slot) for g in self.groups for event in g.events
if event is not None for m, slot in [event]]
assert len(incoming) == len(set(incoming)), "duplicate incoming writer"
expected = {(m, int(q), int((y-cpu.CY[q])*2*n+x-cpu.CX[q]))
for m in (0, 1) for q in range(1, 9)
for y, x in zip(*np.nonzero(active)) if not active[y-cpu.CY[q], x-cpu.CX[q]]}
assert set(incoming) == expected, "incoming coverage mismatch"
receivers = {(int(q), int(target)) for out in self.outgoing for q, _, target in out}
receivers |= {(g.q, g.receiver) for g in self.groups if None in g.events}
assert receivers == {(int(q), int(y*n+x)) for q, y, x in self.maps.coalesce}
self.receivers = np.array(sorted(receivers), dtype=int)
def directional(state, maps):
return state.coarse_f[:, maps.coarse_active].sum(axis=1) + .25*state.fine_f[:, maps.fine_active].sum(axis=1)
def advance(spec, plan, state, mode="spacetime", mutation=None):
"""One arrival-to-arrival interval. 'frozen' disables space AND time variation."""
if mode not in ("frozen", "spacetime"):
raise ValueError(mode)
n, nf, maps = plan.n, plan.nf, plan.maps
if state.phase != "arrival_f" or state.tick % 2:
raise ValueError("synchronized arrival state required")
fc, ff = state.coarse_f.copy(), state.fine_f.copy()
covered_mean = ff.reshape(9, n, 2, n, 2).mean(axis=(2, 4))
fc[:, maps.coarse_covered] = covered_mean[:, maps.coarse_covered]
all_cells = np.ones((n, n), dtype=bool)
def collide(f, mask, tau):
return cpu._collide(spec, f, np.isfinite(f), tau, mask, np.zeros(mask.shape, dtype=int))
gc = collide(fc, all_cells, spec.tau_coarse)
collision = (gc[:, maps.coarse_active]-fc[:, maps.coarse_active]).sum(axis=1)
predicted = cpu._stream_periodic(gc)
gp = collide(predicted, all_cells, spec.tau_coarse) if mode == "spacetime" else gc
registers = np.zeros((9, n*n))
q, target = plan.receivers.T
registers[q, target] = -predicted.reshape(9, -1)[q, target]
incoming = [[], []]
group_error = 0.
min_boundary = float("inf")
negative_boundary = nonfinite_boundary = 0
dropped = False
for group in plan.groups:
q = group.q
count = len(group.samples)//2
data = np.concatenate((gc.reshape(9, -1)[q, group.samples[:count]], gp.reshape(9, -1)[q, group.samples[count:]]))
value = (group.weights @ data if mode == "spacetime"
else np.full(4, gc.reshape(9, -1)[q, group.source]))
min_boundary = min(min_boundary, float(value.min()))
negative_boundary += int(np.count_nonzero(value < 0))
nonfinite_boundary += int(np.count_nonzero(~np.isfinite(value)))
group_error = max(group_error, abs(value.mean()-gc.reshape(9, -1)[q, group.source]))
for k, event in enumerate(group.events):
if event is None:
if mutation == "omit_leftover" and not dropped:
dropped = True
else:
registers[q, group.receiver] += .25*value[k]
else:
m, slot = event
incoming[m].append((q, slot, value[k]))
for m in (0, 1):
gf = collide(ff, maps.fine_active, spec.tau_fine)
collision += .25*(gf[:, maps.fine_active]-ff[:, maps.fine_active]).sum(axis=1)
out = plan.outgoing[m]
if mutation != f"omit_outgoing_{m}":
np.add.at(registers, (out[:, 0], out[:, 2]), .25*gf.reshape(9, -1)[out[:, 0], out[:, 1]])
for q, slot, value in incoming[m]:
gf.reshape(9, -1)[q, slot] = value
fresh = np.full_like(ff, np.nan)
for q in range(9):
moved = cpu._shift(gf[q], int(cpu.CY[q]), int(cpu.CX[q]), np.nan)
fresh[q, maps.fine_active] = moved[maps.fine_active]
if not np.isfinite(fresh[:, maps.fine_active]).all():
raise AssertionError("invalid fine incoming population")
ff = fresh
final_c = predicted + registers.reshape(9, n, n)
final_c[:, maps.coarse_covered] = np.nan
final = cpu.State(final_c, ff, tick=state.tick+2)
residual = directional(final, maps)-directional(state, maps)-collision
minimum = min(float(final_c[:, maps.coarse_active].min()), float(ff[:, maps.fine_active].min()))
return final, dict(directional_residual=residual, collision=collision,
group_error=group_error, min_boundary=min_boundary, min_population=minimum,
negative_boundary_count=negative_boundary, nonfinite_boundary_count=nonfinite_boundary)
def write_receipt(path, result):
"""Normalize NumPy scalars at the JSON boundary; reject other unknown types."""
def scalar(value):
if isinstance(value, np.generic):
return value.item()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
path.write_text(json.dumps(result, indent=2, allow_nan=False, default=scalar)+"\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--n64", action="store_true", help="explicitly run the frozen 1024-interval experiment")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--max-seconds",type=float,default=180.,help="wall-time cap, including plan generation")
parser.add_argument("--max-rss-mib",type=float,default=256.,help="Linux peak-RSS screening cap")
args = parser.parse_args()
if not args.n64:
parser.error("full trajectory requires --n64")
if args.output.exists():
parser.error("output exists; preserve prior evidence and choose a new path")
if not (np.isfinite(args.max_seconds) and args.max_seconds>0 and np.isfinite(args.max_rss_mib) and args.max_rss_mib>0):
parser.error("positive finite resource caps required")
started = time.monotonic()
sys.path.insert(0, str(Path(__file__).parents[1]/"taylor_green"))
import oracle as tg
plan = Plan(64, (8, 8, 56, 48))
spec = Spec(n_coarse=64, patch_lo=8, halo=16, tau_coarse=.5576, tau_fine=.6152, collision="srt")
arrays = [tg.initial((64,64), 0, 64, .0192), tg.initial((128,128), 1, 64, .0192)]
for a, mask in zip(arrays, (plan.maps.coarse_active, plan.maps.fine_active)):
a[:, ~mask] = np.nan
initial = cpu.State(*arrays)
mass0 = tg.metrics(arrays, (plan.maps.coarse_active, plan.maps.fine_active), 0, 64, .0192)["mass"]
metrics = ("velocity_l2", "pressure_l2", "energy_relative")
frozen_baseline = dict(zip(metrics, (.01131134574353476, .03592961319244149, .012514764591008386)))
budget_bound = float(64*np.finfo(float).eps*mass0)
root = Path(__file__).resolve().parents[3]
source = dict(commit=subprocess.check_output(["git", "rev-parse", "HEAD"],cwd=root,text=True).strip(),
dirty=bool(subprocess.check_output(["git","status","--porcelain"],cwd=root,text=True)),
hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()
for p in (Path(__file__),Path(cpu.__file__),Path(tg.__file__))},numpy=np.__version__)
result = dict(scope="experimental CPU FP64; no GPU/accuracy certification", source=source,
screen=dict(velocity_factor=.8, pressure_factor=1., energy_factor=1.,
directional_step_bound=budget_bound, mass_relative=1e-11,
momentum_absolute=1e-9, baseline_metric_atol=1e-10),
limits=dict(seconds=args.max_seconds,rss_mib=args.max_rss_mib),runs=[],completed=False)
def save():
result["resources"] = dict(seconds=time.monotonic()-started,peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024)
write_receipt(args.output, result)
save()
try:
for mode in ("constant_ivp", "spacetime"):
measured = mode == "spacetime"
state = initial; record = dict(mode=mode,rows=[],
boundary_budget_status="measured" if measured else "not_measured",
max_directional_residual=0. if measured else None,
minimum_boundary=None,negative_boundary_count=0 if measured else None,
nonfinite_boundary_count=0 if measured else None)
result["runs"].append(record)
for k in range(1, 1025):
if time.monotonic()-started>args.max_seconds or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024>args.max_rss_mib:
raise RuntimeError("experiment resource cap exceeded")
if mode == "constant_ivp":
state,_ = cpu.advance_coarse_once(spec,plan.maps,state)
else:
state,diagnostic = advance(spec,plan,state)
error = float(np.max(np.abs(diagnostic["directional_residual"])))
record["max_directional_residual"] = max(record["max_directional_residual"],error)
if np.isfinite(diagnostic["min_boundary"]):
record["minimum_boundary"] = (diagnostic["min_boundary"] if record["minimum_boundary"] is None else min(record["minimum_boundary"],diagnostic["min_boundary"]))
for name in ("negative_boundary_count","nonfinite_boundary_count"):
record[name] += diagnostic[name]
if (not np.isfinite(error) or error>budget_bound or diagnostic["group_error"]>2e-14
or diagnostic["negative_boundary_count"] or diagnostic["nonfinite_boundary_count"]
or not np.isfinite(diagnostic["min_boundary"]) or diagnostic["min_boundary"]<=0
or not np.isfinite(diagnostic["min_population"]) or diagnostic["min_population"]<=0):
record["failed_diagnostic"] = {name: repr(value) for name,value in diagnostic.items()}
raise AssertionError("candidate directional/health invariant failed")
if k in (256,512,768,1024):
row = tg.metrics((state.coarse_f,state.fine_f),(plan.maps.coarse_active,plan.maps.fine_active),k,64,.0192,mass0)
row["momentum_change"] = (cpu.MOMENTS.T@(directional(state,plan.maps)-directional(initial,plan.maps)))[1:].tolist()
record["rows"].append(row)
save(); print(mode,k,json.dumps(row),flush=True)
record["worst"] = {key:max(row[key] for row in record["rows"]) for key in metrics}
if mode == "constant_ivp":
for key,value in frozen_baseline.items():
if abs(record["worst"][key]-value)>1e-10:
raise AssertionError(f"frozen baseline mismatch: {key}")
baseline,candidate = result["runs"]
result["checks"] = {key:candidate["worst"][key]<=factor*baseline["worst"][key]
for key,factor in zip(metrics,(.8,1.,1.))}
result["checks"]["mass_momentum"] = all(row["mass_relative"]<=1e-11 and max(map(abs,row["momentum_change"]))<=1e-9 for row in candidate["rows"])
result["completed"] = True
result["experimental_pass"] = all(result["checks"].values())
save()
return 0 if result["experimental_pass"] else 1
except Exception as exc:
result["failure"] = dict(mode=mode,step=k,error=repr(exc))
save()
raise
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,92 +0,0 @@
"""N64/N128 admission check: exact topology and independently rational interpolation.
Raw FP64 bytes are diagnostic: different LAPACK builds need not produce them
identically. The 2e-14 ceiling is the existing interpolation constraint policy,
not a universal LAPACK error theorem. This module never changes runtime weights.
"""
from fractions import Fraction as F
from functools import lru_cache
import numpy as np
from spacetime_gpu import table_hash
STRUCTURE_SHA = 'cd1842952c4dd4df82da8015454c25da9be12b983270236cc65b4044fa38e6e4'
STRUCTURES = {64: (STRUCTURE_SHA, 532),
128: ('ee4ecb645674ae04eb87220a5217851405ccc0f45fbb0fe210499dcea5cd93b1', 1060)}
ATOL = 2e-14
def _transpose(a):
return list(map(list, zip(*a)))
def _multiply(a, b):
return [[sum(x*y for x,y in zip(row,column)) for column in zip(*b)] for row in a]
@lru_cache(None)
def exact_weights(kind, points):
"""Solve the small documented constrained fit using exact rational arithmetic."""
if kind == 'face':
a = [[F(1),F(s),F(t),F(s*t)] for t in (0,1) for s in (-1,0,1)]
e = [[F(1),F(s),F(t),F(s)*F(t)] for s,t in points]
b = [[sum(column)/4 for column in zip(*e)]]
d = [[F(int(j==1)) for j in range(6)]]
elif kind == 'corner':
a = [[F(1),F(x),F(y),F(t)] for t in (0,1) for x,y in ((0,0),(-1,0),(1,0),(0,-1),(0,1))]
e = [[F(1),F(x),F(y),F(t)] for x,y,t in points]
b = [[sum(column)/4 for column in zip(*e)], [F(0),F(0),F(0),F(1)]]
d = [[F(int(j==0)) for j in range(10)], [F(int(j==5)-int(j==0)) for j in range(10)]]
else:
raise ValueError('unsupported interpolation kind')
kkt = [r+s for r,s in zip(_multiply(_transpose(a),a),_transpose(b))]
kkt += [row+[F(0)]*len(b) for row in b]
rhs = _transpose(a)+d
n = len(kkt)
augmented = [row+data for row,data in zip(kkt,rhs)]
for j in range(n):
pivot = next(i for i in range(j,n) if augmented[i][j])
augmented[j],augmented[pivot] = augmented[pivot],augmented[j]
divisor = augmented[j][j]
augmented[j] = [v/divisor for v in augmented[j]]
for i in range(n):
if i != j:
factor = augmented[i][j]
augmented[i] = [x-factor*y for x,y in zip(augmented[i],augmented[j])]
solution = [row[n:] for row in augmented]
if _multiply(kkt,solution) != rhs:
raise AssertionError('exact interpolation solve failed')
return np.array(_multiply(e,solution[:4]), dtype=np.float64)
def validate_tables(plan, tables, *, allow_n128=False):
"""Reject topology, layout, padding, operator, or constraint changes separately."""
structure = table_hash({key:value for key,value in tables.items() if key != 'weights'})
admitted = (64,128) if allow_n128 is True else (64,)
if plan.n not in admitted or structure != STRUCTURES[plan.n][0]:
raise ValueError('frozen integer topology changed')
w = tables['weights']
if (w.dtype != np.dtype(np.float64) or w.shape != (STRUCTURES[plan.n][1],4,10) or len(plan.groups) != w.shape[0]
or not all(a.flags.c_contiguous for a in tables.values()) or not np.isfinite(w).all()):
raise ValueError('weight dtype/shape/layout/finiteness changed')
errors = dict(coefficient=0., constant=0., mean=0., temporal=0.)
for i,g in enumerate(plan.groups):
size = len(g.samples)
if size != tables['sizes'][i] or size != {'face':6,'corner':10}.get(g.kind) or len(g.points) != 4:
raise ValueError('interpolation group coverage/size changed')
if w[i,:,size:].tobytes() != np.zeros((4,10-size),np.float64).tobytes():
raise ValueError('weight padding is not exact positive zero')
actual = w[i,:,:size]
errors['coefficient'] = max(errors['coefficient'],float(abs(actual-exact_weights(g.kind,g.points)).max()))
errors['constant'] = max(errors['constant'],float(abs(actual.sum(axis=1)-1).max()))
central = np.zeros(size); central[1 if g.kind=='face' else 0] = 1
errors['mean'] = max(errors['mean'],float(abs(actual.mean(axis=0)-central).max()))
if g.kind == 'corner':
early,late = (g.points.index((0.,0.,t)) for t in (-.25,.25))
slope = np.zeros(size); slope[0],slope[5] = -1,1
errors['temporal'] = max(errors['temporal'],float(abs(2*(actual[late]-actual[early])-slope).max()))
if any(error > ATOL for error in errors.values()):
raise ValueError(f'exact interpolation/constraint check failed: {errors}; bound={ATOL}')
return dict(status='passed', structure_sha256=structure, raw_table_sha256=table_hash(tables),
coefficient_oracle='exact rational KKT, rounded once to FP64', atol=ATOL, rtol=0.,
maximum_absolute_errors=errors, positive_zero_padding=True)
@@ -1,239 +0,0 @@
"""Host-only experimental union event contract; no solver or runtime admission.
Consumes existing level-wide union geometry, never its legacy transfer tables.
The frozen 707afe6 rectangle oracle remains independent and unchanged. Topology
acceptance proves packet accounting, not interpolation accuracy or GPU support.
"""
from types import SimpleNamespace
import numpy as np
import spacetime_reference as ref
class UnresolvedTopology(ValueError):
"""An experimental coupling limitation, not a production geometry rejection."""
def __init__(self, code, **detail):
self.code, self.detail = code, detail
super().__init__(f"experimental space-time topology unresolved: {code}: {detail}")
def owned_slots(layout, directions, cells):
"""Map dense logical reads/writes to existing owner-core storage, not halos."""
cells = np.asarray(cells)
if not np.issubdtype(cells.dtype, np.integer) or np.any((cells < 0) | (cells >= np.prod(layout.shape))):
raise ValueError('invalid dense cell index')
owner = layout.owner.ravel()[cells]
if np.any(owner == layout.hole):
raise UnresolvedTopology('missing_block_owner', cells=cells[owner == layout.hole].tolist())
return layout.slot(owner, directions)
def _first_entry(covered, origin, child, c):
"""First continuous union entry of one characteristic, in exact quarter times.
Probe the cells immediately before/after each crossed grid line using integer
eighth-cell coordinates. Return only actual exterior faces of the UNION.
"""
start4 = 4*origin + 2*child + 1
for t4 in (1, 3):
before = (2*start4 + c*(2*t4-1))//8
after = (2*start4 + c*(2*t4+1))//8
if not covered[tuple(before)] and covered[tuple(after)]:
axes = []
for j in range(2):
if before[j] != after[j]:
neighbor = after.copy(); neighbor[j] = before[j]
if not covered[tuple(neighbor)]: axes.append(j)
return t4, axes
return None
def _crossing_time(covered, origin, position, c, axes):
"""Fig.11 convex T uses the last plane; Fig.13 concave T uses the first.
Both use the same KKT formulation on different boundary geometry.
DOI/primary PDF: https://www.osti.gov/servlets/purl/1226215, §3.5.1.
"""
times=[(origin[j]+int(c[j]>0)-position[j])/c[j] for j in axes]
if len(axes)==1: return times[0]
sides=[bool(covered[tuple(origin+np.eye(2,dtype=int)[j]*c[j])]) for j in axes]
if sides[0]!=sides[1]:
raise UnresolvedTopology('mixed_corner_support',source_yx=origin.tolist())
return min(times) if all(sides) else max(times)
class UnionPlan:
"""Two-level, level-wide square geometry; all coordinates are level-local.
Assumes a complete parent domain; does not validate nested parent coverage.
``pair`` is existing FixedPatchSet2D geometry, even for one rectangle. Scalar
window origins and nested state coverage need their own contract; they are
explicitly unadmitted here. No global rectangle-gap rule is imposed.
"""
phase = 'arrival_f'
runtime_supported = False
interpolation_accuracy = 'unqualified_for_new_unions'
def __init__(self, pair):
ny, nx = pair.coarse_shape
self.origin = tuple(pair.fine_origin)
if ny != nx or pair.fine_shape != (2*ny, 2*nx) or self.origin != (0., 0.):
raise UnresolvedTopology('requires_level_wide_square_pair',
coarse_shape=pair.coarse_shape, fine_shape=pair.fine_shape, origin=self.origin)
self.n, self.nf = nx, 2*nx
covered = np.array(pair.covered, dtype=bool, copy=True)
active = np.kron(covered, np.ones((2, 2), bool))
if not np.array_equal(active, pair.fine_kind == 0):
raise UnresolvedTopology('non_pair_active_coverage')
self.maps = SimpleNamespace(coarse_active=~covered, coarse_covered=covered, fine_active=active)
self.outgoing, transit = [[], []], []
# Guzik et al. §3.5.4, Fig.19 (https://www.osti.gov/servlets/purl/1226215):
# retain substep-0 outgoing G at source, then fill the substep-1 ghost.
# Rows are (q, fine source, next ghost); no covered coarse receiver credit.
for q in range(1, 9):
c = np.array((ref.cpu.CY[q], ref.cpu.CX[q]))
boundary = active & ~np.roll(active, tuple(-c), axis=(0, 1))
for y, x in zip(*np.nonzero(boundary)):
p = np.array((y, x))
for m in (0, 1):
target = (p+(2-m)*c)//2
if covered[tuple(target)]:
if m != 0 or not active[tuple(p+2*c)]:
raise UnresolvedTopology('unsupported_transit', q=q, substep=m, source_yx=p.tolist())
ghost=p+c
transit.append((q,int(y*self.nf+x),int(ghost[0]*self.nf+ghost[1])))
else:
self.outgoing[m].append((q, int(y*self.nf+x), int(target[0]*nx+target[1])))
self.outgoing = [np.array(x, dtype=np.int64).reshape(-1, 3) for x in self.outgoing]
self.transit = np.array(transit,dtype=np.int64).reshape(-1,3)
self.groups = []
for iy, ix in zip(*np.nonzero(~covered)):
origin = np.array((iy, ix))
# Two-cell production margins make all nearby probes in bounds.
if not covered[max(0,iy-1):iy+2,max(0,ix-1):ix+2].any(): continue
for q in range(1, 9):
c = np.array((ref.cpu.CY[q], ref.cpu.CX[q]))
children = [np.array((dy, dx)) for dy in (0, 1) for dx in (0, 1)]
starts = [2*origin+child for child in children]
events = []
for p in starts:
event = None
for m in (0, 1):
old, new = p+m*c, p+(m+1)*c
if not active[tuple(old)] and active[tuple(new)]:
event = (m, int(old[0]*self.nf+old[1])); break
events.append(event)
if all(e is None for e in events): continue
entries = [_first_entry(covered, origin, child, c) for child in children]
axes = sorted({j for entry in entries if entry is not None for j in entry[1]})
if not axes:
raise UnresolvedTopology('missing_exterior_support', source_yx=origin.tolist(), q=q)
points = []
for p, entry, event in zip(starts, entries, events):
position = (p+.5)/2
time = _crossing_time(covered,origin,position,c,axes)
if entry is not None and (4*time != entry[0] or event is None):
raise UnresolvedTopology('nonconvex_entry_support', source_yx=origin.tolist(), q=q,
quarter_time=entry[0], support_quarter_time=float(4*time))
offset = position+c*time-(origin+.5+c*.5)
points.append((float(offset[1]), float(offset[0]), float(time-.5)) if len(axes)==2
else (float(offset[1-axes[0]]), float(time-.5)))
kind = 'corner' if len(axes)==2 else 'face'; points = tuple(points)
samples, _, evaluation, coefficients = ref.interpolation(kind, points)
indices = []
for row in samples:
shift = np.array((row[1],row[0]), int) if kind=='corner' else np.eye(2,dtype=int)[1-axes[0]]*int(row[0])
pos = (origin+shift) % nx; indices.append(int(pos[0]*nx+pos[1]))
target = (origin+c) % nx
self.groups.append(ref.Group(q,int(iy*nx+ix),int(target[0]*nx+target[1]),kind,
points,np.array(indices),evaluation@coefficients,tuple(events)))
receivers = {(int(q),int(target)) for rows in self.outgoing for q,_,target in rows}
receivers |= {(g.q,g.receiver) for g in self.groups if None in g.events}
self.receivers = np.array(sorted(receivers),dtype=np.int64).reshape(-1,2)
self.proof = validate_ownership(self)
def validate_ownership(plan):
"""Independent mask-based event coverage and four-quarter receiver accounting."""
n, nf, active = plan.n, plan.nf, plan.maps.fine_active
actual = [(m,g.q,slot) for g in plan.groups for e in g.events if e is not None for m,slot in [e]]
expected_transit={(q,int(y*nf+x),int((y+ref.cpu.CY[q])*nf+x+ref.cpu.CX[q]))
for q in range(1,9) for y,x in zip(*np.nonzero(active))
if not active[y+ref.cpu.CY[q],x+ref.cpu.CX[q]]
and active[y+2*ref.cpu.CY[q],x+2*ref.cpu.CX[q]]}
routes=[tuple(map(int,row)) for row in plan.transit]
if len(routes)!=len(set(routes)) or set(routes)!=expected_transit:
raise ValueError('transit provenance/uniqueness/coverage failed')
actual += [(1,q,ghost) for q,source,ghost in routes]
expected = {(m,q,int((y-ref.cpu.CY[q])*nf+x-ref.cpu.CX[q]))
for m in (0,1) for q in range(1,9) for y,x in zip(*np.nonzero(active))
if not active[y-ref.cpu.CY[q],x-ref.cpu.CX[q]]}
if len(actual)!=len(set(actual)) or set(actual)!=expected:
raise ValueError('incoming writer uniqueness/coverage failed')
packets = {tuple(map(int,row)):[] for row in plan.receivers}
if len(packets)!=len(plan.receivers): raise ValueError('duplicate coarse receiver')
anchors = set()
for i,g in enumerate(plan.groups):
if not 1<=g.q<9 or not 0<=g.source<n*n or not plan.maps.coarse_active.ravel()[g.source]:
raise ValueError('invalid coarse source anchor')
if (g.q,g.source) in anchors: raise ValueError('duplicate coarse source group')
anchors.add((g.q,g.source))
origin=np.array(divmod(g.source,n)); c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q]))
target=(origin+c)%n
if g.receiver!=target[0]*n+target[1]: raise ValueError('group receiver provenance failed')
ordered=[]; entries=[]
for dy,dx in ((0,0),(0,1),(1,0),(1,1)):
p=2*origin+(dy,dx); event=None
for m in (0,1):
old,new=p+m*c,p+(m+1)*c
if not active[tuple(old)] and active[tuple(new)]:
event=(m,int(old[0]*nf+old[1])); break
ordered.append(event)
entries.append(_first_entry(plan.maps.coarse_covered,origin,np.array((dy,dx)),c))
if tuple(ordered)!=g.events or all(e is None for e in ordered):
raise ValueError('ordered child characteristic provenance failed')
axes=sorted({j for e in entries if e is not None for j in e[1]})
if not axes or g.kind!=('corner' if len(axes)==2 else 'face'):
raise ValueError('group supporting axes differ')
samples,_,evaluation,coefficients=ref.interpolation(g.kind,g.points)
indices=[]
for row in samples:
shift=np.array((row[1],row[0]),int) if g.kind=='corner' else np.eye(2,dtype=int)[1-axes[0]]*int(row[0])
pos=(origin+shift)%n; indices.append(int(pos[0]*n+pos[1]))
if not np.array_equal(g.samples,indices): raise ValueError('sample anchor provenance failed')
for k,(dy,dx) in enumerate(((0,0),(0,1),(1,0),(1,1))):
position=origin+(np.array((dy,dx))+.5)/2
time=_crossing_time(plan.maps.coarse_covered,origin,position,c,axes)
offset=position+c*time-(origin+.5+c*.5)
point=(float(offset[1]),float(offset[0]),float(time-.5)) if g.kind=='corner' else (float(offset[1-axes[0]]),float(time-.5))
if g.points[k]!=point: raise ValueError('evaluation point provenance failed')
source=np.zeros(len(g.samples)); source[0 if g.kind=='corner' else 1]=1
if (not np.allclose(g.weights,evaluation@coefficients,rtol=0,atol=2e-14)
or not np.allclose(g.weights.mean(axis=0),source,rtol=0,atol=2e-14)):
raise ValueError('group coefficients/directional budget failed')
for k,event in enumerate(g.events):
if event is None:
if (g.q,g.receiver) not in packets: raise ValueError('missing leftover receiver')
packets[(g.q,g.receiver)].append(('leftover',i,k))
for m,rows in enumerate(plan.outgoing):
expected_out = {(q,int(y*nf+x),int(((y+(2-m)*ref.cpu.CY[q])//2)*n+(x+(2-m)*ref.cpu.CX[q])//2))
for q in range(1,9) for y,x in zip(*np.nonzero(active))
if not active[y+ref.cpu.CY[q],x+ref.cpu.CX[q]]
and plan.maps.coarse_active[(y+(2-m)*ref.cpu.CY[q])//2,(x+(2-m)*ref.cpu.CX[q])//2]}
actual_out = [tuple(map(int,row)) for row in rows]
if len(actual_out)!=len(set(actual_out)) or set(actual_out)!=expected_out:
raise ValueError('outgoing uniqueness/coverage failed')
for q,slot,target in actual_out: packets[(q,target)].append(('outgoing',m,q,slot))
expected_receivers = {(q,int(y*n+x)) for y,x in zip(*np.nonzero(plan.maps.coarse_active))
for q in range(1,9) if any(active[2*y+dy-ref.cpu.CY[q],2*x+dx-ref.cpu.CX[q]]
for dy in (0,1) for dx in (0,1)
if 0<=2*y+dy-ref.cpu.CY[q]<nf and 0<=2*x+dx-ref.cpu.CX[q]<nf)}
if set(packets)!=expected_receivers: raise ValueError('coarse receiver coverage failed')
tokens=[token for row in packets.values() for token in row]
if any(len(row)!=4 for row in packets.values()) or len(tokens)!=len(set(tokens)):
raise ValueError('receiver needs four distinct quarter-packets')
return dict(topology='supported',incoming_events=len(actual),outgoing_events=sum(map(len,plan.outgoing)),
transit_events=len(routes),
groups=len(plan.groups),receivers=len(packets),receiver_quarter_packets=len(tokens),
interpolation_accuracy=plan.interpolation_accuracy,runtime_supported=False,
scope='pair-local ownership/coefficients; complete parent domain assumed, nested coverage unvalidated')
@@ -1,497 +0,0 @@
"""Explicit host-only checks or admitted tiny GPU audit; never a long trajectory."""
import argparse
from contextlib import nullcontext
from dataclasses import replace
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
import numpy as np
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT/'src'))
from CelerisLab.config import LBMConfig
import spacetime_gpu as gpu
import run_spacetime_gpu_n64 as trajectory
ref = gpu.ref
RECORDS = []
REPORT = None
OUTPUT = None
GPU_ADMITTED = False
def record(value):
RECORDS.append(value)
if OUTPUT is not None:
ref.write_receipt(OUTPUT,REPORT)
def config(n, shifted=False):
return LBMConfig(nx=n,ny=n,viscosity=.0192,velocity=0.,ddf_shifting=shifted)
def initial(plan, tagged=True):
arrays = []
for n, mask in ((plan.n,plan.maps.coarse_active),(plan.nf,plan.maps.fine_active)):
a = np.broadcast_to(ref.cpu.W[:,None,None], (9,n,n)).copy()
if tagged:
a *= 1+1e-3*np.random.default_rng(n).standard_normal(a.shape)
a[:,~mask] = np.nan
arrays.append(a)
return ref.cpu.State(*arrays)
class Host(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.plan = ref.Plan(12,(3,3,9,8))
def test_pins_and_supported_config(self):
gpu.check_sources()
for n in (12,16,64):
c,f = gpu.validate_config(config(n))
self.assertAlmostEqual(c.omega,1/.5576)
self.assertAlmostEqual(f.omega,1/.6152)
for change in (dict(ddf_shifting=1),dict(collision='TRT'),dict(nx=128,ny=128),
dict(ny=16),dict(les_enabled=True),dict(velocity=.03)):
with self.subTest(change=change), self.assertRaises(ValueError):
gpu.validate_config(replace(config(12),**change))
def test_shifted_codec_and_uploaded_physical_validation(self):
for n in (12,64):
self.assertTrue(gpu.validate_config(config(n,True))[1].ddf_shifting)
physical=gpu._WEIGHTS+2.**-35
stored=gpu._encode_physical(physical,True)
np.testing.assert_array_equal(stored,np.full((9,1,1),2.**-35,np.float32))
np.testing.assert_array_equal(gpu._decode_stored(stored,True),physical)
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
run.status='new'; run.configs=gpu.validate_config(config(12,True)); run.plan=self.plan
run.ctx=SimpleNamespace(activate=nullcontext); run.cuda=Mock()
run.coarse=['coarse']; run.fine=['fine']; run.health_counts=np.zeros(6,np.uint64)
state=initial(self.plan,False)
run.initialize(state)
for call in run.cuda.memcpy_htod.call_args_list:
self.assertTrue((call.args[1][np.isfinite(call.args[1])]==0).all())
state.coarse_f[:]-=.01; state.fine_f[:]-=.01
run.initialize(state) # Valid negative stored values.
with self.assertRaisesRegex(ValueError,'exact shapes'):
run.initialize(ref.cpu.State(state.coarse_f[:1],state.fine_f))
for invalid in (-.01,np.nan):
bad=ref.cpu.State(state.coarse_f.copy(),state.fine_f.copy())
bad.coarse_f[0,0,0]=invalid
with self.assertRaisesRegex(ValueError,'positive finite'):
run.initialize(bad)
def test_shifted_transfer_translation_and_raw_ledger(self):
p=self.plan; t=gpu.pack_plan(p)
rng=np.random.default_rng(19)
for g in p.groups:
values=rng.uniform(.02,.45,len(g.samples)); weight=gpu._WEIGHTS[g.q,0,0]
np.testing.assert_allclose(g.weights@(values-weight),g.weights@values-weight,rtol=0,atol=2e-14)
# Uniform nonzero stored packets expose register omissions; exact rest would not.
arrays=[np.full((9,n,n),-.01,np.float32) for n in (p.n,p.nf)]
for a,m in zip(arrays,(p.maps.coarse_active,p.maps.fine_active)):
a[:,~m]=np.nan
state=ref.cpu.State(*arrays); g0=np.full((9,p.n,p.n),-.01,np.float32)
pre=np.repeat(g0.ravel()[t['origins'],None].astype(float),4,axis=1)
h=pre.astype(np.float32); registers=np.zeros(len(p.receivers))
args=(p,t,state,state,g0,g0,[(state.fine_f,state.fine_f)]*2,pre,h,registers)
ledger=gpu.rounding_ledger(*args,shifted=True)
self.assertEqual(max(map(abs,ledger['unexplained'])),0.)
self.assertGreater(ledger['unexplained_bound'],0.)
self.assertGreater(ledger['minimum_stored'],0.)
expected_mass=p.n*p.n*(1.+9*float(np.float32(-.01)))
self.assertEqual(ledger['unexplained_bound'],64*np.finfo(float).eps*expected_mass)
decoded=ref.cpu.State(*(gpu._decode_stored(a,True) for a in arrays))
with self.assertRaises(AssertionError):
gpu.rounding_ledger(p,t,decoded,*args[3:],shifted=True)
registers[0]+=.25*float(np.float32(-.01))
with self.assertRaisesRegex(AssertionError,'register mismatch'):
gpu.rounding_ledger(*args,shifted=True)
def test_packed_weights_and_packet_ownership(self):
for plan in (self.plan,ref.Plan(16,(4,5,11,12))):
t = gpu.pack_plan(plan)
self.assertEqual(t['sources'].shape,(len(plan.receivers),4))
self.assertEqual(np.count_nonzero(t['times']==-1),24)
self.assertEqual(gpu.table_hash(t),gpu.table_hash(gpu.pack_plan(plan)))
for i,g in enumerate(plan.groups):
np.testing.assert_array_equal(t['weights'][i,:,:len(g.samples)],g.weights)
np.testing.assert_allclose(t['weights'][i].sum(axis=1),1,rtol=0,atol=2e-14)
np.testing.assert_array_equal(t['samples'][i,:len(g.samples)],g.q*plan.n**2+g.samples)
for m in (0,1):
self.assertEqual(len(t[f'incoming{m}']),len(plan.outgoing[m]))
def test_duplicate_writer_is_rejected(self):
plan = self.plan
groups = list(plan.groups)
a,b = next((i,k) for i,g in enumerate(groups) for k,e in enumerate(g.events) if e is not None)
event = groups[a].events[b]
other = next(i for i,g in enumerate(groups) if i!=a and g.q==groups[a].q and event[0] in [e[0] for e in g.events if e])
events = list(groups[other].events)
k = next(k for k,e in enumerate(events) if e is not None and e[0]==event[0])
events[k] = event; groups[other] = replace(groups[other],events=tuple(events))
fake = SimpleNamespace(n=plan.n,nf=plan.nf,groups=groups,receivers=plan.receivers,outgoing=plan.outgoing)
with self.assertRaisesRegex(ValueError,'duplicate incoming'):
gpu.pack_plan(fake)
def test_rest_ledger_and_missing_packet_discrimination(self):
p,t = self.plan,gpu.pack_plan(self.plan)
state = initial(p,False)
state = ref.cpu.State(state.coarse_f.astype(np.float32),state.fine_f.astype(np.float32))
g0 = np.broadcast_to(ref.cpu.W.astype(np.float32)[:,None,None],(9,p.n,p.n)).copy()
pre = np.repeat(g0.ravel()[t['origins'],None].astype(float),4,axis=1)
h = pre.astype(np.float32); registers = np.zeros(len(p.receivers))
phases = [(state.fine_f,state.fine_f)]*2
args = (p,t,state,state,g0,g0,phases,pre,h)
ledger = gpu.rounding_ledger(*args,registers)
self.assertEqual(max(map(abs,ledger['unexplained'])),0)
np.testing.assert_array_equal(ledger['physical_collision'],np.array(ledger['coarse_collision'])
+ledger['fine0_collision']+ledger['fine1_collision'])
np.testing.assert_array_equal(ledger['child_rounding'],np.array(ledger['child_cast'])+ledger['child_interpolation'])
np.testing.assert_array_equal(ledger['collision_cell_mass'],np.zeros((3,4)))
registers[0] = .01
with self.assertRaisesRegex(AssertionError,'register mismatch'):
gpu.rounding_ledger(*args,registers)
def test_second_substep_characteristic_donor(self):
p = ref.Plan(16,(4,4,12,12)); t = gpu.pack_plan(p)
row = t['incoming1'][t['incoming1'][:,0]==5*32*32+10*32+7][0]
self.assertEqual(t['origins'][row[1]//4],5*16*16+4*16+3)
def test_failed_free_remains_retryable(self):
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
run.ctx=SimpleNamespace(activate=nullcontext); run.stream=Mock()
first,second=Mock(),Mock(); first.free.side_effect=[RuntimeError('free failed'),None]
run.allocations=[first,second]; run.modules=[]; run.status='ready'
with self.assertRaisesRegex(RuntimeError,'free failed'):
run.close()
self.assertEqual(run.allocations,[first]); self.assertEqual(run.status,'cleanup_failed')
run.close()
self.assertEqual(run.status,'closed'); self.assertEqual(run.allocations,[])
second.free.assert_called_once()
def test_unmeasured_schedule_and_health_failure(self):
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
run.status='ready'; run.step_count=0; run.identity=False; run.mode='spacetime'
run.ctx=SimpleNamespace(activate=nullcontext); run.cuda=Mock(); run.stream=Mock()
run.plan=self.plan; run.bounds=(3,3,9,8)
run.configs=gpu.validate_config(config(12)); run.coarse=list('abcd'); run.fine=list('efg')
run.tables=gpu.pack_plan(self.plan); run.device_tables={key:key for key in run.tables}
run.active='active'; run.pre='pre'; run.h='h'; run.registers='registers'; run.counts='counts'
run.health_counts=np.zeros(6,np.uint64)
run.launch=Mock(); run.read=Mock(return_value=np.zeros(6,np.uint32)); run.download=Mock()
names=['CoarseStart','Pull','Collide','Interpolate','Collide','Register','Inject','Pull',
'Collide','Register','Inject','Pull','ApplyRegisters']
for _ in range(2):
result=run.step(audit=False)
self.assertEqual(result['boundary_budget_status'],'not_measured')
self.assertIsNone(result['unexplained'])
self.assertEqual([call.args[1] for call in run.launch.call_args_list],names*2)
self.assertEqual(run.read.call_count,2); run.download.assert_not_called()
self.assertEqual(run.step_count,2); self.assertEqual(run.coarse,list('abcd'))
run.read.return_value=np.array([0,0,0,0,1,0],np.uint32)
with self.assertRaisesRegex(AssertionError,'health failed'):
run.step(audit=False)
self.assertEqual(run.status,'failed'); self.assertEqual(run.step_count,2)
self.assertEqual(run.health_counts.tolist(),[0,0,0,0,1,0])
def test_warmup_resets_arrivals_and_counters(self):
audits=[]
class Fake:
def initialize(self,state):
self.state=ref.cpu.State(state.coarse_f.copy(),state.fine_f.copy())
self.step_count=0; self.health_counts=np.zeros(6,np.uint64)
def download(self):
return ref.cpu.State(self.state.coarse_f.copy(),self.state.fine_f.copy(),tick=2*self.step_count)
def step(self,*,audit):
audits.append(audit)
self.step_count+=1; self.state.coarse_f[:]+=1e-4
fake=Fake(); check=Mock(); state=initial(self.plan)
actual,warm=trajectory.warm_and_reset(fake,state,check)
np.testing.assert_array_equal(actual.coarse_f,state.coarse_f)
self.assertEqual(check.call_count,32); self.assertEqual(fake.step_count,0)
self.assertTrue(warm['reset_verified']); self.assertEqual(warm['intervals'],32)
trajectory.warm_and_reset(fake,state,check,audit=True)
self.assertEqual(audits,[False]*32+[True]*32)
def test_accumulated_drift_ledger_and_exact_samples(self):
totals=trajectory.LedgerTotals()
for sign in (1.,-1.):
ledger={name:np.zeros(9) for name in totals.names}
ledger['coarse_collision'][0]=sign*2.**-20
ledger['physical_collision']=ledger['coarse_collision'].copy()
ledger['child_cast'][5]=sign*2.**-30
ledger['child_interpolation'][5]=sign*2.**-50
ledger['child_rounding']=ledger['child_cast']+ledger['child_interpolation']
ledger['receiver_rounding'][5]=-sign*2.**-32
ledger['net_change']=sum(ledger[k] for k in ('physical_collision','child_rounding','receiver_rounding'))
mass=ledger['coarse_collision'].sum()
ledger.update(unexplained_bound=1e-12,collision_cell_mass=[[mass,max(mass,0.),min(mass,0.),abs(mass)],[0.]*4,[0.]*4])
totals.add(ledger)
result=totals.snapshot(np.zeros(9))
self.assertEqual(result['intervals'],2)
self.assertEqual(result['components']['physical_collision']['directional'],[0.]*9)
self.assertGreater(result['components']['physical_collision']['absolute_step_mass_momentum_sum'][0],0)
self.assertEqual(result['components']['physical_collision']['max_absolute_interval_step'][0],1)
self.assertEqual(totals.snapshot()['endpoint_status'],'not_measured')
self.assertFalse(totals.snapshot(np.ones(9)*1e-8)['closure_pass'])
self.assertEqual(trajectory.differing_fields(dict(a=1.,b=[0.,2.]),dict(a=1.,b=[0.,2.])),[])
self.assertEqual(trajectory.differing_fields(dict(a=0.),dict(a=-0.)),['a'])
self.assertEqual(trajectory.differing_fields(dict(a=1.),dict(a=np.nextafter(1.,2.))),['a'])
def test_trajectory_screens_differences_and_json(self):
rows=[dict(tstar=t,velocity_l2=np.float64(.01),pressure_l2=.03,energy_relative=.01,
mass_relative=1e-7,momentum_change=[1e-6,-2e-6],minimum_population=.02,
minimum_boundary_pre=.02,minimum_boundary_stored=.02) for t in trajectory.TIMES]
cpu=dict(runs=[dict(worst=dict(velocity_l2=.02,pressure_l2=.04,energy_relative=.02)),
dict(rows=[dict(tstar=t,velocity_l2=.009,momentum_change=[0.,0.]) for t in trajectory.TIMES])])
uniform=dict(rows=[dict(tstar=.12,pressure_l2=.012253173806349701)])
result=trajectory.assess(rows,cpu,uniform,[0]*6)
self.assertTrue(result['campaign_pass']); self.assertTrue(result['experimental_pass'])
self.assertFalse(result['historical_pressure_gate']['passed'])
self.assertFalse(result['historical_pressure_gate']['matched_storage'])
self.assertAlmostEqual(result['gpu_minus_cpu']['rows'][0]['fields']['velocity_l2']['signed'],.001)
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'receipt.json'
ref.write_receipt(path,dict(completed=True,**result))
self.assertTrue(json.loads(path.read_text())['completed'])
rows[0]['velocity_l2']=.06
failed=trajectory.assess(rows,cpu,uniform,[0,1,0,0,0,0])
ref.write_receipt(path,dict(completed=True,**failed))
saved=json.loads(path.read_text())
self.assertFalse(saved['campaign_pass']); self.assertFalse(saved['experimental_pass'])
for invalid in (float('nan'),-.01):
rows[0]['velocity_l2']=.01; rows[-1]['velocity_l2']=invalid
failed=trajectory.assess(rows,cpu,uniform,[0]*6)
self.assertFalse(failed['campaign_pass']); self.assertFalse(failed['experimental_pass'])
path.write_text('changed reference')
with self.assertRaisesRegex(ValueError,'pinned reference'):
trajectory.load_references(path,path)
def test_shifted_descriptive_comparison_and_runner_pin(self):
expected=dict(mass=4096.,momentum=[0.,-1e-4])
actual=dict(mass=4096.+2.**-20,momentum=[-1e-6,-2e-4])
difference=trajectory.sample_difference(actual,expected)
self.assertEqual(difference['mass']['signed'],2.**-20)
self.assertEqual(difference['momentum']['absolute'],[1e-6,1e-4])
self.assertEqual(trajectory.sha(Path(gpu.__file__).with_suffix('.cu')),trajectory.CUDA_SHA)
self.assertEqual(trajectory.LIMITS,dict(velocity_l2=.05,pressure_l2=.15,energy_relative=.05,mass_relative=1e-5))
self.assertEqual(trajectory.FACTORS,dict(velocity_l2=.8,pressure_l2=1.,energy_relative=1.))
def test_n64_table_guard_and_balanced_mutation(self):
guard=trajectory.table_guard
plan=ref.Plan(64,(8,8,56,48)); tables=gpu.pack_plan(plan)
self.assertEqual(guard.validate_tables(plan,tables)['status'],'passed')
face=next(i for i,g in enumerate(plan.groups) if g.kind=='face')
changed={key:value.copy() for key,value in tables.items()}
changed['weights'][face,0,0]=np.nextafter(changed['weights'][face,0,0],np.inf)
self.assertEqual(guard.validate_tables(plan,changed)['status'],'passed')
for mutation in ('coordinate','order','dtype','shape','layout','padding','negative_zero','nan','inf','balanced'):
changed={key:value.copy() for key,value in tables.items()}
w=changed['weights']
if mutation=='coordinate': changed['incoming1'][0,0]^=1
elif mutation=='order': changed['origins']=changed['origins'][::-1].copy()
elif mutation=='dtype': changed['weights']=w.astype(np.float32)
elif mutation=='shape': changed['weights']=w.reshape(532,10,4)
elif mutation=='layout': changed['weights']=np.asfortranarray(w)
elif mutation=='padding': w[face,0,9]=1e-30
elif mutation=='negative_zero': w[face,0,9]=-0.
elif mutation=='nan': w[face,0,0]=np.nan
elif mutation=='inf': w[face,0,0]=np.inf
else:
w[face,:2,:2] += 2.**-30*np.array([[1.,-1.],[-1.,1.]])
np.testing.assert_array_equal(w[face].sum(axis=1),tables['weights'][face].sum(axis=1))
np.testing.assert_array_equal(w[face].mean(axis=0),tables['weights'][face].mean(axis=0))
with self.subTest(mutation=mutation), self.assertRaises(ValueError):
guard.validate_tables(plan,changed)
changed={key:value.copy() for key,value in tables.items()}
changed['weights'][-1,0,0]+=1.
with self.assertRaises(ValueError):
guard.validate_tables(SimpleNamespace(n=64,groups=plan.groups[:-1]),changed)
groups=list(plan.groups); groups[0]=replace(groups[0],samples=groups[0].samples[:-1])
with self.assertRaisesRegex(ValueError,'coverage/size'):
guard.validate_tables(SimpleNamespace(n=64,groups=groups),tables)
class Device(unittest.TestCase):
shifted = False
def config(self,n):
return config(n,self.shifted)
@classmethod
def setUpClass(cls):
if not GPU_ADMITTED:
raise unittest.SkipTest('CUDA compilation/execution requires explicit tiny-job admission')
from CelerisLab.cuda.context import CudaContext
cls.context = CudaContext(0)
@classmethod
def tearDownClass(cls):
cls.context.close()
def test_tiny_equivalence_budgets_and_phase(self):
for mode,identity in (('spacetime',False),('spacetime',True),('frozen',False),('frozen',True)):
with self.subTest(mode=mode,identity=identity), gpu.SpaceTimeGPU(
self.config(12),(3,3,9,8),context=self.context,mode=mode,identity=identity) as run:
spec = ref.Spec(n_coarse=12,patch_lo=1,halo=2,tau_coarse=.5576,tau_fine=.6152,
collision='identity' if identity else 'srt')
for tagged,steps in ((False,2),(True,8)):
expected = initial(run.plan,tagged); run.initialize(expected)
before = run.download()
if self.shifted:
expected = before
for k in range(1,steps+1):
expected,_ = ref.advance(spec,run.plan,expected,mode)
ledger = run.step(); actual = run.download()
self.assertEqual(actual.tick,2*k); self.assertEqual(run.step_count,k)
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
np.testing.assert_array_equal(np.isnan(a),np.isnan(b))
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True)
total = lambda s: ref.cpu.MOMENTS.T @ ref.directional(
ref.cpu.State(s.coarse_f.astype(float),s.fine_f.astype(float)),run.plan.maps)
np.testing.assert_allclose(total(actual),total(before),rtol=0,atol=2e-4)
if tagged and not identity and k==1:
for a,b,tau,mask in zip((actual.coarse_f,actual.fine_f),
(expected.coarse_f,expected.fine_f),(.5576,.6152),
(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
wrong = ref.cpu._collide(spec,b,np.isfinite(b),tau,mask,np.zeros(mask.shape,int))
self.assertGreater(float(np.nanmax(abs(a-wrong))),3e-6)
if k in (1,2,8):
record(dict(case='tiny',shifted=self.shifted,mode=mode,identity=identity,tagged=tagged,
step=k,device_bytes=run.device_bytes,ledger=ledger))
def test_omitted_packets_and_ghosts_fail(self):
for mutation in (1,2,3,4):
with self.subTest(mutation=mutation), gpu.SpaceTimeGPU(
self.config(12),(3,3,9,8),context=self.context,identity=self.shifted) as run:
state=initial(run.plan,not self.shifted)
if self.shifted:
state.coarse_f[:]+=1e-4; state.fine_f[:]+=1e-4
for a,mask in zip((state.coarse_f,state.fine_f),(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
self.assertTrue((gpu._encode_physical(a,True)[:,mask]>0).all())
run.initialize(state)
with self.assertRaises(AssertionError):
run.step(mutation=mutation)
self.assertEqual(run.step_count,0)
self.assertEqual(run.status,'failed')
with self.assertRaises(RuntimeError):
run.download()
def test_frozen_donor_trap(self):
with gpu.SpaceTimeGPU(self.config(16),(4,4,12,12),context=self.context,mode='frozen',identity=True) as run:
state = initial(run.plan,False)
state.coarse_f[5,4,3]=1.; state.coarse_f[5,5,3]=2.
run.initialize(state); donor=run.download().coarse_f[5,4,3]; run.step()
self.assertEqual(run.download().fine_f[5,11,8],donor)
# Negative stored values can be physical in shifted mode; never clip either case.
with self.context.activate():
for physical_negative in ((False,True) if self.shifted else (True,)):
data=np.full((9,16,16),-.01,np.float32)
if self.shifted and physical_negative:
data=np.broadcast_to(-gpu._WEIGHTS-.01,(9,16,16)).astype(np.float32).copy()
run.cuda.memcpy_htod(run.coarse[1],data); run.cuda.memcpy_htod(run.coarse[3],data)
run.cuda.memset_d32_async(run.counts,0,6,run.stream)
t=run.device_tables; ng=len(run.plan.groups)
run.launch(0,'Interpolate',run.coarse[1],run.coarse[3],t['samples'],t['origins'],
t['sizes'],t['weights'],run.pre,run.h,np.uint32(ng),np.uint32(0),run.counts,count=ng)
counts=run.read(run.counts,(6,),np.uint32)
self.assertEqual(counts[1],4*ng*physical_negative)
self.assertEqual(counts[3],4*ng*physical_negative)
self.assertTrue((run.read(run.h,(ng,4))<0).all())
def test_n64_one_interval_only(self):
sys.path.insert(0,str(Path(__file__).parents[1]/'taylor_green'))
import oracle as tg
with gpu.SpaceTimeGPU(self.config(64),(8,8,56,48),context=self.context) as run:
arrays = [tg.initial((64,64),0,64,.0192),tg.initial((128,128),1,64,.0192)]
for a,mask in zip(arrays,(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
a[:,~mask]=np.nan
state = ref.cpu.State(*arrays); run.initialize(state)
spec = ref.Spec(n_coarse=64,patch_lo=8,halo=16,tau_coarse=.5576,tau_fine=.6152,collision='srt')
expected,_ = ref.advance(spec,run.plan,run.download() if self.shifted else state)
started=time.monotonic(); ledger=run.step(); seconds=time.monotonic()-started
actual=run.download()
errors=[]
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
np.testing.assert_array_equal(np.isnan(a),np.isnan(b))
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True)
errors.append(float(np.nanmax(abs(a-b))))
record(dict(case='N64_one_interval',shifted=self.shifted,errors=errors,ledger=ledger,seconds=seconds,
device_bytes=run.device_bytes,table_sha256=gpu.table_hash(run.tables),
build_metadata=run.build_metadata,device=self.context.name))
def test_unmeasured_path_and_warm_reset(self):
with gpu.SpaceTimeGPU(self.config(12),(3,3,9,8),context=self.context) as run:
state=initial(run.plan)
run.initialize(state)
raw_initial=run._download_stored()
for _ in range(2):
run.step()
expected=run._download_stored()
run.initialize(state)
for _ in range(2):
result=run.step(audit=False)
self.assertEqual(result['boundary_budget_status'],'not_measured')
actual=run._download_stored()
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
reset,warm=trajectory.warm_and_reset(run,state,lambda:None)
raw_reset=run._download_stored()
for a,b in zip((raw_reset.coarse_f,raw_reset.fine_f),(raw_initial.coarse_f,raw_initial.fine_f)):
np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
record(dict(case='unmeasured_bitwise_and_reset',shifted=self.shifted,warmup=warm,health_counts=run.health_counts.tolist()))
class ShiftedDevice(Device):
shifted = True
def main():
global REPORT,OUTPUT,GPU_ADMITTED
parser=argparse.ArgumentParser(description=__doc__)
mode=parser.add_mutually_exclusive_group(required=True)
mode.add_argument('--host-only',action='store_true')
mode.add_argument('--gpu-admitted',action='store_true')
parser.add_argument('--output',type=Path)
args=parser.parse_args()
GPU_ADMITTED=args.gpu_admitted
if args.gpu_admitted and (not os.environ.get('CUDA_VISIBLE_DEVICES') or args.output is None):
parser.error('GPU job requires explicit CUDA_VISIBLE_DEVICES and --output')
if args.output is not None and args.output.exists():
parser.error('output exists; retain evidence and choose a new path')
started=time.monotonic()
report=dict(scope='tiny GPU equivalence and rounding audit; no long trajectory',completed=False,
requested_mode='admitted_tiny_gpu' if args.gpu_admitted else 'host_only',
commit=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(),
dirty=bool(subprocess.check_output(['git','status','--porcelain'],cwd=ROOT,text=True)),
hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in
(Path(__file__),Path(gpu.__file__),Path(gpu.__file__).with_suffix('.cu'))},
reference_hashes=gpu.PINS,cuda_visible_devices=os.environ.get('CUDA_VISIBLE_DEVICES'),records=RECORDS)
REPORT,OUTPUT=report,args.output
if args.output:
ref.write_receipt(args.output,report)
suite=unittest.TestLoader().loadTestsFromTestCase(Host)
if args.gpu_admitted:
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(Device))
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(ShiftedDevice))
result=unittest.TextTestRunner(verbosity=2).run(suite)
report.update(completed=True,passed=result.wasSuccessful(),tests=result.testsRun,
failures=[dict(test=str(test),error=error) for test,error in result.failures+result.errors],
seconds=time.monotonic()-started)
if args.output:
ref.write_receipt(args.output,report)
return 0 if result.wasSuccessful() else 1
if __name__=='__main__':
raise SystemExit(main())
@@ -1,205 +0,0 @@
"""Host checks or separately admitted tiny GPU ladder capability; no trajectories."""
import argparse
from contextlib import nullcontext
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
import numpy as np
import run_spacetime_gpu_ladder as ladder
gpu,ref,tg=ladder.gpu,ladder.ref,ladder.tg
RECORDS=[]
ADMITTED=False
def config(n):
return ladder.old.LBMConfig(nx=n,ny=n,viscosity=.0003*n,velocity=0.,ddf_shifting=True)
class Host(unittest.TestCase):
def test_scaling_and_admission(self):
for _,n,refined in ladder.CASES:
s=ladder.setup(n,refined)
self.assertEqual(s['finest_n'],n*(2 if refined else 1))
self.assertEqual(len(s['early_steps']),65)
np.testing.assert_allclose(np.array(s['early_steps'])*.03/n,s['early_times'],rtol=0,atol=3e-17)
self.assertEqual(s['intervals']*.03/n,.48)
gpu.validate_config(config(n),uniform=not refined,allow_n128=refined)
with self.assertRaises(ValueError): gpu.validate_config(config(128))
with self.assertRaises(ValueError): gpu.validate_config(config(256),allow_n128=True)
with self.assertRaises(ValueError): gpu.validate_config(config(512),uniform=True)
def test_n128_tables_and_mutations(self):
plan=ref.Plan(128,(16,16,112,96)); tables=gpu.pack_plan(plan)
out=ladder.old.table_guard.validate_tables(plan,tables,allow_n128=True)
self.assertEqual(out['structure_sha256'],'ee4ecb645674ae04eb87220a5217851405ccc0f45fbb0fe210499dcea5cd93b1')
self.assertEqual(len(plan.groups),1060)
with self.assertRaises(ValueError): ladder.old.table_guard.validate_tables(plan,tables)
for kind in ('topology','balanced_weights'):
bad={k:v.copy() for k,v in tables.items()}
if kind=='topology': bad['sources'][0,0]+=1
else: bad['weights'][0,:2,:2]+=1e-5*np.array([[1,-1],[-1,1]])
with self.assertRaises(ValueError): ladder.old.table_guard.validate_tables(plan,bad,allow_n128=True)
def test_projection_manufactured_nonuniform_leaves(self):
n=16; plan=ref.Plan(n,(3,3,13,12)); masks=(plan.maps.coarse_active,plan.maps.fine_active)
coords=[tg.centers(m.shape,l) for l,m in enumerate(masks)]
x=np.concatenate([v[0][m] for v,m in zip(coords,masks)])
y=np.concatenate([v[1][m] for v,m in zip(coords,masks)])
w=np.concatenate([np.full(m.sum(),.25**l) for l,m in enumerate(masks)])
b=np.column_stack((np.cos(4*np.pi*x/n),np.sin(4*np.pi*x/n),np.cos(4*np.pi*y/n),np.sin(4*np.pi*y/n)))
b-=w@b/w.sum(); gram=b.T@(w[:,None]*b)
residual=np.cos(6*np.pi*x/n)+np.sin(6*np.pi*y/n); residual-=w@residual/w.sum()
residual-=b@np.linalg.solve(gram,b.T@(w*residual)); residual*=1e-5
coefficients=np.array([2,-3,4,-1])*1e-5
pressure=tg.exact(x,y,0,n,.0003*n)[2]+b@coefficients+residual+.017
arrays=[]; offset=0
for m in masks:
a=np.full((9,*m.shape),np.nan); count=m.sum()
a[:,m]=ref.cpu.W[:,None]*(1+3*pressure[offset:offset+count]); arrays.append(a); offset+=count
result=ladder.pressure_projection(arrays,masks,0,n,.0003*n)
np.testing.assert_allclose(result['coefficients'],coefficients,rtol=0,atol=3e-17)
self.assertAlmostEqual(result['residual_rms'],np.sqrt(w@(residual*residual)/w.sum()),places=15)
np.testing.assert_allclose(result['orthogonality'],0,rtol=0,atol=1e-18)
self.assertLess(abs(result['energy_closure']),1e-20)
expected=tg.metrics(arrays,masks,0,n,.0003*n)['pressure_l2']
self.assertAlmostEqual(result['relative_rms'],expected,places=12)
def test_uniform_budget_and_negative_control(self):
rng=np.random.default_rng(12); a=rng.normal(0,.001,(9,12,12)).astype(np.float32)
g=(a*.99).astype(np.float32)
b=np.stack([np.roll(g[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
ledger=gpu.uniform_ledger(a,b,g,shifted=True)
self.assertLess(max(abs(np.array(ledger['unexplained']))),ledger['unexplained_bound'])
b[1,0,0]+=1e-4
with self.assertRaises(AssertionError): gpu.uniform_ledger(a,b,g,shifted=True)
def test_uniform_failed_io_poisoning(self):
run=object.__new__(gpu.UniformGPU); run.configs=(config(12),); run.status='ready'
run.ctx=SimpleNamespace(activate=nullcontext); run.buffers=['f']; run.cuda=SimpleNamespace(memcpy_htod=Mock(side_effect=RuntimeError('copy')))
with self.assertRaisesRegex(RuntimeError,'copy'): run.initialize(np.broadcast_to(ref.cpu.W[:,None,None],(9,12,12)))
self.assertEqual(run.status,'failed'); run.status='ready'; run.read=Mock(side_effect=RuntimeError('read'))
with self.assertRaisesRegex(RuntimeError,'read'): run._download_stored()
self.assertEqual(run.status,'failed')
def test_uniform_health_mask_reaches_both_kernels(self):
run=object.__new__(gpu.UniformGPU); run.configs=(config(16),); run.status='ready'; run.identity=False
run.ctx=SimpleNamespace(activate=nullcontext); run.buffers=['f','g','out']; run.active=object(); run.counts='counts'
run.stream=object(); run.cuda=SimpleNamespace(memset_d32_async=Mock()); run.launch=Mock()
run.read=Mock(return_value=np.zeros(6,np.uint32)); run.health_counts=np.zeros(6,np.uint64); run.step_count=0
run.step(audit=False)
self.assertEqual([call.args[1] for call in run.launch.call_args_list],['Collide','Pull'])
self.assertTrue(all(call.args[4] is run.active for call in run.launch.call_args_list))
def test_zero_error_comparisons(self):
rows=[dict(tstar=t,velocity_l2=0.,pressure_l2=0.,energy_relative=0.) for t in ladder.old.TIMES]
result=ladder.comparisons([dict(name=name,rows=rows) for name,_,_ in ladder.CASES],dict(rows=rows))
self.assertIsNone(result['trends'][0]['samples'][0]['pressure_l2']['ratio'])
self.assertNotIn('two_point_slope',result['controls'][0]['samples'][0]['pressure_l2'])
self.assertIn('two_point_slope',result['trends'][0]['samples'][0]['pressure_l2'])
json.dumps(result,allow_nan=False)
def test_frozen_screen_and_json_receipt(self):
rows=[dict(tstar=t,minimum_population=.02,**{key:np.float64(limit*.5) for key,limit in ladder.LIMITS.items()}) for t in ladder.old.TIMES]
self.assertTrue(ladder.assess(rows,[0]*6)['campaign_pass'])
rows[0]['mass_relative']=2e-5
failed=ladder.assess(rows,[0]*6); self.assertFalse(failed['campaign_pass'])
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'receipt.json'; ref.write_receipt(path,dict(completed=True,passed=np.bool_(False),assessment=failed))
saved=json.loads(path.read_text()); self.assertFalse(saved['passed']); self.assertTrue(saved['completed'])
class Device(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not ADMITTED: raise unittest.SkipTest('explicit tiny GPU admission required')
from CelerisLab.cuda.context import CudaContext
cls.context=CudaContext(0)
@classmethod
def tearDownClass(cls): cls.context.close()
def test_uniform_actual_upload_oracle_and_reset(self):
for n in (16,64,128,256):
with gpu.UniformGPU(config(n),context=self.context) as run:
analytic=tg.initial((n,n),0,n,.0003*n); run.initialize(analytic); start=run._download_stored()
actual_start=run.download(); mask=np.ones((n,n),bool)
spec=ref.Spec(n_coarse=n,patch_lo=1,halo=2,tau_coarse=.5+.0009*n,tau_fine=.5+.0018*n,collision='srt')
collided=ref.cpu._collide(spec,actual_start,np.isfinite(actual_start),spec.tau_coarse,mask,np.zeros((n,n),int))
expected=np.stack([np.roll(collided[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
ledger=run.step(); actual=run.download()
np.testing.assert_allclose(actual,expected,rtol=0,atol=3e-6)
audited=run._download_stored(); run.initialize(analytic); run.step(audit=False)
np.testing.assert_array_equal(audited.view(np.uint32),run._download_stored().view(np.uint32))
run.initialize(analytic); np.testing.assert_array_equal(start.view(np.uint32),run._download_stored().view(np.uint32))
self.assertEqual(run.step_count,0); self.assertFalse(run.health_counts.any())
RECORDS.append(dict(case=f'uniform{n}_one_interval',error=float(abs(actual-expected).max()),ledger=ledger,
build_metadata=run.build_metadata,device_bytes=run.device_bytes,raw_reset_verified=True))
def test_uniform_tagged_phase_and_health_controls(self):
n=16; rng=np.random.default_rng(n)
analytic=np.broadcast_to(ref.cpu.W[:,None,None],(9,n,n))*(1+1e-3*rng.standard_normal((9,n,n)))
for identity in (True,False):
with gpu.UniformGPU(config(n),context=self.context,identity=identity) as run:
run.initialize(analytic); start=run.download(); raw_start=run._download_stored(); mask=np.ones((n,n),bool)
spec=ref.Spec(tau_coarse=.5+.0009*n,collision='identity' if identity else 'srt')
collided=ref.cpu._collide(spec,start,np.isfinite(start),spec.tau_coarse,mask,np.zeros((n,n),int))
expected=np.stack([np.roll(collided[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
ledger=run.step(); actual=run.download(); np.testing.assert_allclose(actual,expected,rtol=0,atol=3e-6)
if identity:
rolled=np.stack([np.roll(raw_start[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
np.testing.assert_array_equal(run._download_stored().view(np.uint32),rolled.view(np.uint32))
if not identity:
wrong=ref.cpu._collide(spec,expected,np.isfinite(expected),spec.tau_coarse,mask,np.zeros((n,n),int))
self.assertGreater(float(abs(actual-wrong).max()),3e-6)
RECORDS.append(dict(case='uniform_tagged',identity=identity,error=float(abs(actual-expected).max()),ledger=ledger))
with gpu.UniformGPU(config(n),context=self.context) as run:
run.initialize(analytic)
with self.context.activate():
bad=np.broadcast_to(-gpu._WEIGHTS-.01,(9,n,n)).astype(np.float32).copy()
run.cuda.memcpy_htod(run.buffers[0],bad)
with self.assertRaises(AssertionError): run.step()
self.assertTrue(run.health_counts.any()); self.assertEqual(run.status,'failed')
RECORDS.append(dict(case='uniform_health_negative_control',health_counts=run.health_counts.tolist()))
def test_refined_n128_actual_upload_oracle(self):
with gpu.SpaceTimeGPU(config(128),(16,16,112,96),context=self.context,allow_n128=True) as run:
tables=ladder.old.table_guard.validate_tables(run.plan,run.tables,allow_n128=True)
run.initialize(ref.cpu.State(tg.initial((128,128),0,128,.0384),tg.initial((256,256),1,128,.0384)))
spec=ref.Spec(n_coarse=128,patch_lo=16,halo=32,tau_coarse=.6152,tau_fine=.7304,collision='srt')
expected,_=ref.advance(spec,run.plan,run.download()); ledger=run.step(); actual=run.download(); errors=[]
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True); errors.append(float(np.nanmax(abs(a-b))))
RECORDS.append(dict(case='refined128_one_interval',errors=errors,ledger=ledger,tables=tables,
build_metadata=run.build_metadata,device_bytes=run.device_bytes))
def main():
global ADMITTED
p=argparse.ArgumentParser(description=__doc__); modes=p.add_mutually_exclusive_group(required=True)
modes.add_argument('--host-only',action='store_true'); modes.add_argument('--gpu-admitted',action='store_true')
p.add_argument('--output',type=Path,required=True); args=p.parse_args(); ADMITTED=args.gpu_admitted
if args.output.exists(): p.error('output exists; preserve evidence')
if ADMITTED and not os.environ.get('CUDA_VISIBLE_DEVICES'): p.error('explicit CUDA_VISIBLE_DEVICES required')
paths=(Path(__file__),Path(ladder.__file__),Path(gpu.__file__),Path(gpu.__file__).with_suffix('.cu'),Path(ladder.old.table_guard.__file__))
report=dict(completed=False,passed=False,mode='tiny_gpu' if ADMITTED else 'host_only',records=RECORDS,
source=dict(commit=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ladder.ROOT,text=True).strip(),
dirty=bool(subprocess.check_output(['git','status','--porcelain'],cwd=ladder.ROOT,text=True)),
hashes={p.name:ladder.old.sha(p) for p in paths}))
ref.write_receipt(args.output,report); started=time.monotonic()
suite=unittest.TestLoader().loadTestsFromTestCase(Host)
if ADMITTED: suite.addTests(unittest.TestLoader().loadTestsFromTestCase(Device))
result=unittest.TextTestRunner(verbosity=2).run(suite)
report.update(completed=True,passed=result.wasSuccessful(),tests=result.testsRun,seconds=time.monotonic()-started,
failures=[dict(test=str(t),error=e) for t,e in result.errors+result.failures])
ref.write_receipt(args.output,report); return 0 if report['passed'] else 1
if __name__=='__main__': raise SystemExit(main())
@@ -1,155 +0,0 @@
"""Tiny CPU algebra/event tests; no full trajectory or GPU execution."""
from dataclasses import replace
import json
from pathlib import Path
import tempfile
import unittest
import numpy as np
import spacetime_reference as st
class SpaceTime(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.plan = st.Plan(12, (3, 3, 9, 8))
cls.spec = st.Spec(n_coarse=12, patch_lo=1, halo=2,
tau_coarse=.5576, tau_fine=.6152, collision="srt")
def state(self, tagged=False):
arrays = []
for n, mask in ((12,self.plan.maps.coarse_active),(24,self.plan.maps.fine_active)):
q, y, x = np.indices((9,n,n))
a = np.broadcast_to(st.cpu.W[:,None,None], (9,n,n)).copy()
if tagged:
a *= 1+1e-3*np.random.default_rng(n).normal(size=a.shape)
a[:,~mask] = np.nan
arrays.append(a)
return st.cpu.State(*arrays)
def test_events_and_corner_leftovers(self):
groups = [g for g in self.plan.groups if None in g.events]
self.assertEqual(len(groups), 8)
self.assertTrue(all(g.events.count(None)==3 for g in groups))
for m in (0,1):
events = list(map(tuple,self.plan.outgoing[m]))
self.assertEqual(len(events),len(set(events)))
st.Plan(16,(4,5,11,12))
def test_interpolation(self):
for g in self.plan.groups:
samples, a, e, coeff = st.interpolation(g.kind,g.points)
np.testing.assert_allclose(g.weights@np.ones(len(samples)),1,rtol=0,atol=2e-14)
np.testing.assert_allclose(g.weights.mean(axis=0), np.eye(len(samples))[1 if g.kind=='face' else 0],rtol=0,atol=2e-14)
if g.kind=='face':
beta=np.array([1.,.03,.02,0.])
else:
cx,cy=st.cpu.CX[g.q],st.cpu.CY[g.q]
beta=np.array([1.,.03,.02,-cx*.03-cy*.02])
steady=a@np.array([1.,.03,.02,0.])
self.assertLess(abs((coeff@steady)[3]),2e-14)
np.testing.assert_allclose(g.weights@(a@beta),e@beta,rtol=0,atol=2e-14)
def test_rest(self):
initial=self.state()
final,d=st.advance(self.spec,self.plan,initial)
for a,b in zip((initial.coarse_f,initial.fine_f),(final.coarse_f,final.fine_f)):
np.testing.assert_allclose(a,b,rtol=0,atol=2e-14,equal_nan=True)
def test_frozen_recovers_ivp(self):
for collision in ('identity','srt'):
spec=replace(self.spec,collision=collision)
a=b=self.state(True)
for _ in range(2):
a,_=st.advance(spec,self.plan,a,'frozen')
b,_=st.cpu.advance_coarse_once(spec,self.plan.maps,b)
for aa,bb in zip((a.coarse_f,a.fine_f),(b.coarse_f,b.fine_f)):
np.testing.assert_allclose(aa,bb,rtol=0,atol=2e-14,equal_nan=True)
def test_second_substep_uses_characteristic_origin(self):
# Review counterexample in (x,y): ghost (7,10), m=1 traces back
# to (6,9), parent (3,4), not its current parent (3,5).
plan=st.Plan(16,(4,4,12,12))
owners=[g for g in plan.groups if g.q==5 and (1,10*32+7) in g.events]
self.assertEqual(len(owners),1)
self.assertEqual(owners[0].source,4*16+3)
spec=st.Spec(n_coarse=16,patch_lo=2,halo=4,collision='identity')
arrays=[]
for n,mask in ((16,plan.maps.coarse_active),(32,plan.maps.fine_active)):
a=np.broadcast_to(st.cpu.W[:,None,None],(9,n,n)).copy()
a[:,~mask]=np.nan
arrays.append(a)
arrays[0][5,4,3]=1.; arrays[0][5,5,3]=2.
final,_=st.advance(spec,plan,st.cpu.State(*arrays),'frozen')
self.assertEqual(final.fine_f[5,11,8],1.)
self.assertNotEqual(final.fine_f[5,11,8],arrays[0][5,5,3])
def test_corner_affine_tradeoff_is_explicit(self):
group=next(g for g in self.plan.groups if g.kind=='corner')
_,a,e,coeff=st.interpolation(group.kind,group.points)
linear_x=np.array([0.,1.,0.,0.])
# The two paper constraints are consistent. Arbitrary affine exactness
# is the incompatible additional demand.
self.assertEqual(abs(float((e@linear_x).mean())),.125)
fitted=coeff@(a@linear_x)
self.assertLess(abs(fitted[3]),2e-14)
self.assertLess(abs(float((e@fitted).mean())),2e-14)
self.assertGreater(np.max(np.abs(e@fitted-e@linear_x)),.01)
def test_directional_budgets(self):
for collision in ('identity','srt'):
spec=replace(self.spec,collision=collision)
a=self.state(True)
for _ in range(2):
a,d=st.advance(spec,self.plan,a)
np.testing.assert_allclose(d['directional_residual'],0,rtol=0,atol=2e-12)
self.assertLess(d['group_error'],2e-14)
def test_missing_packets_fail(self):
for mutation in ('omit_leftover','omit_outgoing_0','omit_outgoing_1'):
_,d=st.advance(self.spec,self.plan,self.state(True),mutation=mutation)
self.assertGreater(np.max(np.abs(d['directional_residual'])),1e-5)
def test_negative_interpolation_is_reported_not_clipped(self):
initial=self.state()
group=self.plan.groups[0]
initial.coarse_f.reshape(9,-1)[group.q,group.source]=-.01
spec=replace(self.spec,collision='identity')
_,d=st.advance(spec,self.plan,initial,'frozen')
self.assertGreaterEqual(d['negative_boundary_count'],4)
self.assertEqual(d['min_boundary'],-.01)
self.assertEqual(d['nonfinite_boundary_count'],0)
class Receipts(unittest.TestCase):
def test_completed_and_failed_screen_receipts(self):
for passed in (True, False):
with self.subTest(passed=passed), tempfile.TemporaryDirectory() as directory:
# Same NumPy comparison type produced by the final metric screen.
checks = {'velocity_l2': np.float64(.5 if passed else 1.) <= .8,
'mass_momentum': True}
self.assertIsInstance(checks['velocity_l2'], np.bool_)
result = dict(completed=True, checks=checks,
experimental_pass=all(checks.values()),
runs=[dict(rows=[dict(step=np.int64(1024),
mass_relative=np.float32(0.))])])
path = Path(directory)/'receipt.json'
st.write_receipt(path, result)
saved = json.loads(path.read_text())
self.assertIs(saved['completed'], True)
self.assertIs(saved['experimental_pass'], passed)
self.assertIs(saved['checks']['velocity_l2'], passed)
self.assertEqual(saved['runs'][0]['rows'][0],
dict(step=1024, mass_relative=0.))
def test_receipts_still_reject_nonfinite_and_unknown_values(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/'receipt.json'
with self.assertRaises(ValueError):
st.write_receipt(path, {'invalid': np.float32('nan')})
with self.assertRaises(TypeError):
st.write_receipt(path, {'invalid': object()})
self.assertFalse(path.exists())
if __name__=='__main__':
unittest.main(verbosity=2)
@@ -1,287 +0,0 @@
"""Host-only ownership/geometry tests, never a GPU or accuracy admission."""
import copy
import json
import sys
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D
from spacetime_union_plan import UnionPlan, UnresolvedTopology, owned_slots, validate_ownership
import spacetime_reference as ref
import spacetime_gpu as frozen # pack_plan only: no CUDA import/owner construction
from spacetime_table_guard import exact_weights
MATRIX=[]
ENUMERATION={}
CASES={
'rectangle': ([(4,4,12,12)],True),
'two_separated': ([(3,3,6,6),(9,9,12,12)],True),
'one_cell_face_gap': ([(3,3,6,10),(7,3,10,10)],True),
'one_cell_diagonal_gap': ([(3,3,6,6),(7,7,10,10)],True),
'staggered_gap': ([(3,3,6,8),(7,5,11,10)],True),
'thin_rectangle': ([(4,4,5,11)],True),
'concave_L': ([(4,4,5,7),(4,5,7,7)],True),
'corner_contact': ([(3,3,6,6),(6,6,9,9)],True),
'cross': ([(3,6,13,9),(6,3,9,13)],True),
'narrow_notch': ([(3,3,6,11),(7,3,10,11),(3,9,10,11)],True),
'hole': ([(3,3,13,5),(3,11,13,13),(3,5,5,11),(11,5,13,11)],True),
}
def pair(rects,n=16,ny=None):
cfg=LBMConfig(nx=n,ny=n if ny is None else ny,viscosity=.0192,velocity=0.)
return FixedPatchSet2D.from_config(cfg,rects)
def assert_same(test,a,b):
# Includes event-child order, zero padding, integer dtype/shape and FP64 bits.
aa,bb=frozen.pack_plan(a),frozen.pack_plan(b)
test.assertEqual(aa.keys(),bb.keys())
for key in aa:
test.assertEqual((aa[key].dtype,aa[key].shape,aa[key].tobytes()),
(bb[key].dtype,bb[key].shape,bb[key].tobytes()),key)
for x,y in zip(a.groups,b.groups):
test.assertEqual((x.q,x.source,x.receiver,x.kind,x.points,x.events),
(y.q,y.source,y.receiver,y.kind,y.points,y.events))
# Frozen pack_plan has no transit column and is NOT a union runtime format.
np.testing.assert_array_equal(getattr(a,'transit',np.empty((0,3),int)),
getattr(b,'transit',np.empty((0,3),int)))
class UnionContract(unittest.TestCase):
def test_single_rectangle_exact_707afe6(self):
frozen.check_sources()
for n,b in ((12,(3,3,9,8)),(16,(4,5,11,12)),(16,(4,4,5,7)),(64,(8,8,56,48))):
with self.subTest(n=n,bounds=b):
assert_same(self,UnionPlan(pair([b],n)),ref.Plan(n,b))
self.assertNotIn('pycuda.driver',sys.modules)
def test_union_decomposition_and_permutation_invariance(self):
baseline=UnionPlan(pair([(4,4,12,12)]))
decompositions=[[(4,4,8,12),(8,4,12,12)],[(4,4,10,12),(6,4,12,12)],
[(4,4,12,8),(4,8,12,12)],[(4,4,12,12),(4,4,12,12)]]
for rectangles in decompositions:
for order in (rectangles,list(reversed(rectangles))):
assert_same(self,baseline,UnionPlan(pair(order)))
two=CASES['two_separated'][0]
assert_same(self,UnionPlan(pair(two)),UnionPlan(pair(list(reversed(two)))))
lshape=CASES['concave_L'][0]
for decomposition in (list(reversed(lshape)),[(4,4,5,5),(4,5,7,7)],lshape+lshape):
assert_same(self,UnionPlan(pair(lshape)),UnionPlan(pair(decomposition)))
def test_supported_unresolved_matrix(self):
for name,(rectangles,supported) in CASES.items():
geometry=pair(rectangles) # Existing production accepts EVERY fixture.
row=dict(case=name,rectangles=rectangles,production_geometry_accepted=True,
runtime_supported=False,interpolation_accuracy='unqualified_for_new_unions')
if supported:
plan=UnionPlan(geometry); row.update(plan.proof)
self.assertEqual(plan.proof['receiver_quarter_packets'],4*len(plan.receivers))
else:
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(geometry)
row.update(topology='unresolved',code=caught.exception.code,witness=caught.exception.detail)
MATRIX.append(row)
def test_L_witness_and_reflections(self):
rectangles=CASES['concave_L'][0]
plan=UnionPlan(pair(rectangles))
self.assertIn((5,8*32+9,9*32+10),map(tuple,plan.transit))
directions=set()
for reflect in (False,True):
transformed=[(16-x1,y0,16-x0,y1) if reflect else (x0,y0,x1,y1) for x0,y0,x1,y1 in rectangles]
for _ in range(4):
plan=UnionPlan(pair(transformed)); self.assertEqual(len(plan.transit),2)
for q,source,ghost in plan.transit:
directions.add(q); p=np.array(divmod(source,32)); c=np.array((ref.cpu.CY[q],ref.cpu.CX[q]))
self.assertEqual(ghost,(p+c)[0]*32+(p+c)[1])
self.assertEqual([bool(plan.maps.fine_active[tuple(p+j*c)]) for j in range(3)],[True,False,True])
transformed=[(16-y1,x0,16-y0,x1) for x0,y0,x1,y1 in transformed]
self.assertEqual(directions,{5,6,7,8})
def test_Fig19_two_hop_transport_and_quarter_budget(self):
# Guzik §3.5.4 Fig.19: https://www.osti.gov/servlets/purl/1226215 .
# Isolated tagged transport only: no collision law or union evolution.
for name in ('concave_L','corner_contact','hole'):
p=UnionPlan(pair(CASES[name][0])); q,source,ghost=p.transit.T
stride=ref.cpu.CY[q]*p.nf+ref.cpu.CX[q]; target=ghost+stride
tags=(np.arange(len(q),dtype=np.float32)+1)/256
active=p.maps.fine_active
def run(shifted,mutation=None):
scratch=np.full((9,p.nf,p.nf),np.nan,np.float32)
scratch.reshape(9,-1)[q,source]=tags
def moment(field):
value=field.astype(float)+(ref.cpu.W[:,None,None] if shifted else 0)
return .25*np.where(np.isfinite(field)&active,value,0).sum(axis=(1,2))
def pull(field):
moved=np.stack([np.roll(field[j],(ref.cpu.CY[j],ref.cpu.CX[j]),axis=(0,1)) for j in range(9)])
moved[:,~active]=np.nan
return moved
initial=moment(scratch)
carry=[(int(j),int(h),v.copy()) for j,h,v in zip(q,ghost,scratch.reshape(9,-1)[q,source])]
if mutation=='drop': carry.pop()
if mutation=='duplicate': carry.append(carry[0])
first=pull(scratch) # m0 ghost arrivals are outside the ACTIVE mask.
inventory=np.zeros(9)
for j,h,value in carry: inventory[j]+=.25*(float(value)+(ref.cpu.W[j] if shifted else 0))
np.testing.assert_allclose(moment(first)+inventory,initial,rtol=0,atol=2e-14)
if mutation=='early':
for j,h,value in carry: scratch.reshape(9,-1)[j,h]=value
scratch[:]=np.nan # m1 collision scratch reuse destroys an early injection.
while carry:
j,h,value=carry.pop()
if mutation!='early': scratch.reshape(9,-1)[j,h]=value
final=pull(scratch)
self.assertEqual(carry,[]) # Consumed route inventory, not subtraction of identical sums.
np.testing.assert_allclose(moment(final),initial,rtol=0,atol=2e-14)
arrivals=final.reshape(9,-1)[q,target]
np.testing.assert_array_equal(arrivals.view(np.uint32),tags.view(np.uint32))
for shifted in (False,True):
run(shifted)
for mutation in ('drop','duplicate','early'):
with self.assertRaises(AssertionError): run(shifted,mutation)
for direction,slot,_ in p.transit:
self.assertNotIn((direction,slot),{tuple(row[:2]) for row in p.outgoing[0]})
for change in (lambda b:setattr(b,'transit',b.transit[1:]),
lambda b:setattr(b,'transit',np.vstack((b.transit,b.transit[0]))),
lambda b:b.transit.__setitem__((0,2),b.transit[0,2]+1),
lambda b:b.transit.__setitem__((0,0),1)):
bad=copy.deepcopy(p); change(bad)
with self.assertRaises(ValueError): validate_ownership(bad)
bad=copy.deepcopy(p); direction,slot,_=p.transit[0]
y,x=divmod(slot,p.nf); cy,cx=ref.cpu.CY[direction],ref.cpu.CX[direction]
covered_target=((y+2*cy)//2)*p.n+(x+2*cx)//2
bad.outgoing[0]=np.vstack((bad.outgoing[0],[direction,slot,covered_target]))
with self.assertRaises(ValueError): validate_ownership(bad)
def test_Fig13_first_entry_and_wrong_crossing(self):
p=UnionPlan(pair(CASES['concave_L'][0]))
g=next(g for g in p.groups if g.source==4*16+5 and g.q==8)
self.assertEqual(g.kind,'corner')
self.assertEqual(sorted(point[-1] for point in g.points),[-.25,-.25,-.25,.25])
# For a concave T first entry is min, not the frozen convex max.
damaged=copy.deepcopy(p); wrong=next(x for x in damaged.groups if x.source==g.source and x.q==g.q)
origin=np.array((4,5)); c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); points=[]
for child in ((0,0),(0,1),(1,0),(1,1)):
position=origin+(np.array(child)+.5)/2
time=max((origin+ (c>0)-position)/c); offset=position+c*time-(origin+.5+c*.5)
points.append((float(offset[1]),float(offset[0]),float(time-.5)))
wrong.points=tuple(points)
with self.assertRaises(ValueError): validate_ownership(damaged)
def test_all_511_small_masks(self):
counts=dict(masks=0,with_transit=0,transit_paths=0)
for bits in range(1,512):
rectangles=[(4+x,4+y,5+x,5+y) for y in range(3) for x in range(3) if bits & (1<<(3*y+x))]
p=UnionPlan(pair(rectangles)); counts['masks']+=1
counts['with_transit']+=bool(len(p.transit)); counts['transit_paths']+=len(p.transit)
# Independent first-entry check: fine-cell membership before/after
# each quarter crossing, without the plan's plane min/max helper.
for g in p.groups:
c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); origin=np.array(divmod(g.source,p.n))
for k,child in enumerate(((0,0),(0,1),(1,0),(1,1))):
start=2*origin+child; hits=[]
for t4 in (1,3):
before=(4*start+2+c*(2*t4-1))//4
after=(4*start+2+c*(2*t4+1))//4
if not p.maps.fine_active[tuple(before)] and p.maps.fine_active[tuple(after)]: hits.append(t4/4)
if hits: self.assertEqual(g.points[k][-1]+.5,hits[0])
self.assertEqual(counts,dict(masks=511,with_transit=384,transit_paths=2048)); ENUMERATION.update(counts)
def test_ordered_provenance_and_packet_mutations_fail(self):
baseline=UnionPlan(pair(CASES['two_separated'][0]))
def reject(change):
bad=copy.deepcopy(baseline); change(bad)
with self.assertRaises((ValueError,KeyError)): validate_ownership(bad)
reject(lambda p:setattr(p.groups[0],'source',p.groups[0].source+1))
reject(lambda p:setattr(p.groups[0],'samples',p.groups[0].samples+1))
reject(lambda p:setattr(p.groups[0],'events',tuple(reversed(p.groups[0].events))))
reject(lambda p:setattr(p.groups[0],'receiver',p.groups[0].receiver+1))
reject(lambda p:p.outgoing.__setitem__(0,p.outgoing[0][1:]))
reject(lambda p:p.outgoing.__setitem__(1,np.vstack((p.outgoing[1],p.outgoing[1][0]))))
reject(lambda p:setattr(p,'receivers',p.receivers[1:]))
def balanced(p):
p.groups[0].weights[:2,:2]+=1e-5*np.array([[1,-1],[-1,1]])
reject(balanced) # Preserves row/column sums, corrupts the actual operator.
def test_coefficients_and_directional_transfer_budget(self):
rng=np.random.default_rng(31)
for name in CASES:
p=UnionPlan(pair(CASES[name][0])); total=np.zeros(9)
receiver={tuple(row):[] for row in p.receivers}; first_leftover=None
for g in p.groups:
samples,a,e,coeff=ref.interpolation(g.kind,g.points)
np.testing.assert_allclose(g.weights,exact_weights(g.kind,g.points),rtol=0,atol=2e-14)
np.testing.assert_allclose(g.weights@np.ones(len(samples)),1,rtol=0,atol=2e-14)
if g.kind=='face': beta=np.array([1.,.03,.02,0.])
else:
beta=np.array([1.,.03,.02,-ref.cpu.CX[g.q]*.03-ref.cpu.CY[g.q]*.02])
self.assertLess(abs((coeff@(a@np.array([1.,.03,.02,0.])))[3]),2e-14)
np.testing.assert_allclose(g.weights@(a@beta),e@beta,rtol=0,atol=2e-14)
values=rng.normal(size=len(samples)); h=g.weights@values
origin=values[0 if g.kind=='corner' else 1]
# Fine incoming plus coarse leftovers must exactly split one
# coarse directional source budget, regardless of sample values.
incoming=sum(h[k] for k,event in enumerate(g.events) if event is not None)
leftover=sum(h[k] for k,event in enumerate(g.events) if event is None)
self.assertLess(abs(.25*(incoming+leftover)-origin),2e-14)
total[g.q]+=.25*incoming-origin
for k,event in enumerate(g.events):
if event is None:
receiver[(g.q,g.receiver)].append(h[k])
if first_leftover is None: first_leftover=(g.q,h[k])
# Tagged collision-free boundary-flux ledger, not a solver trajectory:
# every outgoing fine debit is credited to its actual receiver.
for rows in p.outgoing:
for q,slot,target in rows:
value=float(rng.normal()); total[q]-=.25*value
receiver[(q,target)].append(value)
for (q,target),values in receiver.items():
self.assertEqual(len(values),4); total[q]+=.25*sum(values)
np.testing.assert_allclose(total,0,rtol=0,atol=2e-14)
damaged=total.copy(); q,value=first_leftover; damaged[q]-=.25*value
self.assertGreater(float(abs(damaged).max()),1e-5)
self.assertEqual(validate_ownership(p)['topology'],'supported')
def test_existing_block_addressing_and_sparse_holes(self):
geometry=pair(CASES['two_separated'][0]); p=UnionPlan(geometry)
layouts=(BlockLayout2D.build(16,16,4,4),geometry.fine_layout((4,4)))
for fine,layout in enumerate(layouts):
cells=layout.shape[0]*layout.shape[1]
dense=np.arange(9*cells,dtype=np.float64).reshape(9,*layout.shape)
blocked=layout.to_blocked(dense).ravel()
refs=[]
if not fine:
refs=[(g.q,int(k)) for g in p.groups for k in [g.source,g.receiver,*g.samples]]
else:
refs=[(g.q,slot) for g in p.groups for event in g.events if event is not None for _,slot in [event]]
refs += [(int(q),int(slot)) for rows in p.outgoing for q,slot,_ in rows]
q,k=np.array(refs).T
np.testing.assert_array_equal(blocked[owned_slots(layout,q,k)],dense.reshape(9,-1)[q,k])
holes=np.flatnonzero(~layouts[1].owned)
self.assertGreater(len(holes),0)
with self.assertRaises(UnresolvedTopology): owned_slots(layouts[1],np.array([1]),holes[:1])
def test_deferred_layouts_and_phase_are_explicit(self):
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(pair([(3,3,8,8)],16,12))
self.assertEqual(caught.exception.code,'requires_level_wide_square_pair')
cfg=LBMConfig(nx=16,ny=16,viscosity=.0192,velocity=0.)
scalar=FixedPatch2D.from_config(cfg,(4,4,8,8))
with self.assertRaises(UnresolvedTopology): UnionPlan(scalar)
self.assertEqual(UnionPlan.phase,'arrival_f'); self.assertFalse(UnionPlan.runtime_supported)
# Restriction and collision do not commute, even at omega=1. This is
# why postcollision G storage cannot be relabeled/inverted into this F contract.
w=ref.cpu.W; cx,cy=ref.cpu.CX,ref.cpu.CY
def equilibrium(u):
cu=cx*u;return w*(1+3*cu+4.5*cu*cu-1.5*u*u)
children=np.array([equilibrium(u) for u in (.03,-.03,.03,-.03)])
self.assertAlmostEqual(float(abs(w-children.mean(axis=0)).max()),.0006,places=14)
if __name__=='__main__':
result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(UnionContract))
print(json.dumps(dict(passed=result.wasSuccessful(),tests=result.testsRun,
scope='host-only topology/provenance/coefficient checks; no runtime or accuracy admission',matrix=MATRIX,
enumeration=ENUMERATION),indent=2))
sys.exit(not result.wasSuccessful())
+5 -5
View File
@@ -62,6 +62,7 @@ def decode_macros(data, w, cx, cy):
EXPERIMENTS = { EXPERIMENTS = {
"historical": "source_pins.json", "historical": "source_pins.json",
"dev8403956": "source_pins_dev8403956.json", "dev8403956": "source_pins_dev8403956.json",
"a135252": "source_pins_a135252.json",
} }
@@ -69,11 +70,10 @@ def source_guard(root, variant, experiment="historical"):
manifest_path = HERE / "data/les_flow" / EXPERIMENTS[experiment] manifest_path = HERE / "data/les_flow" / EXPERIMENTS[experiment]
manifest = json.loads(manifest_path.read_text()) manifest = json.loads(manifest_path.read_text())
for name, sha in manifest["sources"].items(): for name, sha in manifest["sources"].items():
expected = ( expected = sha
manifest["candidate_header_sha256"] if name == HEADER:
if name == HEADER and variant == "corrected" expected = (manifest["candidate_header_sha256"] if variant == "corrected"
else sha else manifest.get("baseline_header_sha256", sha))
)
if hashlib.sha256((root / name).read_bytes()).hexdigest() != expected: if hashlib.sha256((root / name).read_bytes()).hexdigest() != expected:
raise RuntimeError("Source pin mismatch: " + name) raise RuntimeError("Source pin mismatch: " + name)
identity = dict( identity = dict(
@@ -0,0 +1,208 @@
"""New-source four-case repeat of 11901a7; frozen oracle and gates are unchanged."""
import argparse
import json
import math
from pathlib import Path
from assess import BASELINE_FACTOR, FLOORS
from oracle import LIMITS, TIMES, U, rectangles
from run_case import (INITIALIZATION, sample_steps, NUMERICAL_REVISION, SOURCE_SHA256,
BUILD_CAPTURE_SHA256, REFERENCE_HARNESS)
# Host-generated snapshots of a135252 at the frozen two local configurations.
# These describe expected inputs; actual sidecars are captured before cleanup.
BUILD_SOURCES = {
128: "803177416b7599ab89cc5afd35cf4a28a673b2f4bb8e8a6a1abc0b6d77610873",
256: "17ec5e60f87b840d3d13b8778bc8a3aecf485469c5a061af46c5cc19b73396cf",
}
CASES = ((100, 128, 1, "constant"), (100, 256, 1, "constant"),
(100, 128, 2, "constant"), (100, 128, 2, "projected_linear"))
NUMERICS = dict(lattice_model="D2Q9", collision="SRT", store_precision="FP32",
ddf_shifting=True, streaming="double_buffer", les_enabled=False,
boundary="periodic", block_shape=[8, 8],
sample_phase="postcollision G at root synchronization")
def finite(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def finite_tree(value):
if isinstance(value, dict):
return all(finite_tree(v) for v in value.values())
if isinstance(value, list):
return all(finite_tree(v) for v in value)
return not isinstance(value, float) or math.isfinite(value)
def at_steps(rows, steps, n):
return len(rows) == len(steps) and all(
type(r.get("step")) is int and r["step"] == step and finite(r.get("tstar"))
and abs(r["tstar"] - step * U / n) <= 3e-17
for r, step in zip(rows, steps)
)
def maximum(rows, metric):
values = [r.get(metric) for r in rows]
return max(values) if values and all(finite(v) and v >= 0 for v in values) else None
def valid_builds(record, n, depth):
builds = record.get("actual_build_sidecars", [])
if not isinstance(builds, list) or len(builds) != depth:
return False
for level, build in enumerate(builds):
if not isinstance(build, dict):
return False
size = n * 2**level
cfg = build.get("config", {})
expected = dict(nx=10, ny=10, nz=1, lattice_model="D2Q9", collision="SRT",
store_precision="FP32", streaming="double_buffer", ddf_shifting=True,
les_enabled=False, velocity=0.0, viscosity=U*size/100, rho=1.0,
omega=1/(.5+3*U*size/100), threads_per_block=256)
ptx = build.get("ptx_sha256", "")
if (build.get("source_sha256") != BUILD_SOURCES[size]
or build.get("arch") != "sm_70" or build.get("flags") != ["--use_fast_math"]
or build.get("n_objects") != 0 or not isinstance(cfg, dict)
or any(cfg.get(k) != value for k, value in expected.items())
or not isinstance(ptx, str) or len(ptx) != 64
or any(c not in "0123456789abcdef" for c in ptx)):
return False
return True
def assess(records, expected_revision=None):
indexed, duplicates = {}, []
for record in records:
key = tuple(record.get(k) for k in ("re_L", "n", "depth", "explosion"))
if key in indexed:
duplicates.append(str(key))
else:
indexed[key] = record
missing = [str(k) for k in CASES if k not in indexed]
checks = dict(complete_matrix=not missing, no_extra_cases=set(indexed) <= set(CASES),
no_duplicate_cases=not duplicates, finite_receipts=finite_tree(records))
sources = [r.get("source", {}) for r in records]
checks["same_source_and_harness"] = bool(sources) and len({
json.dumps(s, sort_keys=True) for s in sources}) == 1
checks["clean_a135252_numerical_source"] = bool(sources) and all(
s.get("dirty") is False and s.get("source_tree_sha256") == SOURCE_SHA256
and bool(s.get("revision")) and bool(s.get("harness_sha256"))
and s.get("build_capture_sha256") == BUILD_CAPTURE_SHA256
and (expected_revision is None or s.get("revision") == expected_revision) for s in sources)
runtimes = [r.get("runtime_identity") for r in records]
checks["same_recorded_runtime"] = bool(runtimes) and all(
isinstance(v, dict) and all(v.get(k) for k in
("device", "nvcc_path", "nvcc_version", "python", "numpy", "pycuda"))
and isinstance(v["device"], dict) and v["device"].get("capability") == [7, 0]
and all(v["device"].get(k) for k in ("name", "pci_bus_id", "driver", "runtime"))
for v in runtimes
) and len({json.dumps(v, sort_keys=True) for v in runtimes}) == 1
devices = [r.get("cuda_visible_devices") for r in records]
checks["same_explicit_device"] = bool(devices) and all(
isinstance(d, str) and d and "," not in d and d != "-1" for d in devices
) and len(set(devices)) == 1
enabled = [r.get("early_pressure", {}).get("enabled") for r in records]
checks["same_early_sampling"] = bool(enabled) and all(type(v) is bool for v in enabled) and len(set(enabled)) == 1
shared_valid = all(checks.values())
complete, case_valid, early = {}, {}, []
for key in CASES:
if key not in indexed:
continue
r = indexed[key]
_, n, depth, scheme = key
label = f"{n}/{depth}/{scheme}"
rows = r.get("rows", [])
main_steps, early_steps = sample_steps(n, True)
complete[key] = r.get("completed") is True and at_steps(rows, main_steps, n)
checks[label + " complete_times"] = complete[key]
checks[label + " scales"] = (
r.get("U") == U and finite(r.get("viscosity"))
and abs(r["viscosity"] - U * n / 100) < 1e-15
and r.get("tau_by_level") == [0.5 + 3 * U * n / 100 * 2**l for l in range(depth)]
and r.get("mach") == U * math.sqrt(3)
)
checks[label + " actual_builds"] = valid_builds(r, n, depth)
checks[label + " reference"] = (r.get("numerical_revision") == NUMERICAL_REVISION
and r.get("reference_harness") == REFERENCE_HARNESS)
checks[label + " numerics"] = r.get("numerics") == NUMERICS and r.get("initialization") == INITIALIZATION
runtime = r.get("runtime_source", {})
package = Path(runtime.get("checkout", "")) / "src/CelerisLab"
checks[label + " runtime_source"] = bool(runtime.get("checkout")) and all(
runtime.get(k) == str(package / tail) for k, tail in
(("module", "__init__.py"), ("compiler", "cuda/compiler_v2.py"), ("kernel_root", "lbm/kernels")))
expected_patch = [] if depth == 1 else rectangles(n, depth)
checks[label + " patch"] = json.dumps(r.get("refinements")) == json.dumps(expected_patch)
cells = [n*n] if depth == 1 else [8704, 30720]
hops = [16*n * 2**l for l in range(depth)]
work = r.get("workload", {})
checks[label + " workload"] = (
work.get("active_cells_by_level") == cells and work.get("level_hops") == hops
and work.get("root_intervals") == 16*n
and work.get("active_collision_updates_by_level") == [c*h for c, h in zip(cells, hops)]
and work.get("active_collision_updates") == sum(c*h for c, h in zip(cells, hops))
)
checks[label + " absolute"] = bool(rows) and all(
finite(row.get(metric)) and 0 <= row[metric] <= limit
for row in rows for metric, limit in LIMITS.items())
trace = r.get("early_pressure", {})
trace_rows = trace.get("rows", [])
peak = maximum(trace_rows, "pressure_l2")
trace_complete = at_steps(trace_rows, early_steps, n) and peak is not None
checks[label + " early_trace"] = (
trace_complete and trace.get("sampled_max_pressure_l2") == peak
and len(rows) >= 2 and all(trace_rows[i]["pressure_l2"] == rows[j].get("pressure_l2")
for i, j in ((0, 0), (-1, 1)))
if trace.get("enabled") is True else
trace.get("enabled") is False and trace_rows == [] and trace.get("sampled_max_pressure_l2") is None
)
early.append(dict(n=n, depth=depth, explosion=scheme, enabled=trace.get("enabled"),
samples=len(trace_rows), complete=trace_complete,
sampled_max_pressure_l2=peak))
case_valid[key] = all(value for name, value in checks.items() if name.startswith(label + " "))
def worst(key, metric):
return maximum(indexed[key]["rows"][1:], metric) if complete.get(key) else None
comparisons, scheme_results = [], {}
for scheme in ("constant", "projected_linear"):
key = (100, 128, 2, scheme)
for metric, floor in FLOORS.items():
amr, coarse, fine = (worst(k, metric) for k in (key, CASES[0], CASES[1]))
budget = None if coarse is None else max(BASELINE_FACTOR * coarse, floor)
budget = budget if finite(budget) else None
ratio = None if amr is None or fine is None else amr / max(fine, 1e-30)
passed = amr is not None and budget is not None and amr <= budget
checks[f"baseline {scheme}/{metric}"] = passed
comparisons.append(dict(explosion=scheme, metric=metric, amr=amr,
uniform128=coarse, baseline_budget=budget, passed=passed,
uniform256=fine, amr_to_uniform256=ratio if finite(ratio) else None))
baseline_passed = all(checks[f"baseline {scheme}/{metric}"] for metric in FLOORS)
valid = shared_valid and all(case_valid.get(k, False) for k in (CASES[0], CASES[1], key))
scheme_results[scheme] = dict(passed=valid and baseline_passed,
valid_case_and_controls=valid, baseline_passed=baseline_passed)
return dict(passed=all(checks.values()), missing=missing, duplicates=duplicates, checks=checks,
comparisons=comparisons, scheme_results=scheme_results, early_pressure=early,
numerical_revision=NUMERICAL_REVISION, source_tree_sha256=SOURCE_SHA256,
reference_harness=REFERENCE_HARNESS, expected_revision=expected_revision,
floors=FLOORS, baseline_factor=BASELINE_FACTOR,
main_statistic="maximum of four post-initial samples at t*=.12,.24,.36,.48",
early_statistic="sampled pressure maximum only, t*=k*.001875, k=0..64; not an all-step or continuous maximum",
finest_control="uniform256 and AMR/ uniform256 ratios are descriptive, not acceptance gates",
scope="2D periodic Re100 smooth-decay diagnostic; no new algorithm, convergence-order or general AMR qualification")
if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("files", type=Path, nargs="+")
p.add_argument("--output", type=Path, required=True)
p.add_argument("--expected-revision", required=True)
args = p.parse_args()
if args.output.exists():
p.error("Output already exists; choose a new path to retain prior evidence")
result = assess([json.loads(path.read_text()) for path in args.files], args.expected_revision)
args.output.write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
print(json.dumps(result, indent=2, allow_nan=False))
raise SystemExit(0 if result["passed"] else 1)
+134 -21
View File
@@ -1,9 +1,10 @@
"""One bounded GPU case. No production edits, plots, checkpoints or field dumps.""" """One bounded GPU case, with explicit opt-in experimental explosion."""
import argparse import argparse
import hashlib import hashlib
import json import json
import os import os
import shutil
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
@@ -12,6 +13,35 @@ import time
import numpy as np import numpy as np
from oracle import U, TIMES, LIMITS, initial, metrics, rectangles from oracle import U, TIMES, LIMITS, initial, metrics, rectangles
NUMERICAL_REVISION = "a135252ae78b47b57d7e77d19001700b3bedac8b"
SOURCE_SHA256 = "a5dbfd804befce868067d79a23e5fd47dce7f505b3f9b1bb2145fb57cb5f2178"
BUILD_CAPTURE_SHA256 = "3dbaa6547cb0a4cb1541054f3b64ad8b6e18cc70527ac6166e56b944e9bd464a"
REFERENCE_HARNESS = "11901a7f77557a901e017a171eeae1758d584fa5"
INITIALIZATION = "physical FP64 equilibrium G at actual cell centers; rho=1+3p"
def sample_steps(n, early_pressure=False):
"""Root synchronization steps; early ladder matches the 707afe6 diagnostic."""
main = [round(tstar * n / U) for tstar in TIMES]
early = list(range(0, 4 * n + 1, n // 16)) if early_pressure else []
return main, early
def runtime_source(repo):
"""Fail before CUDA if Python or the compiler snapshot comes from another tree."""
import CelerisLab
from CelerisLab.cuda import compiler_v2
paths = dict(module=Path(CelerisLab.__file__).resolve(),
compiler=Path(compiler_v2.__file__).resolve(),
kernel_root=Path(compiler_v2.kernel_path("blocks.cu")).resolve().parent)
package = repo / "src/CelerisLab"
expected = dict(module=package / "__init__.py", compiler=package / "cuda/compiler_v2.py",
kernel_root=package / "lbm/kernels")
if paths != expected:
raise RuntimeError("Wrong imported source or CUDA source/include root")
return dict(checkout=str(repo), **{key: str(path) for key, path in paths.items()})
def provenance(repo): def provenance(repo):
git = lambda *a: subprocess.check_output( git = lambda *a: subprocess.check_output(
@@ -34,6 +64,46 @@ def provenance(repo):
) )
def verify_source(repo, revision):
"""Exact clean harness commit, fixed production tree, and reviewed capture helper."""
import oracle
from tests.validation.multigrid import check_projected_pairs as gate
harness = repo / "tests/validation/taylor_green"
if (Path(__file__).resolve() != harness / "run_case.py"
or Path(oracle.__file__).resolve() != harness / "oracle.py"):
raise RuntimeError("Wrong runner or oracle checkout")
helper = repo / "tests/validation/multigrid/check_projected_pairs.py"
if (Path(gate.__file__).resolve() != helper
or hashlib.sha256(helper.read_bytes()).hexdigest() != BUILD_CAPTURE_SHA256):
raise RuntimeError("Wrong build-capture helper")
if gate.preflight(repo, revision, "candidate") != NUMERICAL_REVISION:
raise RuntimeError("Wrong production revision")
result = provenance(repo)
if result["source_tree_sha256"] != SOURCE_SHA256:
raise RuntimeError("Wrong numerical source fingerprint")
result["build_capture_sha256"] = BUILD_CAPTURE_SHA256
return result
def build_capture(report, save):
from tests.validation.multigrid.check_projected_pairs import capture_builds
return capture_builds(report, save)
def runtime_identity(ctx):
import pycuda
import pycuda.driver as cuda
nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc"
return dict(device=dict(name=ctx.device.name(), pci_bus_id=ctx.device.pci_bus_id(),
capability=list(ctx.device.compute_capability()),
driver=cuda.get_driver_version(), runtime=list(cuda.get_version())),
nvcc_path=str(Path(nvcc).resolve()),
nvcc_version=subprocess.check_output([nvcc, "--version"], text=True).strip(),
python=sys.version, numpy=np.__version__, pycuda=pycuda.VERSION_TEXT)
def config(n, re): def config(n, re):
from CelerisLab.config import LBMConfig from CelerisLab.config import LBMConfig
@@ -71,19 +141,31 @@ def workload(masks, base_steps):
def main(): def main():
p = argparse.ArgumentParser(description=__doc__) p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--repo", type=Path, required=True) p.add_argument("--repo", type=Path, required=True)
p.add_argument("--expected-revision", required=True)
p.add_argument("--run-gpu", action="store_true")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--re", type=int, choices=[100, 1000], required=True) p.add_argument("--re", type=int, choices=[100, 1000], required=True)
p.add_argument("--n", type=int, choices=[32, 64, 128, 256], required=True) p.add_argument("--n", type=int, choices=[32, 64, 128, 256], required=True)
p.add_argument("--depth", type=int, choices=[1, 2, 3], required=True) p.add_argument("--depth", type=int, choices=[1, 2, 3], required=True)
p.add_argument("--output", type=Path, required=True) p.add_argument("--output", type=Path, required=True)
p.add_argument("--explosion", choices=["constant", "projected_linear"], default="constant")
p.add_argument("--early-pressure", action="store_true",
help="also save 65 scalar pressure samples at t*=k*.001875, k=0..64")
args = p.parse_args() args = p.parse_args()
if not os.environ.get("CUDA_VISIBLE_DEVICES"): if args.output.exists():
p.error("Explicit physical CUDA_VISIBLE_DEVICES selection required") p.error("Output already exists; choose a new path to retain prior evidence")
if args.explosion != "constant" and args.depth != 2:
p.error("projected_linear requires depth 2")
selected = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if not args.dry_run and (not args.run_gpu or not selected or "," in selected or selected == "-1"):
p.error("--run-gpu and exactly one explicit CUDA_VISIBLE_DEVICES are required")
repo = args.repo.resolve() repo = args.repo.resolve()
sys.path.insert(0, str(repo / "src")) sys.path[:0] = [str(repo / "src"), str(repo)]
import CelerisLab source = verify_source(repo, args.expected_revision)
runtime = runtime_source(repo)
if Path(CelerisLab.__file__).resolve() != repo / "src/CelerisLab/__init__.py": if args.dry_run:
raise RuntimeError("Wrong imported source checkout") print(json.dumps(dict(source=source, runtime_source=runtime, dry_run=True)))
return 0
from CelerisLab.cuda.context import CudaContext from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.blocks import BlockLayout2D from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.block_stepper import PeriodicBlockStepper from CelerisLab.lbm.block_stepper import PeriodicBlockStepper
@@ -96,9 +178,17 @@ def main():
h = build_hierarchy(cfg, refinements) h = build_hierarchy(cfg, refinements)
np.testing.assert_array_equal(leaf_partition(h), np.ones((args.n, args.n))) np.testing.assert_array_equal(leaf_partition(h), np.ones((args.n, args.n)))
masks = [k == ACTIVE for k in h.kinds] masks = [k == ACTIVE for k in h.kinds]
main_steps, early_steps = sample_steps(args.n, args.early_pressure)
report = dict( report = dict(
schema=1, schema=1,
source=provenance(repo), source=source,
numerical_revision=NUMERICAL_REVISION,
reference_harness=REFERENCE_HARNESS,
actual_build_sidecars=[],
runtime_identity=None,
runtime_source=runtime,
historical_baseline_revision="9805383f9c50d867e8555471baf5dbda8333a652",
explosion=args.explosion,
n=args.n, n=args.n,
re_L=args.re, re_L=args.re,
depth=args.depth, depth=args.depth,
@@ -107,21 +197,35 @@ def main():
mach=U * np.sqrt(3), mach=U * np.sqrt(3),
viscosity=cfg.viscosity, viscosity=cfg.viscosity,
tau_by_level=[0.5 + 3 * c.viscosity for c in h.configs], tau_by_level=[0.5 + 3 * c.viscosity for c in h.configs],
initialization="physical FP64 equilibrium G at actual cell centers; rho=1+3p", initialization=INITIALIZATION,
cuda_visible_devices=os.environ["CUDA_VISIBLE_DEVICES"], numerics=dict(lattice_model=cfg.lattice_model, collision=cfg.collision,
store_precision=cfg.store_precision, ddf_shifting=cfg.ddf_shifting,
streaming=cfg.streaming, les_enabled=cfg.les_enabled,
boundary="periodic", block_shape=[8, 8],
sample_phase="postcollision G at root synchronization"),
cuda_visible_devices=selected,
device_id=0, device_id=0,
limits=LIMITS, limits=LIMITS,
workload=workload(masks, round(TIMES[-1] * args.n / U)), workload=workload(masks, round(TIMES[-1] * args.n / U)),
rows=[], rows=[],
early_pressure=dict(enabled=args.early_pressure, rows=[],
sampled_max_pressure_l2=None),
completed=False, completed=False,
) )
args.output.parent.mkdir(parents=True, exist_ok=True) args.output.parent.mkdir(parents=True, exist_ok=True)
save = lambda: args.output.write_text( def save():
json.dumps(report, indent=2, allow_nan=False) + "\n" temporary = args.output.with_suffix(args.output.suffix + ".tmp")
) text = json.dumps(report, indent=2, allow_nan=False) + "\n"
if len(text.encode()) > 1024 * 1024:
raise RuntimeError("Scalar receipt exceeds 1 MiB cap")
temporary.write_text(text)
temporary.replace(args.output)
save() save()
started = time.monotonic() started = time.monotonic()
with CudaContext(0) as ctx: with CudaContext(0) as ctx, build_capture(report, save):
report["runtime_identity"] = runtime_identity(ctx)
save()
if args.depth == 1: if args.depth == 1:
layout = BlockLayout2D.build(args.n, args.n, 8, 8, periodic=(True, True)) layout = BlockLayout2D.build(args.n, args.n, 8, 8, periodic=(True, True))
run = PeriodicBlockStepper(cfg, layout, context=ctx) run = PeriodicBlockStepper(cfg, layout, context=ctx)
@@ -132,6 +236,7 @@ def main():
coarse_boundary="periodic", coarse_boundary="periodic",
refinements=refinements, refinements=refinements,
level_blocks=[(8, 8)] * args.depth, level_blocks=[(8, 8)] * args.depth,
explosion=args.explosion,
) )
with run: with run:
run.initialize( run.initialize(
@@ -141,24 +246,30 @@ def main():
] ]
) )
initial_mass = None initial_mass = None
for tstar in TIMES: for step in sorted(set(main_steps + early_steps)):
step = round(tstar * args.n / U)
run.step(step - run.step_count) run.step(step - run.step_count)
arrays = (run.download(),) if args.depth == 1 else run.download() arrays = (run.download(),) if args.depth == 1 else run.download()
row = metrics(arrays, masks, step, args.n, cfg.viscosity, initial_mass) row = metrics(arrays, masks, step, args.n, cfg.viscosity, initial_mass)
if not all(np.isfinite(value) for value in row.values()):
raise FloatingPointError("nonfinite sample diagnostics")
row["step"] = step row["step"] = step
if initial_mass is None: if initial_mass is None:
initial_mass = row["mass"] initial_mass = row["mass"]
report["rows"].append(row) if step in main_steps:
report["rows"].append(row)
if step in early_steps:
trace = report["early_pressure"]
trace["rows"].append({key: row[key] for key in ("step", "tstar", "pressure_l2")})
trace["sampled_max_pressure_l2"] = max(r["pressure_l2"] for r in trace["rows"])
save() save()
print( print(
json.dumps( json.dumps(
dict( dict(
event="PROGRESS", event="PROGRESS" if step in main_steps else "EARLY_PRESSURE",
n=args.n, n=args.n,
re_L=args.re, re_L=args.re,
depth=args.depth, depth=args.depth,
**row, **(row if step in main_steps else report["early_pressure"]["rows"][-1]),
) )
), ),
flush=True, flush=True,
@@ -168,7 +279,9 @@ def main():
# This is the first measured trajectory step, not discarded warmup. # This is the first measured trajectory step, not discarded warmup.
run.step(1) run.step(1)
probe = (run.download(),) if args.depth == 1 else run.download() probe = (run.download(),) if args.depth == 1 else run.download()
metrics(probe, masks, 1, args.n, cfg.viscosity, initial_mass) probe_metrics = metrics(probe, masks, 1, args.n, cfg.viscosity, initial_mass)
if not all(np.isfinite(value) for value in probe_metrics.values()):
raise FloatingPointError("nonfinite READY diagnostics")
report["ready_after_step"] = 1 report["ready_after_step"] = 1
save() save()
print( print(
@@ -0,0 +1,434 @@
"""Host geometry, receipt-corruption tests and mocked scheduling; no CUDA/flow."""
from contextlib import nullcontext, redirect_stdout
from copy import deepcopy
from dataclasses import asdict, replace
import hashlib
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import numpy as np
import run_case
from assess_projected_pairs import (BUILD_SOURCES, CASES, NUMERICS, NUMERICAL_REVISION,
SOURCE_SHA256, assess)
from oracle import LIMITS, TIMES, U, rectangles
REPO = Path(__file__).resolve().parents[3]
OLD_REVISION = "519473a61c7b319183aa54e55b9f097499dde0bf"
MOCK_REVISION = "f" * 40
RUNTIME = dict(device=dict(name="host mock", pci_bus_id="mock", capability=[7, 0],
driver=12040, runtime=[12, 4, 0]), nvcc_path="/mock/nvcc",
nvcc_version="mock nvcc", python="mock Python", numpy=np.__version__, pycuda="mock")
def source():
return dict(revision=MOCK_REVISION, dirty=False, source_tree_sha256=SOURCE_SHA256,
harness_sha256={"run_case.py": "a" * 64},
build_capture_sha256=run_case.BUILD_CAPTURE_SHA256)
def build_metadata(size):
cfg = replace(run_case.config(size, 100), nx=10, ny=10)
cfg.validate()
return dict(source_sha256=BUILD_SOURCES[size], config=asdict(cfg), n_objects=0,
arch="sm_70", flags=["--use_fast_math"], ptx_sha256="b" * 64)
def records(early=False):
from CelerisLab.lbm.hierarchy import build_hierarchy
from CelerisLab.lbm.refinement import ACTIVE
out = []
for re, n, depth, scheme in CASES:
cfg = run_case.config(n, re)
refinements = [] if depth == 1 else rectangles(n, depth)
h = build_hierarchy(cfg, refinements)
main_steps, early_steps = run_case.sample_steps(n, early)
out.append(dict(
re_L=re, n=n, depth=depth, explosion=scheme, U=U, mach=float(U*np.sqrt(3)),
viscosity=cfg.viscosity, tau_by_level=[0.5+3*c.viscosity for c in h.configs],
refinements=refinements, numerics=NUMERICS.copy(), initialization=run_case.INITIALIZATION,
source=source(), numerical_revision=NUMERICAL_REVISION,
reference_harness=run_case.REFERENCE_HARNESS,
actual_build_sidecars=[build_metadata(n * 2**level) for level in range(depth)],
runtime_identity=deepcopy(RUNTIME), cuda_visible_devices="0",
runtime_source=run_case.runtime_source(REPO), completed=True,
workload=run_case.workload([k == ACTIVE for k in h.kinds], 16*n),
rows=[dict(step=s, tstar=s*U/n, **{k: 0.0 for k in LIMITS}) for s in main_steps],
early_pressure=dict(enabled=early, rows=[
dict(step=s, tstar=s*U/n, pressure_l2=.01 if s not in main_steps else 0.0) for s in early_steps],
sampled_max_pressure_l2=.01 if early else None)))
return out
class ProjectedPairsTests(unittest.TestCase):
def test_exact_geometry_work_and_samples(self):
from CelerisLab.lbm.hierarchy import build_hierarchy, leaf_partition
data = records(True)
self.assertEqual(sum(r["workload"]["active_collision_updates"] for r in data), 589299712)
for r in data:
n, depth = r["n"], r["depth"]
h = build_hierarchy(run_case.config(n, 100), r["refinements"])
np.testing.assert_array_equal(leaf_partition(h), np.ones((n, n)))
main, early = run_case.sample_steps(n, True)
self.assertEqual(main, [0, 4*n, 8*n, 12*n, 16*n])
self.assertEqual(early, [k*(n//16) for k in range(65)])
np.testing.assert_allclose(np.array(early)*U/n, np.arange(65)*.001875, rtol=0, atol=3e-17)
if depth == 2:
self.assertEqual(r["refinements"], [[(16, 16, 112, 96)]])
self.assertTrue(assess(data)["passed"])
self.assertTrue(assess(records())["passed"])
def test_exact_old519_geometry_and_table_parity(self):
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.hierarchy import build_hierarchy
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
# Frozen from executing exact 519473a61c7b319183aa54e55b9f097499dde0bf
# geometry and table definitions, after checking unchanged config,
# descriptors and blocks. No historical Git object is needed at runtime.
# Historical source SHA256, refinement.py then refinement_tables.py:
# 08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687
# dabff948bb30496384d1a52c666b0edbc2085f9c61ed1bbc39f748502629113d
frozen = {
"covered": ("|b1", (128, 128), "14a1b5be54e3309bea825a00a653105951f0c805622b877a86dd453d65556df3"),
"fine_kind": ("|u1", (256, 256), "891ff72c3d60686c62b1f35ffe59d054ac23b8f9d59aeb5782bc941202794cc0"),
"ghost_fine": ("<u8", (2880,), "2d0311ac0feb1c0e1e2b6bcf806fe616b8217513de218407267dcd7652287579"),
"ghost_coarse": ("<u4", (2880,), "5a3b305a0f841c8d05a13f105fda02b43ef875ab1321f9bca579f2b8ae313233"),
"coalesce_coarse": ("<u8", (1060,), "a1d88c60f282153dcf2bb497c59d777968b6db773d061d8fea08163c98155967"),
"coalesce_fine": ("<u8", (1060, 4), "08914c5d17c1ec8e2bfd234f581e4b3283de26296ae7d80403385feed7b89532"),
"neighbors": ("<u8", (2880, 4), "cfef3a07103241e3dfff24fd65ab967afcf20520f699574b191044d47cf5d1f4"),
"offsets": ("<f4", (2880, 2), "c98ba658d5f2cc18605e029fbe1da448ded3fd2436edcaf8ea097fa46aeb1f2d"),
}
hierarchy = build_hierarchy(run_case.config(128, 100), rectangles(128, 2))
pair = hierarchy.pairs[0]
self.assertEqual((pair.coarse_shape, pair.fine_shape, pair.fine_origin),
((128, 128), (256, 256), (0.0, 0.0)))
self.assertEqual(len(np.unique(pair.ghost_coarse)), 720)
self.assertTrue((hierarchy.kinds[0].ravel()[pair.ghost_coarse] == 0).all())
layout = BlockLayout2D.build(128, 128, 8, 8, periodic=(True, True))
neighbors, offsets = projected_explosion_tables(pair, layout, hierarchy.kinds[0])
arrays = dict(neighbors=neighbors, offsets=offsets)
arrays.update({name: getattr(pair, name) for name in frozen if name not in arrays})
for name, array in arrays.items():
with self.subTest(array=name):
self.assertEqual((array.dtype.str, array.shape, hashlib.sha256(array.tobytes()).hexdigest()),
frozen[name])
def test_actual_build_capture_rejects_tampered_ptx_and_sidecar(self):
from CelerisLab.cuda import compiler_v2
from tests.validation.multigrid.check_projected_pairs import capture_builds
for tamper in (None, "ptx", "sidecar"):
with self.subTest(tamper=tamper), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "blocks.ptx"
path.write_bytes(b"host-only mock PTX")
metadata = dict(ptx_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
if tamper == "ptx":
path.write_bytes(b"tampered")
if tamper == "sidecar":
metadata["ptx_sha256"] = "0" * 64
Path(str(path) + ".json").write_text(json.dumps(metadata))
report = {"actual_build_sidecars": []}
with patch.object(compiler_v2, "load_module", return_value="mock-module") as load:
with capture_builds(report, lambda: None):
if tamper:
with self.assertRaisesRegex(AssertionError, "PTX and build sidecar"):
compiler_v2.load_module(str(path))
load.assert_not_called()
self.assertEqual(report["actual_build_sidecars"], [])
else:
self.assertEqual(compiler_v2.load_module(str(path)), "mock-module")
self.assertEqual(report["actual_build_sidecars"], [metadata])
self.assertIs(compiler_v2.load_module, load)
def test_frozen_formula_and_finest_control(self):
data = records(True)
data[0]["rows"][2]["velocity_l2"] = .002
data[0]["rows"][0]["velocity_l2"] = .04 # retained absolute gate, excluded from worst
for r in data[2:]:
for row in r["rows"][1:]:
row.update(velocity_l2=.0039, pressure_l2=.02, energy_relative=.004)
r["early_pressure"]["rows"][-1]["pressure_l2"] = .02
r["early_pressure"]["sampled_max_pressure_l2"] = .02
data[1]["rows"][2]["velocity_l2"] = .00001
data[3]["early_pressure"]["rows"][7]["pressure_l2"] = .9
data[3]["early_pressure"]["sampled_max_pressure_l2"] = .9
result = assess(data)
self.assertTrue(result["passed"])
velocity = result["comparisons"][0]
self.assertEqual(velocity["baseline_budget"], .004)
self.assertAlmostEqual(velocity["amr_to_uniform256"], 390)
self.assertEqual(result["early_pressure"][-1]["sampled_max_pressure_l2"], .9)
data[3]["rows"][-1]["pressure_l2"] = .02001
result = assess(data)
self.assertFalse(result["passed"])
self.assertEqual(result["comparisons"][4]["amr"], .02001)
def test_scheme_verdicts_are_independent(self):
data = records()
data[2]["rows"][-1]["pressure_l2"] = .03
result = assess(data)
self.assertFalse(result["passed"])
self.assertFalse(result["scheme_results"]["constant"]["passed"])
self.assertTrue(result["scheme_results"]["projected_linear"]["passed"])
def test_corruptions_cannot_pass(self):
good = records(True)
corruptions = [
lambda r: r.update(completed=False),
lambda r: r["rows"].pop(),
lambda r: r["rows"][-1].update(pressure_l2=float("nan")),
lambda r: r["rows"][-1].update(energy_relative=float("inf")),
lambda r: r["rows"][-1].update(pressure_l2=1e308),
lambda r: r["rows"][-1].update(velocity_l2=-1),
lambda r: r["rows"][-1].update(step=2047),
lambda r: r["rows"][-1].update(tstar=.4801),
lambda r: r["source"].update(dirty=True),
lambda r: r["source"].update(source_tree_sha256="wrong"),
lambda r: r["source"].update(revision=OLD_REVISION),
lambda r: r["source"].pop("build_capture_sha256"),
lambda r: r.update(numerical_revision=OLD_REVISION),
lambda r: r.update(reference_harness="wrong"),
lambda r: r.pop("runtime_identity"),
lambda r: r["runtime_identity"].pop("nvcc_version"),
lambda r: r["runtime_identity"].update(device="wrong"),
lambda r: r["runtime_identity"]["device"].update(capability=[8, 0]),
lambda r: r.update(cuda_visible_devices="0,1"),
lambda r: r.pop("actual_build_sidecars"),
lambda r: r["actual_build_sidecars"].pop(),
lambda r: r["actual_build_sidecars"].reverse(),
lambda r: r["actual_build_sidecars"][0].update(source_sha256="c" * 64),
lambda r: r["actual_build_sidecars"][0].update(arch="sm_80"),
lambda r: r["actual_build_sidecars"][0].update(flags=[]),
lambda r: r["actual_build_sidecars"][0].update(ptx_sha256="not-a-hash"),
lambda r: r["actual_build_sidecars"][0]["config"].update(ddf_shifting=False),
lambda r: r["source"]["harness_sha256"].update(extra="mixed"),
lambda r: r.update(viscosity=.01),
lambda r: r["tau_by_level"].reverse(),
lambda r: r["numerics"].update(ddf_shifting=False),
lambda r: r.update(initialization="different equilibrium"),
lambda r: r.update(refinements=[[(16, 16, 112, 104)]]),
lambda r: r["workload"].update(active_collision_updates=0),
lambda r: r["runtime_source"].update(kernel_root="/wrong/installed/tree"),
lambda r: r["early_pressure"]["rows"].pop(),
lambda r: r["early_pressure"]["rows"][8].update(step=65),
lambda r: r["early_pressure"]["rows"][-1].update(pressure_l2=.005),
lambda r: r["early_pressure"]["rows"][8].update(pressure_l2=float("nan")),
lambda r: r["early_pressure"].update(sampled_max_pressure_l2=.5),
lambda r: r["early_pressure"].update(enabled=False),
]
for mutation in corruptions:
bad = deepcopy(good)
mutation(bad[-1])
with self.subTest(mutation=mutation):
result = assess(bad)
self.assertFalse(result["passed"])
json.dumps(result, allow_nan=False) # failure summary itself remains serializable
self.assertTrue(assess(good, expected_revision=MOCK_REVISION)["passed"])
self.assertFalse(assess(good, expected_revision=OLD_REVISION)["passed"])
old = deepcopy(good)
for record in old:
record["source"].update(revision=OLD_REVISION,
source_tree_sha256="766e173df81d2881d3871007f15d5e321f5c7205ae578b0f80b43d1e7fe3e220")
self.assertFalse(assess(old)["passed"])
self.assertFalse(assess(good[:-1])["passed"])
self.assertFalse(assess(good + [good[-1]])["passed"])
self.assertFalse(assess([])["passed"])
incomplete = deepcopy(good)
incomplete[-1]["completed"] = False
self.assertFalse(assess(incomplete)["comparisons"][3]["passed"])
def test_pinned_build_sources_match_host_generated_inputs(self):
from CelerisLab.cuda import compiler_v2
for size in (128, 256):
cfg = replace(run_case.config(size, 100), nx=10, ny=10)
cfg.validate()
root = compiler_v2.generate_config(cfg)
try:
actual = json.loads((Path(root) / ".celeris-build").read_text())
expected = build_metadata(size)
self.assertEqual(actual, {key: expected[key] for key in
("source_sha256", "config", "n_objects")})
finally:
compiler_v2.cleanup_build_dir(root)
def test_source_preflight_requires_clean_exact_revision_and_tree(self):
import oracle
from tests.validation.multigrid import check_projected_pairs as gate
for module in (run_case, oracle):
with self.subTest(imported_module=module.__name__), \
patch.object(module, "__file__", "/wrong/checkout/" + Path(module.__file__).name), \
patch.object(gate, "preflight") as preflight:
with self.assertRaisesRegex(RuntimeError, "Wrong runner or oracle checkout"):
run_case.verify_source(REPO, MOCK_REVISION)
preflight.assert_not_called()
for expected, status, diff, fingerprint in (
(MOCK_REVISION, "", 0, SOURCE_SHA256),
(OLD_REVISION, "", 0, SOURCE_SHA256),
(MOCK_REVISION, " M tracked.py", 0, SOURCE_SHA256),
(MOCK_REVISION, "", 1, SOURCE_SHA256),
(MOCK_REVISION, "", 0, "wrong")):
provenance = dict(source(), source_tree_sha256=fingerprint)
with self.subTest(expected=expected, status=status, diff=diff, fingerprint=fingerprint), \
patch.object(gate.subprocess, "check_output", side_effect=[MOCK_REVISION, status]), \
patch.object(gate.subprocess, "call", return_value=diff), \
patch.object(run_case, "provenance", return_value=provenance):
if expected != MOCK_REVISION or status or diff or fingerprint != SOURCE_SHA256:
with self.assertRaises((ValueError, RuntimeError)):
run_case.verify_source(REPO, expected)
else:
self.assertEqual(run_case.verify_source(REPO, expected), source())
def test_explicit_opt_in_selector_and_dry_run_without_cuda(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "case.json"
base = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
"--depth", "1", "--output", str(output)]
pinned = base + ["--expected-revision", MOCK_REVISION]
for argv, device in ((base + ["--run-gpu"], "0"), (pinned, "0"),
(pinned + ["--run-gpu"], ""), (pinned + ["--run-gpu"], "0,1"),
(pinned + ["--run-gpu"], "-1"),
(pinned + ["--run-gpu", "--explosion", "projected"], "0"),
(pinned + ["--run-gpu", "--explosion", "projected_linear"], "0")):
with self.subTest(argv=argv, device=device), patch.object(sys, "argv", argv), \
patch.dict("os.environ", CUDA_VISIBLE_DEVICES=device), \
patch.object(run_case, "verify_source") as verify, patch("sys.stderr", io.StringIO()):
with self.assertRaises(SystemExit) as caught:
run_case.main()
self.assertEqual(caught.exception.code, 2)
verify.assert_not_called()
self.assertFalse(output.exists())
stdout = io.StringIO()
cuda_modules = {name: None for name in ("pycuda", "CelerisLab.cuda.context",
"CelerisLab.lbm.block_stepper", "CelerisLab.lbm.refinement_stepper")}
with patch.object(sys, "argv", pinned + ["--dry-run"]), \
patch.dict(sys.modules, cuda_modules), patch.dict("os.environ", CUDA_VISIBLE_DEVICES=""), \
patch.object(run_case, "verify_source", return_value=source()) as verify, \
patch.object(run_case, "runtime_identity", side_effect=AssertionError("CUDA reached")), \
redirect_stdout(stdout):
self.assertEqual(run_case.main(), 0)
verify.assert_called_once_with(REPO, MOCK_REVISION)
self.assertTrue(json.loads(stdout.getvalue())["dry_run"])
self.assertFalse(output.exists())
def test_runtime_rejects_installed_kernel_tree(self):
from CelerisLab.cuda import compiler_v2
with patch.object(compiler_v2, "_KERNEL_DIR", "/other/CelerisLab/lbm/kernels"):
with self.assertRaisesRegex(RuntimeError, "CUDA source/include root"):
run_case.runtime_source(REPO)
def test_existing_output_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "failed.json"
output.write_text('{"completed": false}\n')
argv = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
"--depth", "1", "--output", str(output),
"--expected-revision", MOCK_REVISION, "--run-gpu"]
with patch.object(sys, "argv", argv), patch("sys.stderr", io.StringIO()):
with self.assertRaises(SystemExit) as caught:
run_case.main()
self.assertEqual(caught.exception.code, 2)
self.assertEqual(output.read_text(), '{"completed": false}\n')
result = subprocess.run([sys.executable, str(Path(__file__).with_name("assess_projected_pairs.py")),
"unused.json", "--expected-revision", MOCK_REVISION,
"--output", str(output)], capture_output=True)
self.assertEqual(result.returncode, 2)
self.assertEqual(output.read_text(), '{"completed": false}\n')
def test_mocked_runner_default_early_and_failure(self):
# Replace the GPU context and steppers before import. No CUDA module or flow is used.
for early, depth, fail in ((False, 1, None), (False, 2, None), (True, 2, None),
(True, 2, "resource"), (True, 2, "nonfinite"),
(True, 2, "probe_nonfinite")):
calls, selectors = [], []
class FakeRun:
def __init__(self, *args, **kwargs):
self.step_count = 0
if "coarse_boundary" in kwargs:
selectors.append(kwargs["explosion"])
def __enter__(self):
return self
def __exit__(self, *args):
pass
def initialize(self, *arrays):
pass
def step(self, count):
self.assertion = count >= 0
if not self.assertion:
raise AssertionError("backward step")
if fail == "resource" and self.step_count >= 16:
raise MemoryError("mock resource failure")
self.step_count += count
calls.append(self.step_count)
def download(self):
return None
modules = {
"CelerisLab.cuda.context": SimpleNamespace(CudaContext=FakeRun),
"CelerisLab.lbm.block_stepper": SimpleNamespace(PeriodicBlockStepper=FakeRun),
"CelerisLab.lbm.refinement_stepper": SimpleNamespace(FixedPatchStepper=FakeRun),
}
def metric(arrays, masks, step, n, nu, mass):
return dict(step=step, tstar=step*U/n, mass=16384.,
**{k: float("nan") if ((fail == "nonfinite" and step == 16)
or (fail == "probe_nonfinite" and step == 1))
and k == "energy_relative" else 0.0 for k in LIMITS})
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "case.json"
argv = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
"--depth", str(depth), "--output", str(output),
"--expected-revision", MOCK_REVISION, "--run-gpu"]
if early:
argv += ["--early-pressure", "--explosion", "projected_linear"]
with patch.dict(sys.modules, modules), patch.object(sys, "argv", argv), \
patch.dict("os.environ", CUDA_VISIBLE_DEVICES="mock"), \
patch.object(run_case, "verify_source", return_value=source()) as verify, \
patch.object(run_case, "runtime_identity", return_value=deepcopy(RUNTIME)), \
patch.object(run_case, "build_capture", return_value=nullcontext()), \
patch.object(run_case, "metrics", side_effect=metric), redirect_stdout(io.StringIO()):
if fail:
with self.assertRaises(MemoryError if fail == "resource" else FloatingPointError):
run_case.main()
else:
self.assertEqual(run_case.main(), 0)
receipt = json.loads(output.read_text())
verify.assert_called_once_with(REPO, MOCK_REVISION)
self.assertEqual(selectors, ["projected_linear" if early else "constant"] if depth == 2 else [])
self.assertEqual(receipt["source"], source())
self.assertEqual(receipt["runtime_identity"], RUNTIME)
self.assertEqual(receipt["completed"], not fail)
self.assertEqual(calls[:2], [0, 1]) # READY probe is part of this trajectory
if not fail:
self.assertEqual([r["tstar"] for r in receipt["rows"]], TIMES)
self.assertEqual(len(receipt["early_pressure"]["rows"]), 65 if early else 0)
self.assertEqual(calls[-1], 2048)
else:
self.assertEqual(len(receipt["rows"]), 1)
self.assertEqual(len(receipt["early_pressure"]["rows"]),
{"resource": 3, "nonfinite": 2, "probe_nonfinite": 1}[fail])
if fail == "probe_nonfinite":
self.assertNotIn("ready_after_step", receipt)
if __name__ == "__main__":
unittest.main()