Compare commits

..
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 36ffc8b1b2 test: gate V1 side files and V3 legacy receipts; early output rejection
- analyze_jet_cv_continuation: samples are analyzed only if the sibling
  re*_s*.json receipt is the same case with run_status COMPLETE and
  source_recheck UNCHANGED; receipts without run_status (pre-recheck
  runners) are refused unless --legacy-receipts, and labelled
  LEGACY_NO_FINAL_SOURCE_RECHECK. Results carry receipt_gate.
- V3: receipts are schema 2 and need source_recheck UNCHANGED; a missing
  recheck is accepted only for frozen-mode schema 1 receipts whose
  run_case.py hash is one of the pre-recheck runners (9805383, 2cf9853,
  db3d098; hashes checked against git history in a test).
- V1/V3 refuse --output inside the checkout before source checks or CUDA
  (shared source_provenance.reject_output_inside).
- A failing recheck is recorded as RECHECK_FAILED (still invalid); the
  in-run V1 receipt write is tmp+replace.
Negative host tests for each gate. No threshold, solver or evidence edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:13:59 +00:00
ClaudeandClaude Opus 5.5 df0f586331 test: final source recheck for V1 jet CV and V3 Taylor-Green runs
Both runners now re-derive the recorded source identity after the run
(source_provenance.final_source_check) and only then mark the receipt
complete. A changed source, or a recheck that fails, rewrites the receipt
as invalid and raises:
- V1: in-run receipts carry run_status=INCOMPLETE; finish_receipt sets
  COMPLETE or INVALID_SOURCE_CHANGED (+ source_recheck/source_after).
- V3: completed stays False, invalid=SOURCE_CHANGED_DURING_RUN, no
  screening_pass; the assessor requires source_recheck UNCHANGED (frozen
  mode still accepts receipts from before the field existed).
Interrupted runs keep INCOMPLETE / completed=False. Host tests cover
unchanged, changed and failed rechecks and incomplete runs. No threshold
or solver change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:44:23 +00:00
ClaudeandClaude Opus 5.5 8cae58cdf1 test: fix V4 dirty mock, reject in-checkout output, typed blob errors
- test_dfg_re20: the dirty-checkout test now supplies all three git
  responses (HEAD:src, HEAD, status) and asserts the call order, so it
  reaches the clean-checkout rejection instead of StopIteration.
- run_dfg_re20: --output inside the source checkout is refused before
  provenance, plan writing or any CUDA work (receipts would dirty the
  recorded source); tested with the checkout root and nested paths.
- source_provenance.historical_files: validates all paths first and
  reports an unreadable blob as HistoryUnavailable, not a raw
  CalledProcessError; tested with a failing git show.

No physics thresholds changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:36:28 +00:00
ClaudeandClaude Opus 5.5 34b30c434f test: restore into the same body scene; V3 mock asserts current=False
The checkpoint loader requires matching body count and geometry, so the
round-trip test rebuilds the three-circle scene before loading instead of
an empty simulation (loader unchanged). run_case.main now calls
verify_source(..., current=False); the two dry-run/run mock assertions
follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:33:48 +00:00
ClaudeandClaude Opus 5.5 c33af7a66d test: versioned reader for DFG Re20 result.json (schema 1 and 2)
read_result() returns cases/provenance for the old bare-list receipts and
the new schema-2 object, and rejects unknown schemas. Existing evidence
(e.g. 70f6617) and its digest scripts stay valid for schema 1; queue and
digest scripts must use read_result(...)['cases'] for new receipts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:29:52 +00:00
ClaudeandClaude Opus 5.5 2220cdd157 test: DFG Re20 provenance refuses dirty trees; versioned receipts
Per review of aceea70e: a dirty checkout is refused (HEAD would not
describe the code that runs, and a dirty-to-dirty edit would pass the
end-of-run check). On a clean tree HEAD pins the imported helpers too.
plan.json and result.json carry schema=2; schema-1 receipts (bare list,
e.g. evidence 70f6617) are documented. Adds a dirty-refusal test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:28:25 +00:00
ClaudeandClaude Opus 5.5 8064834c37 test: tighten historical-pin checks per review of 941eb28b
- test_refined_dfg::test_source_pin runs its mismatch negative controls
  first (now including window mode) so missing history cannot skip them.
- test_re100: renamed to test_manifest_matches_its_pinned_commit, plus an
  explicit record that today's src differs from the pin.
- Structural manifest checks run before any history-dependent check.
- Real tamper test: a flipped digest raises via verify_manifest, and a
  path absent at the commit raises ValueError.
- A blobless clone (tree present, blob missing) is reported as unavailable
  history, not as a missing path.
- All history skips go through history_unavailable(): fatal when
  CELERIS_REQUIRE_HISTORY=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:28:09 +00:00
ClaudeandClaude Opus 5.5 f02355a2b7 test: LES a135252 guard tests use the pinned tree, not today's src
The a135252 manifest is historical qualification: it pins the committed
corrected tree at a135252. The guard is correct; the four tests used the
current working tree as the 'pinned' fixture, so 87c3a31's jets.py change
broke them. Fixtures are now materialized from a135252 with git; the
corrected acceptance, baseline rejection, four tamper rejections and the
reversed-header baseline control are unchanged. A new test records that
the guard refuses today's src where it differs from the pin.

Missing history skips with the reason, or fails when
CELERIS_REQUIRE_HISTORY=1 (release gating) via history_unavailable().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:27:13 +00:00
ClaudeandClaude Opus 5.5 f626cbcda2 test: bitwise checkpoint round trip for a three-cylinder body scene
Save -> load into a fresh Simulation must restore populations (bitwise),
flags, body and step counts, and both copies must stay bitwise identical
after 40 further steps. Jets remain non-checkpointable (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:25:24 +00:00
ClaudeandClaude Opus 5.5 db3d098f43 test: current-checkout mode for the projected-pair TG runner and assessor
run_case --current-checkout records the clean checkout (HEAD, src
fingerprint, harness hashes, pinned build-capture helper) labelled
current_checkout instead of requiring the frozen a135252 tree; receipts
carry numerical_revision=None. assess --current-checkout applies the same
gates to such receipts. Frozen mode is unchanged and now rejects any
receipt with source_mode, so the two never cross-qualify. A missing a135252
object is reported as unavailable history instead of a source diff.
check_projected_pairs.py is untouched, so its pinned hash and existing
receipts keep their meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:24:44 +00:00
ClaudeandClaude Opus 5.5 aceea70e5f test: record checkout provenance in DFG Re20 receipts
plan.json and result.json now carry HEAD, the dirty flag, the src tree
hash and the runner hash, labelled current_checkout; result.json is
refused if the checkout changed during the run. Numerics, sampling and
the frozen steady rule are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:16:21 +00:00
ClaudeandClaude Opus 5.5 941eb28b79 test: check pinned source manifests against their own commits
Historical-evidence pins (dfg_re100 / jet_circle_cv / refined_dfg manifests)
now compare each recorded digest with the file at the recorded commit, read
with git, instead of with today's working tree. Missing history (no git,
shallow clone, installed copy) raises HistoryUnavailable and the test is
reported as skipped with that reason, never passed. Receipt/plan logic tests
take the selected manifest directly, so their assertions run again; the
pinned modes' mismatch rejections stay as negative controls.

V1 (run_jet_circle_cv) keeps its pinned mode and gains --current-checkout:
it records HEAD, the src tree and per-file hashes labelled as current
checkout, never as the frozen qualification, and requires a clean tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:15:50 +00:00
105 changed files with 2181 additions and 2601 deletions
+36 -38
View File
@@ -112,9 +112,8 @@ from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
cfg = LBMConfig(nx=32, ny=24, viscosity=.1)
body = SimObject(0, geometry=CircleGeometry(16.0, 12.0, 2.15), center=(16.0, 12.0), radius=2.15,
surface_actuation=SurfaceActuation(0.7, 0.85, 0.002)) # angle, half-width, speed
with CudaContext(0) as ctx, FixedPatchStepper(cfg, context=ctx, refinements=[(8, 6, 24, 18)],
level_blocks=[None, (16, 20)],
level_objects=[(), (body,)]) as run:
with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
fine_objects=[body], fine_block=(16, 20)) as run:
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
# (9, ny, nx) postcollision populations per level; non-leaf cells are discarded.
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
@@ -125,8 +124,8 @@ with CudaContext(0) as ctx, FixedPatchStepper(cfg, context=ctx, refinements=[(8,
Scope and limits:
- The examples below use D2Q9, SRT, FP32, double buffer, unshifted, no LES or
forcing. Fixed 2:1 hierarchies use level lists of arbitrary depth; two- and
three-level examples below use that same constructor.
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
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)`),
@@ -134,51 +133,50 @@ Scope and limits:
- Bodies must be stationary circles whose whole boundary stencil lies in the fine
active region, clear of the coarse/fine transfer cells; violations are rejected
before any GPU allocation.
- `level_blocks=[None, (by, bx)]` stores the fine level in fixed-size blocks; `by`/`bx` must
- `fine_block=(by, bx)` stores the fine level in fixed-size blocks; `by`/`bx` must
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
infrastructure for future block AMR, not a speedup.
- Several regions: pass a sequence of `(x0, y0, x1, y1)` rectangles as
`refinements[0]`, with both levels blocked (`level_blocks`). Transfers are built once
`bounds`, with both `fine_block` and `base_block`. Transfers are built once
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
grid-aligned tiles intersecting the union plus its four-cell ghost band are stored.
Block sizes must divide the level-wide grid; rectangle edges need not align
with tiles. Order of rectangles does not matter. L1 bodies/Jets (`level_objects[1]`/`level_jets[1]`) work
with tiles. Order of rectangles does not matter. `fine_objects`/`jets` work
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
different bodies, even across regions. Wall-touching patches remain unsupported.
For nesting, add further `refinements` entries as shown below.
For nesting, use the level lists or legacy child forms below.
```python
cfg = LBMConfig(nx=32, ny=16, viscosity=.1)
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, refinements=[((4, 4, 12, 12), (16, 4, 24, 12))],
level_blocks=[(4, 4), (4, 4)]) as run:
cfg, ((4, 4, 12, 12), (16, 4, 24, 12)), context=ctx,
fine_block=(4, 4), base_block=(4, 4)) as run:
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
np.broadcast_to(rest, (9, *run.patch.fine_shape))) # (9, 32, 64)
run.step(32)
coarse_g, fine_g = run.download() # coarse: NaN on every covered cell;
# fine: NaN on every non-ACTIVE cell
```
- Three-level nested windows: pass two scalar rectangles in `refinements` —
L1 in L0 cells, L2 in **L1 window cells** (the parent's fine grid including its
4-cell ghost band) — plus all three `level_blocks` entries. L2 must sit inside
L1's ACTIVE cells, away from L1's ghost band and transfer footprint (checked
before any GPU allocation). `initialize` and `download` take/return three
arrays; each level is NaN on its non-ACTIVE (and child-covered) cells. One
coarse step = 2 L1 steps = 4 L2 steps. One L1 window only in this scalar
chain. Optional stationary circles and Jets live on L2 via `level_objects[2]`
and `level_jets[2]`; L1 bodies/Jets retain their L1 meaning and must be empty
in this mode. All object and Jet geometry uses the **L0 edge frame**, not the
child bounds' L1 window frame. L2 has dx=dt=0.25, so prescribed lattice
velocity is unchanged. Each whole body and its BFL stencil must be inside L2
ACTIVE cells, outside the transfer footprint. A Jet may select one physical
body across multiple blocks, never two bodies.
- 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
`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
any GPU allocation). `initialize` and `download` take/return three arrays; each
level is NaN on its non-ACTIVE (and child-covered) cells. One coarse step = 2 L1
steps = 4 L2 steps. One L1 patch only. Optional stationary circles and Jets
live on L2 via `child_objects` and `child_jets`; `fine_objects`/`jets` retain
their L1 meaning and must be empty in this mode. All object and Jet geometry
uses the **L0 edge frame**, not the child bounds' L1 window frame. L2 has
dx=dt=0.25, so prescribed lattice velocity is unchanged. Each whole body and
its BFL stencil must be inside L2 ACTIVE cells, outside the transfer footprint.
A Jet may select one physical body across multiple blocks, never two bodies.
```python
from CelerisLab.body.geometry.circle import CircleGeometry
@@ -190,9 +188,9 @@ Scope and limits:
jet = Jet(Tube((6.5, 8.), 0., 3., .3), Parallel(0.), amplitude=.002)
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, refinements=[(4, 4, 12, 12), (8, 8, 16, 16)],
level_blocks=[(4, 4)] * 3, level_objects=[(), (), (body,)],
level_jets=[(), (), (jet,)]) as run:
cfg, (4, 4, 12, 12), context=ctx, fine_block=(4, 4), base_block=(4, 4),
child=(8, 8, 16, 16), child_block=(4, 4),
child_objects=[body], child_jets=[jet]) as run:
shapes = (run.patch.coarse_shape, run.patch.fine_shape, run.child_patch.fine_shape)
run.initialize(*(np.broadcast_to(rest, (9, *s)) for s in shapes)) # 16², 24², 24²
run.step(4)
@@ -200,16 +198,16 @@ Scope and limits:
run.step(4)
coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN
```
- Level lists (the only constructor): `refinements[l]` describes level `l+1`
in allocated level-`l` integer cells; use a rectangle list for a union, or a
scalar rectangle for a single window (a scalar child requires a scalar
parent). `level_blocks`, `level_objects` and `level_jets` each have one entry
per level, including L0. Three or more levels require all-blocked storage and
bodies/Jets only on the finest level. Every adjacent pair must preserve
inherited ACTIVE coverage, valid 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.
- Level lists: `refinements[l]` describes level `l+1` in allocated level-`l`
integer cells; use a rectangle list for a union, or a scalar rectangle for a
single window (a scalar child requires a scalar parent). `level_blocks`,
`level_objects` and `level_jets` each have one entry per level, including L0.
These lists are mutually exclusive with the legacy patch keywords. Three or
more levels require all-blocked storage and bodies/Jets only on the finest
level. Every adjacent pair must preserve inherited ACTIVE coverage, valid
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,
+1 -1
View File
@@ -25,7 +25,7 @@ under the alternate compile branch.
- No LES, no external forcing (`d_params` force fields stay zero).
- Shifted Jets stay SRT-only: `body/jets.py` keeps its existing guard. This extension does not
provide shifted MRT Jets.
- `PeriodicBlockStepper` (uniform blocked stepper) remained SRT-only; it was removed in 8e1feff and its single-level capability, still SRT-only, is `FixedPatchStepper(refinements=[])`.
- `PeriodicBlockStepper` (uniform blocked stepper) remains SRT-only.
## Evidence status
CPU: `tests/unit/test_refinement_mrt.py` (host guards, source contract). GPU:
+1 -1
View File
@@ -1,6 +1,6 @@
# Blocked AMR root channel increment
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", refinements=[bounds], level_blocks=[(by,bx), (fy,fx)])`. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", base_block=(by,bx), fine_block=(fy,fx))`, or the existing level-list form. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
`step/block_channel.cu` is a small root-only kernel. Fine kernels, transfer kernels and recursive hop scheduling are unchanged. Global coordinates and the global profile height/velocity are separate from block-local storage strides. Corner ownership and collision phases match `OneStep`:
-393
View File
@@ -1,393 +0,0 @@
# Evidence index (draft, R0)
Active-tree evidence at candidate 0f8fdbc30ea640c764b38ee598d8f4d135c81c85, classified for the release cleanup
(thread C0C78UFKGRM 1793021833.593279). Draft for review: nothing is removed by this file.
Recovery of any listed file: `git show 0f8fdbc3:<path>` (blob id given; an immutable tag is requested as anchor).
Classes: K = conclusion kept in the active tree; K-code = tested research code kept; D-snapshot = pre-Gitea
patch/diff transport (code landed in git from ed1a6ee on; not yet verified hunk by hunk); P-pin = generated hash inventories, git-status dumps and manifests;
R-raw = run outputs, logs, signals, frames, one-shot run scripts and handoff notes. D, P, R leave the active tree in R1.
No file under src/ or a test reads any of these paths (git grep at 0f8fdbc3); runners under tests/validation
that read research/j* are handled in R2.
| Class | Files | Bytes |
|---|---:|---:|
| K | 31 | 196,506 |
| K-code | 3 | 24,324 |
| D-snapshot | 18 | 338,948 |
| P-pin | 50 | 979,177 |
| R-raw | 142 | 3,790,554 |
## (root)
| File | Class | Bytes | Blob |
|---|---|---:|---|
| BASELINE_JET_AMR.txt | R-raw | 5,244 | 62aa2a66dc05 |
## output
| File | Class | Bytes | Blob |
|---|---|---:|---|
| round3_compatibility_summary.md | R-raw | 4,366 | f243318f5591 |
## research/fine_surface_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 54,507 | 71a4543ead37 |
| cpu_device_guard_cmd.txt | R-raw | 241 | 06e0f35d92b3 |
| cpu_device_guard_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_device_guard_stdout.txt | R-raw | 98 | 424d584a031c |
| cpu_existing_runs.json | R-raw | 650 | 3179536407b7 |
| cpu_final_run.json | R-raw | 386 | 523fadb3fec9 |
| cpu_final_stdout.txt | R-raw | 99 | 9cfe73da3858 |
| cpu_lifecycle_run.json | R-raw | 387 | 5b403b9e2f30 |
| cpu_lifecycle_stdout.txt | R-raw | 112 | 3c5c49b285fa |
| cpu_maps_stdout.txt | R-raw | 95 | 71dd87f9faf9 |
| cpu_preallocation_run.json | R-raw | 337 | fa6e96d98cdd |
| cpu_preallocation_stdout.txt | R-raw | 98 | 5d643576f251 |
| cpu_shared_kernel_stdout.txt | R-raw | 98 | 8a0c046bb9b1 |
| cpu_surface_command.txt | R-raw | 113 | 2a5ff31e8d06 |
| cpu_surface_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_projection_command.txt | R-raw | 171 | 70b7f5c62c1a |
| cpu_surface_projection_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_projection_stdout.txt | R-raw | 113 | efd37a149cc1 |
| cpu_surface_stdout.txt | R-raw | 99 | 9cfe73da3858 |
| gpu_preparation_run1.py | R-raw | 14,043 | ee586d035b7a |
| gpu_run.json | R-raw | 2,943 | 15b8ba70d528 |
| gpu_stdout.txt | R-raw | 5,424 | c5216e0267f3 |
| gpu_test_run1.py | R-raw | 15,479 | f30376abc97f |
| incremental.diff | D-snapshot | 53,853 | 318d92e7d177 |
| manifest.json | P-pin | 8,411 | 8281338416af |
| report.txt | K | 8,373 | 6b9e5123e414 |
## research/fine_surface_joint_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline_00.json | P-pin | 21,302 | 4ae101ebd02b |
| baseline_01.json | P-pin | 26,191 | 548cf7b3b494 |
| baseline_02.json | P-pin | 22,440 | e0421e7a4052 |
| baseline_03.json | P-pin | 8,584 | da99b45f32a4 |
| blow_boundary.csv | R-raw | 12,536 | e13363b69457 |
| blow_collisions.csv | R-raw | 19,145 | 5d5b395486bb |
| blow_ledger.csv | R-raw | 15,977 | b43bbfd0b8d2 |
| blow_propagation.csv | R-raw | 9,714 | f06d80d296ca |
| blow_signals.npz | R-raw | 221,114 | 528adf75baa2 |
| blow_summary.json | R-raw | 4,113 | 32285ac15be3 |
| command.txt | R-raw | 232 | 510ecb2925dd |
| criteria.txt | K | 7,480 | dd36e76a3be1 |
| frozen.json | R-raw | 11,972 | b5a3324e8686 |
| gpu_run.json | R-raw | 1,955 | 67ac4785f7e4 |
| incremental.diff | D-snapshot | 625 | e03c132d22df |
| integrity.json | P-pin | 826 | a8b6e4be88d2 |
| manifest.json | P-pin | 4,388 | d2af50260c39 |
| regions.json | R-raw | 27,525 | 7bba26904221 |
| report.txt | K | 8,207 | d7571e068ad3 |
| result.json | R-raw | 11,442 | 9580c2686e70 |
| review.txt | K | 2,117 | bbe1d8a83d47 |
| runner.py | R-raw | 5,847 | 812973acc000 |
| stdout.txt | R-raw | 19,940 | c9218e2e48fc |
| suction_boundary.csv | R-raw | 12,505 | b870be05e6c0 |
| suction_collisions.csv | R-raw | 19,173 | 2de48c0a84ab |
| suction_ledger.csv | R-raw | 16,208 | 92cf1f44911e |
| suction_propagation.csv | R-raw | 9,720 | f017bcc3ca6f |
| suction_signals.npz | R-raw | 220,058 | 66d06cf2d909 |
| suction_summary.json | R-raw | 4,126 | d70a1d371950 |
| validate.py | R-raw | 19,576 | 37441febd168 |
| zero_boundary.csv | R-raw | 11,491 | 65c92e48c974 |
| zero_collisions.csv | R-raw | 18,915 | 027cb8ff6b23 |
| zero_ledger.csv | R-raw | 16,051 | 34a99bb89dd7 |
| zero_signals.npz | R-raw | 127,958 | a966f093a9cd |
| zero_summary.json | R-raw | 1,508 | 6e033f991438 |
## research/fixed_patch_jet_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 69,721 | 91ad9c4d1e88 |
| before.json | R-raw | 690 | 06e1b6278a29 |
| fixed_patch_jet.py | R-raw | 9,646 | 6fbdcd9a076b |
| incremental.patch | D-snapshot | 9,891 | b43129d82c69 |
| manifest.json | P-pin | 1,967 | 08daa0248f60 |
| notes.txt | K | 2,214 | 50070fe54efb |
| results.json | R-raw | 1,100 | d959a6a1cc2c |
| stdout.txt | R-raw | 1,272 | eccdc0f6b16a |
## research/fixed_patch_jet_closure_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 72,692 | 8a157af82ecf |
| cpu_results.json | R-raw | 628 | 8e4a1c090c93 |
| cpu_stdout.txt | R-raw | 590 | d800b783fe47 |
| incremental.patch | D-snapshot | 3,490 | a9a486c68afd |
| layout_uncropped.png | R-raw | 51,785 | 0cf1cfa5e99d |
| manifest.json | P-pin | 1,849 | 31cf0c739241 |
| notes.txt | K | 3,023 | 0f7e7bdfd0e6 |
## research/fixed_patch_jet_records_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 71,228 | a8e967f76e08 |
| before.json | R-raw | 690 | ebd6f4251fc2 |
| fixed_patch_jet.py | R-raw | 10,650 | f6b299885da0 |
| incremental.patch | D-snapshot | 5,539 | c7f4d0c02ccc |
| manifest.json | P-pin | 2,057 | 1d616c2541df |
| notes.txt | K | 2,449 | b58cec14d359 |
| results.json | R-raw | 1,136 | 788af64a6ebd |
| stdout.txt | R-raw | 20,750 | a765b06deefa |
## research/j1-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 7,667 | 8f469daef054 |
| baseline-status.txt | P-pin | 2,601 | 64ca05ab952e |
| baseline.json | P-pin | 23,052 | ab5852bcb0ef |
| incremental.diff | D-snapshot | 29,912 | dd9c4e688888 |
| results.json | R-raw | 68,294 | 56547aa8a678 |
## research/j2-slot-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 12,930 | 69fa4d3bccd3 |
| baseline-status.txt | P-pin | 2,825 | e711afb0016c |
| baseline.json | P-pin | 24,660 | 286e023da94f |
| incremental.diff | D-snapshot | 25,231 | 649f3b5d168d |
| results.json | R-raw | 477,249 | b4272e25179d |
## research/j3-coverage-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 11,409 | 5887cf73dce2 |
| baseline-status.txt | P-pin | 3,055 | 78e88356caa3 |
| baseline.json | P-pin | 30,407 | 095972547a7f |
| incremental.diff | D-snapshot | 20,114 | f0d502dc5c5b |
| negative-budget-20744.h5 | R-raw | 88,017 | 693b133be84d |
| negative-end-20760.h5 | R-raw | 88,174 | 429270d263d4 |
| restart-21280.h5 | R-raw | 88,062 | 74900affae38 |
| restart-reference-21296.h5 | R-raw | 88,154 | 7510ee97885b |
| results.json | R-raw | 296,314 | 7806b10e65cb |
| verification.json | K | 1,468 | 78a07c47aea4 |
## research/j3-lag-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 11,732 | 34fe5dbb2cae |
| baseline-status.txt | P-pin | 2,968 | 6ae6094ea9d7 |
| baseline.json | P-pin | 26,505 | 5c289d14ff30 |
| end-21280.h5 | R-raw | 92,366 | f30b209179ab |
| incremental.diff | D-snapshot | 15,790 | 5c6c485e1f75 |
| results.json | R-raw | 21,718 | 2bf58939129c |
| start-20760.h5 | R-raw | 92,247 | ae0abd05190c |
## research/j3-offline-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 6,271 | a76e1b486bd2 |
| lags.csv | R-raw | 1,104 | dd3136dd2dc2 |
| localization.png | R-raw | 136,013 | 74b56f3e814c |
| regions.csv | R-raw | 2,126 | 5d361e6872af |
| results.json | R-raw | 8,852 | bf77b0f534d4 |
## research/j3-reservoir-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 13,563 | a74b67a64e0d |
| baseline-status.txt | P-pin | 2,896 | a318e077dfae |
| baseline.json | P-pin | 25,571 | 060502ea7dd8 |
| incremental.diff | D-snapshot | 22,898 | 8e2c79a86c98 |
| results.json | R-raw | 182,747 | 7fd5e51a1785 |
## research/refinement_example_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 8,286 | 591478100da8 |
| before.json | R-raw | 818 | 7ab0962775b1 |
| incremental.patch | D-snapshot | 8,682 | 7ad8719d0167 |
| manifest.json | P-pin | 1,798 | bf1eee8a7cf8 |
| notes.txt | K | 1,185 | 7ff019ad023d |
| results.json | R-raw | 1,118 | 94697b0988b9 |
| stdout.txt | R-raw | 325 | 501446f557f4 |
## research/refinement_followup_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 4,887 | af65e889f20e |
| baseline.json | P-pin | 5,343 | 7c60ad8ad692 |
| cleanup.json | R-raw | 1,253 | 15cf342fba2e |
| cpu_results.json | R-raw | 1,056 | 1166dffd2a09 |
| cpu_results_attempt1.json | R-raw | 642 | d139d5508105 |
| cpu_stdout.txt | R-raw | 499 | d1b33ea20563 |
| cpu_stdout_attempt1.txt | R-raw | 879 | 6a9fe7fce39a |
| gpu_before.json | R-raw | 589 | 46762a0ab207 |
| gpu_before_no_launch.json | R-raw | 589 | 074982d1b2d4 |
| gpu_rectangle_before.json | R-raw | 589 | 831e3793339f |
| gpu_rectangle_before_no_launch_1791886104981638567.json | R-raw | 589 | c33885d50bfd |
| gpu_rectangle_results.json | R-raw | 1,003 | 9c6da219ac6c |
| gpu_rectangle_stdout.txt | R-raw | 332 | 9185bf303954 |
| gpu_results.json | R-raw | 1,189 | 53e4f1c44de1 |
| gpu_seed1_before.json | R-raw | 589 | c77ea3485f84 |
| gpu_seed1_results.json | R-raw | 996 | b718e8af84e4 |
| gpu_seed1_stdout.txt | R-raw | 306 | 4d7de0897137 |
| gpu_stdout.txt | R-raw | 68 | b0956fe3cdb0 |
| incremental.patch | D-snapshot | 16,836 | 9df25d8c177a |
| manifest.json | P-pin | 4,410 | 177704d6f1ae |
| run_gpu.py | R-raw | 5,237 | b5af3f0d4f79 |
## research/refinement_prepare_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 8,939 | 8e210c28c7c5 |
| baseline.json | P-pin | 46,348 | d5f257216b8d |
| incremental.patch | D-snapshot | 15,751 | 0ddb32550584 |
| manifest.json | P-pin | 2,113 | 9fd94ccf2c3e |
| validation.json | R-raw | 3,567 | f057e7cbcd35 |
| validation.txt | R-raw | 873 | 594daa45c58c |
## research/refinement_slot_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 67,717 | d498081e7c50 |
| before.json | R-raw | 690 | 430e76b3aa8a |
| cpu_results.json | R-raw | 1,421 | 4f6bc4d9428f |
| incremental.patch | D-snapshot | 18,676 | 67a3e8cc9e68 |
| manifest.json | P-pin | 2,270 | 6515458290f4 |
| notes.txt | K | 2,612 | 08a713ba4b37 |
| results.json | R-raw | 1,160 | 03e683027ba8 |
| stdout.txt | R-raw | 643 | d57b91769826 |
## research/refinement_slot_followup_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 68,536 | 89a26ce79438 |
| before.json | R-raw | 690 | e707df192ff2 |
| incremental.patch | D-snapshot | 495 | c6f6fa11237d |
| manifest.json | P-pin | 1,920 | 4840243a33aa |
| notes.txt | K | 1,549 | 4b77583ca116 |
| planar_velocity.cuh | R-raw | 1,911 | 87db7466c843 |
| planar_velocity.sha256 | P-pin | 251 | 6694c4a1c1f0 |
| results.json | R-raw | 1,050 | 6ef6eda854c6 |
| stdout.txt | R-raw | 1,160 | 461860351c0c |
## research/refinement_step_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 10,671 | 88d6f24743f5 |
| baseline.json | P-pin | 40,620 | 20f61559dd38 |
| cpu_validation.json | R-raw | 1,231 | 7999fd30018c |
| gpu_failure_validation.json | R-raw | 868 | 5f6db65ee4f9 |
| gpu_failure_validation.txt | R-raw | 248 | d9a570a1e9e9 |
| gpu_validation.json | R-raw | 672 | 2028f59e1055 |
| gpu_validation.txt | R-raw | 632 | 9699918ee127 |
| incremental.patch | D-snapshot | 26,516 | d95f9347633d |
| manifest.json | P-pin | 2,075 | 32a7f1a7309e |
## research/repair-2026-10-10
| File | Class | Bytes | Blob |
|---|---|---:|---|
| TRON_HANDOFF.md | R-raw | 25,208 | a339786c7944 |
| baseline-hashes.json | P-pin | 16,020 | 9b4e24cb1d10 |
| baseline-status.txt | P-pin | 429 | 515be249b638 |
| checkpoint-domain-2026-10-12.diff | D-snapshot | 14,443 | 035ed630dda1 |
| classic-results.json | R-raw | 244,729 | 7297aa0d5bca |
| classic-signals.npz | R-raw | 630,332 | 8cf6d1218aa0 |
| final-gpu.log | R-raw | 191 | a0eec1a8ea31 |
| final-unit.log | R-raw | 188 | 94efba2f1d42 |
| provenance.json | P-pin | 1,020 | 25e22b4f0696 |
| repair-architecture.md | K | 9,498 | 12051c3e8571 |
| repair-numerical.md | K | 10,769 | 0c5757fe5f36 |
| repair-state.md | K | 8,481 | 3c7f2d5b687a |
| simplification-checks.log | R-raw | 1,045 | fa9d464be740 |
## research/shear_refinement
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.md | K-code | 4,142 | 67bdd8ed622b |
| boundary_shear_tagging.py | K-code | 13,859 | b5415a1c4ea1 |
| example.py | K-code | 6,323 | 66295d3f0273 |
## research/surface_actuation_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 54,139 | 0ad06888dbeb |
| cpu_checkpoint_run.json | R-raw | 400 | 39681b60ff15 |
| cpu_checkpoint_stdout.txt | R-raw | 114 | 929e4b8d4f46 |
| cpu_initial.txt | R-raw | 620 | 29213c58a679 |
| cpu_surface_command.txt | R-raw | 118 | 6d8c615a6e03 |
| cpu_surface_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_stdout.txt | R-raw | 99 | ee9237fc26e5 |
| gpu_run.json | R-raw | 1,698 | dc8f87eafaa9 |
| gpu_stdout.txt | R-raw | 159 | b262891e52c3 |
| incremental.diff | D-snapshot | 50,206 | 43090a1a8b11 |
| manifest.json | P-pin | 7,527 | 35daa01271cb |
| report.txt | K | 5,078 | 1162a6479f9f |
## research/wall_seam_pulse_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| analyze.py | R-raw | 10,343 | ed7cf6999a49 |
| interpretation.txt | K | 3,514 | 90935db2aa99 |
| results.json | R-raw | 15,137 | 678a0759871a |
| run.json | R-raw | 1,170 | 53e303fd0d5d |
| sources.csv | R-raw | 2,313 | 40d3736b8ef1 |
| stdout.txt | R-raw | 1,179 | 756d8c38e213 |
## tests/validation/multigrid/cpuref_0_3_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 4,133 | 62897bfd9812 |
| README_grok.md | K | 1,751 | 2d36e05661a8 |
| baseline.json | P-pin | 39,291 | 188491096547 |
| manifest.json | P-pin | 3,495 | 10c19f5fe5ca |
| results_horsea.json | R-raw | 2,281 | 3b163384e376 |
| stdout_grok.txt | R-raw | 7,143 | 5a0c270ea4ae |
| stdout_horsea.txt | R-raw | 9,013 | adff8bb8d295 |
## tests/validation/multigrid/local_stress_v1_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 8,239 | 3513fd294223 |
| TABLE_local_stress_observable_v1.md | K | 2,967 | 6115dcde0f46 |
| baseline.json | P-pin | 43,991 | ab631ea4f2ad |
| comparison.json | R-raw | 13,248 | d7d1b36eb247 |
| manifest.json | P-pin | 4,132 | c6c44d8125cd |
| results_horsea.json | R-raw | 2,671 | 79bdf001f948 |
| run_once.py | R-raw | 4,313 | fa9c5239e993 |
| source_manifest.json | P-pin | 622 | 0516f1860407 |
| stdout_horsea.txt | R-raw | 17,091 | a20c554693df |
| stdout_local_stress_observable_v1.txt | R-raw | 12,238 | 692ffe1a05da |
## tests/validation/multigrid/shear_v0_2_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 7,220 | 772f74ad40ff |
| baseline.json | P-pin | 41,751 | 70d9c9fa71c0 |
| comparison.json | R-raw | 7,679 | 0af8736f9a01 |
| joint_report.md | K | 6,110 | eed871221298 |
| manifest.json | P-pin | 3,796 | b22d03a1ba9e |
| results_horsea.json | R-raw | 1,957 | b2cd48a07a08 |
| run_once.py | R-raw | 3,245 | 0b8d5e21f326 |
| source_manifest.json | P-pin | 574 | 4d1c742690c7 |
| stdout_grok.txt | R-raw | 7,950 | 0e80b1aba7f6 |
| stdout_horsea.txt | R-raw | 11,421 | 3c867cdac197 |
+15 -15
View File
@@ -1,8 +1,8 @@
# Fixed three-level rectangle unions
This page describes three-level `FixedPatchStepper` setups built from the
`refinements` level list, with rectangle unions on either adjacent pair. The
same constructor supports arbitrary depth; three or more levels require
This page describes the legacy three-level entry point of `FixedPatchStepper`,
with rectangle unions on either adjacent pair. The `refinements` level-list
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.
@@ -12,15 +12,15 @@ No CUDA kernel changes are involved.
## Explicit coordinate frames
For a single scalar parent patch, pass a scalar rectangle as `refinements[1]`:
`(x0,y0,x1,y1)` in half-open integer indices of the allocated L1 window,
including its ghost band.
Keep the existing scalar `child=(x0,y0,x1,y1)` for a single scalar parent patch.
Its coordinates retain their original meaning: half-open integer indices in the
allocated L1 window, including its ghost band.
A rectangle list as `refinements[1]` accepts a scalar or a set parent and
interprets every rectangle in the actual allocated L1 array, including ghost
bands and holes. It never infers region-local coordinates. The physical root
cell edge corresponding to an L1 index `(x,y)` is
`parent.fine_origin + (x,y)/2`:
The new keyword `child_rectangles_in_parent=[...]` is mutually exclusive with
`child`. It accepts a scalar or a set parent and interprets every rectangle in
the actual allocated L1 array, including ghost bands and holes. It never infers
region-local coordinates. The physical root cell edge corresponding to an L1
index `(x,y)` is `parent.fine_origin + (x,y)/2`:
- A scalar L1 parent has a shifted window origin.
- A set L1 parent has origin `(0,0)` and public shape `(2*ny,2*nx)`.
@@ -47,9 +47,9 @@ tile edges.
```python
run = FixedPatchStepper(
cfg, context=ctx,
refinements=[[(4, 4, 20, 28), (24, 4, 44, 28)], [(16, 16, 32, 40), (56, 16, 80, 40)]],
level_blocks=[(4, 4)] * 3,
cfg, [(4, 4, 20, 28), (24, 4, 44, 28)], context=ctx,
base_block=(4, 4), fine_block=(4, 4), child_block=(4, 4),
child_rectangles_in_parent=[(16, 16, 32, 40), (56, 16, 80, 40)],
)
```
@@ -57,7 +57,7 @@ This example uses a 48x32 root. `initialize` and `download` use three Q-major
dense arrays. Public arrays and masks still scale with the full allocated
extent; sparse device buffers do not remove that host-memory limit.
Stationary whole-circle L2 bodies and Jets (`level_objects[2]`, `level_jets[2]`) use root edge
Stationary whole-circle `child_objects` and `child_jets` use root edge
coordinates and a single L2 boundary object. Body IDs are physical object-list
positions, not region IDs or caller `obj_id`. Complete boundary stencils must
avoid the transfer footprint. Change a child Jet with
-25
View File
@@ -1,25 +0,0 @@
# Device state lifecycle (uniform Simulation, FixedPatchStepper)
State is grouped by what a change invalidates. This file describes the code as
it is; it adds no framework. Line references are to candidate 0f8fdbc3.
| Class | Contents | How it changes | Owner rule |
|---|---|---|---|
| 1. Module and its globals | Compile-time (visible to the compiler, fixed per module): `config.h` macros incl. `NX`/`NY`, `COLLISION_MODEL`, `BLOCK_ROOT_BC`, `WALL_*`. Runtime per-module globals (not visible to the compiler; written by host code): `__constant__` `d_params`, `d_w`, `d_cx`, `d_cy`, `d_block_channel`, `d_block_wall`; plus bound kernel handles | Globals are written once by the owner right after `load_module`. Uniform grid: `Simulation._recompile` (construction, body-topology sync) loads a new module and re-uploads; `update_runtime_params` rewrites `d_params` (omega, force, n_objects) between runs. Steppers: never rewritten after construction, so they are immutable for the instance's lifetime although they are runtime values. The same quantity can be a macro in one route and a global in another (dense slot root: `WALL_SLOT_WIDTH`/`WALL_NORMAL_VELOCITY` macros; blocked slot root: `d_block_wall`), which can change generated arithmetic. | Every owner loads its own module (`compiler.load_module` = `module_from_file`, no cache). A loaded module is never shared between live owners: globals are per module, so sharing would share mutable `d_params`. |
| 2. Geometry, layout, tables | flags, curved-link tables, block layouts and halo pairs, transfer (explode/coalesce) tables, block kinds | Immutable for the life of an instance. Uniform grid: rebuilt by body sync. `FixedPatchStepper`: a geometry change is a new instance (`remap_child`), committed only after a successful transfer. | No in-place table edits. |
| 3. Controls and dependent boundary columns | Jet amplitude -> per-link `surface_velocity`; body actions (uniform grid) | Between steps, without a table or module rebuild (`set_jet_amplitude`). | Host copy commits only after the device upload succeeded; a device error fails the instance. |
| 4. Per-step state | population double buffers and their parity, raw-exchange ledgers, observations | Device-only during `step(n)` / `run(n)`; buffer swaps are host-handle swaps. | Valid on the host only after the single wait at the end of the call. |
Storage routes are separate concerns:
- Original uniform grid (`uniform.cu`, `Simulation`): whole-domain `NX`/`NY`.
- Blocked levels (`blocked.cu`; every `FixedPatchStepper` level, including the
single level of `refinements=[]`): `NX`/`NY` are one block window including halo.
- Dense refinement levels (`refinement.cu`, `level_blocks[l] is None`).
A single-rectangle refinement (`FixedPatch2D`) is stored in its own fine
window frame in both dense and blocked form (`refinement_stepper.py:222-224`,
`BlockLayout2D.build(*pair.fine_shape, ...)`). Only a rectangle set
(`FixedPatchSet2D`) uses the level-wide frame `(2*ny, 2*nx)`, whose
`fine_layout` requires that full grid to be divisible by the tile
(`refinement.py:240-252`).
-88
View File
@@ -1,88 +0,0 @@
# Supported settings: 2D Jet + fixed multilevel refinement (candidate 888c8038)
Every entry names the guard that enforces it (file and function or kernel guard,
at source revision 888c80381c006e976be2af8a452288729fc17c1b) and, where one
exists, an existing test that exercises it. A setting not listed here is not
claimed. Observations at the end are tied to the exact case in which they were
measured; they are not general bounds.
## Lattice, storage, collision
| Setting | Supported | Guard |
|---|---|---|
| Dimension / lattice | 2D D2Q9 only (3D refinement is future work) | `lbm/refinement.py` `FixedPatch2D.from_config`; `#error` guards in `lbm/kernels/blocks.cu` and `lbm/kernels/refinement.cu` |
| Precision / streaming | FP32 storage, double buffer | same guards |
| Collision on refinement levels | SRT or MRT; TRT and LES rejected | `lbm/refinement.py` `FixedPatch2D.from_config` (re-checked for fine boundaries in `prepare_fine_boundary`) |
| Shifted (ddf_shifting) storage | only when every level is blocked; dense or mixed rejected | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__`, `FixedPatchStepper._check_storage`; `#error` guard in `lbm/kernels/refinement.cu` (dense patch kernel unshifted only) |
| Fine relaxation | 1/tau_fine must lie inside the configured omega guards | `lbm/refinement.py` `FixedPatch2D.from_config` |
| Single blocked level `FixedPatchStepper(refinements=[], level_blocks=[(by, bx)], level_objects=[circles])` (replaces `PeriodicBlockStepper`, removed in 8e1feff) | doubly periodic, SRT only, no LES/Jets/projection/exchange observation, stationary passive circles; `download()` returns a 1-tuple | `lbm/refinement_stepper.py` `_check_levels`, `FixedPatchStepper.__init__`; `lbm/blocks.py` `static_circle_tables` |
Tests: `tests/unit/test_refinement_mrt.py`, `tests/validation/multigrid/test_refinement_mrt_gpu.py`,
`tests/unit/test_shifted_storage.py`, `tests/unit/test_shifted_block_operators.py`.
## Refinement hierarchy (`FixedPatchStepper(cfg, *, context, refinements, ...)`)
| Setting | Supported | Guard |
|---|---|---|
| Depth | at least one refinement level; one entry per level in `level_blocks`/`level_objects`/`level_jets` | `lbm/refinement_stepper.py` `_check_levels` |
| Single L1 rectangle | two-coarse-cell margin to the domain edge | `lbm/refinement.py` `FixedPatch2D.from_config` |
| Several L1 rectangles (union) | require `level_blocks[0]` and `level_blocks[1]`; transfers must close (dependency checks, no fixed gap rule) | `lbm/refinement_stepper.py` `_check_levels`; `lbm/refinement.py` `_check_parent_pull_closure` |
| Nested levels | depth >= 3 must be all blocked; a blocked level requires blocked levels below; a scalar child needs a scalar parent | `lbm/refinement_stepper.py` `_check_levels`; `lbm/hierarchy.py` `build_hierarchy` |
| Nested transfer support | child only over inherited ACTIVE cells, donors/receivers uncovered ACTIVE, no child over the parent restriction footprint | `lbm/refinement.py` `_nested_kind` |
| Tile shape | positive `(by, bx)`; the level allocation must be divisible by the tile (no partial tiles yet) | `lbm/refinement_stepper.py` `_check_levels`, `lbm/refinement.py` `FixedPatchSet2D.fine_layout` (shape); `lbm/blocks.py` `BlockLayout2D.build` (divisibility) |
| Coarse boundary | `periodic`, `slot_reservoir` or `channel` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| `slot_reservoir` | on-site velocity bottom slot, reservoir top, velocity 0, 2 <= slot width < nx, patch rows 4..ny-4 | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__`; `lbm/refinement.py` `prepare_fine_boundary` |
| `channel` | all levels blocked; Zou-He inlet, pressure-NEQ outlet, stationary bounce-back walls; transfers away from the closure | `lbm/channel.py` `validate_channel_config`, `validate_channel_transfers` |
| Explosion | `constant` (default); `projected_linear` only periodic, all-blocked SRT, no bodies/Jets, no LES (experimental) | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| L2 runtime remap (`remap_child`) | periodic three-level L2 sets only, no L0/L1 bodies, identical pinned boundary | `lbm/refinement_stepper.py` `FixedPatchStepper.remap_child` |
Tests: `tests/unit/test_refinement_three_level_multipatch.py`, `tests/validation/multigrid/test_refinement_multipatch.py`,
`test_refinement_three_level.py`, `test_refinement_three_level_multipatch_gpu.py`, `test_refinement_blocked_base.py`,
`test_refinement_blocked_fine.py`, `test_channel_root_gpu.py`, `tests/unit/test_channel_root.py`,
`tests/unit/test_projected_pair_stepper.py`, `test_runtime_child_remap_gpu.py`, `test_pinned_body_remap_gpu.py`.
## Bodies and Jets on refined levels
| Setting | Supported | Guard |
|---|---|---|
| Bodies | stationary circles without sensor/force-region roles, entirely inside the patch union, fully resolved, disjoint, stencil away from the two-hop transfer footprint | `lbm/refinement.py` `prepare_fine_boundary` |
| Level | not on L0; only on the finest level of a nested hierarchy | `lbm/refinement_stepper.py` `_check_levels` |
| Jet condition | velocity only; `pressure` is reserved and raises NotImplementedError | `body/jets.py` `Jet.__post_init__` |
| Jet geometry | each Jet selects resolved links of exactly one stationary body; no Jet/Jet or Jet/SurfaceActuation overlap; abs(amplitude) < 1 lattice | `body/jets.py` `Jet.__post_init__`, `jet_link_shapes`, `_surface_links` |
| Jets with shifted storage | only in the all-blocked stepper with SRT and no LES | `body/jets.py` `_surface_links` |
| SurfaceActuation | unshifted storage only | `body/objects.py` `SimObject.validate_surface_actuation` |
| Raw boundary exchange (force) | blocked body levels only; the window ledger requires `observe_raw_exchange` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| Checkpoint with Jets | refused | `simulation.py` `Simulation.save_checkpoint` |
Raw exchange is the cut-link momentum reaction (wall + aperture), not a validated
nozzle thrust or stress load. Jet amplitude is a lattice velocity, not a flow rate.
Tests: `tests/unit/test_jets.py`, `test_jets_review.py`, `test_shifted_jets.py`, `test_raw_exchange.py`,
`tests/validation/multigrid/test_refinement_jets.py`, `test_shifted_jets_gpu.py`, `test_raw_exchange_gpu.py`,
`test_exchange_window.py`, `test_fine_surface_gpu.py`.
## Observations from specific tested cases (not general bounds)
- SRT channel root vs uniform OneStep, strict-parity test
`tests/validation/multigrid/test_channel_root_gpu.py::ChannelRootGPU::test_uniform_matched_root`:
in one of its 18 sub-cases (2 storage modes x 3 profile/clamp/damp settings x 3 blocks; which
one is not recorded), 1 of the 4320 compared values differs, max abs 2.33e-10, max rel 1.77e-7
(about 2 ULP). The test remains failed; its cause and any general bound are unresolved.
- MRT root parity test
`tests/validation/multigrid/test_refinement_mrt_gpu.py::RefinementMRTGPU::test_uniform_matched_mrt_root`
(12 sub-cases: 2 storage modes x 2 profiles x 3 blocks, default outlet settings) passed bitwise
in the Horsea focused GPU run at K1 7813334 (2026-10-21).
- Periodic Taylor-Green Re 100, measured at 36ffc8b1 (V3 matched pair, N128 depth 2):
every per-sample screen passed; the constant explosion's aggregate worst velocity L2,
0.004418, exceeds its preregistered 0.002 budget, while projected_linear passed that budget.
- Curved-wall pressure (pilot harness, exact-q analytic annulus, tau 0.8): the coarse swirl r1
case exceeded its terminal density-gain cap (1.105e-4 vs 1e-4), so the frozen 9-case matrix
is NOT_ESTABLISHED. No validated fix exists. This is a pilot harness, not production code.
- DFG 2D-1 Re 20 (V4, SRT, t* 60, pressure-NEQ outlet and local Zou-He inlet): refined and uniform
runs were NOT_STEADY under the frozen steady rule, so no interval verdict was given.
## Not supported in this release
3D, dynamic/automatic AMR (tagging, time-varying hierarchies beyond `remap_child`),
moving bodies on refined levels, pressure Jets, Jet checkpoints, LES or TRT on refined
levels, partial (non-dividing) tiles.
+5
View File
@@ -46,4 +46,9 @@ CUDA_VISIBLE_DEVICES=<n> python tests/validation/run_dfg_re20.py --case both \
The uniform D80 control separates method error from refinement error. `--plan` writes `plan.json` only. A GPU run writes `plan.json` and `result.json`
(per case: lattice parameters, every sample, window ranges, status, verdict).
Receipts: `plan.json` and `result.json` carry `schema` 2 and `provenance` (HEAD, src tree,
runner hash; clean checkout required, refused if it changes during the run). Receipts
written before this (schema 1, e.g. evidence 70f6617) are a bare list of cases. Read both
with `run_dfg_re20.read_result(path)` (`["cases"][0]` replaces the old `[0]`); existing
evidence scripts stay valid for schema-1 receipts and need this reader for new ones.
Host contract tests: `tests/unit/test_dfg_re20.py`.
+2 -2
View File
@@ -90,8 +90,8 @@ def main():
# The caller owns the CUDA context; the stepper must be closed first (the
# nested with-statement does that).
with CudaContext(0) as context, FixedPatchStepper(
cfg, context=context, coarse_boundary=args.mode, refinements=[BOUNDS],
level_blocks=[None, fine_block], level_objects=[(), (body,)]) as run:
cfg, BOUNDS, context=context, coarse_boundary=args.mode,
fine_objects=[body], fine_block=fine_block) as run:
p = run.patch
# Rest equilibrium is already postcollision G, so no initial collision is
# needed. Non-leaf entries (covered, ghost, solid) are discarded by initialize.
+2 -2
View File
@@ -148,8 +148,8 @@ def main():
samples=[], status='running', last_synchronized_coarse_step=0)
start, run = time.monotonic(), None
try:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context,
coarse_boundary='slot_reservoir', refinements=[BOUNDS]) as run:
with CudaContext(0) as context, FixedPatchStepper(cfg, BOUNDS, context=context,
coarse_boundary='slot_reservoir') as run:
p = run.patch
masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE]
masks[0][[0, -1]] = False
+1 -1
View File
@@ -48,7 +48,7 @@ def main():
nx=16, ny=16, viscosity=.1, velocity=1e-3, rho=1.0,
lattice_model="D2Q9", collision="SRT", streaming="double_buffer",
store_precision="FP32", ddf_shifting=False, les_enabled=False)
with CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)]) as run:
with CudaContext(0) as context, PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as run:
p = run.patch
coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny)
fine = shear_equilibrium(p.fine_shape, p.fine_origin, .5, cfg.ny)
+3 -14
View File
@@ -207,30 +207,19 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""Per-link prescribed surface velocity (n, 2) FP32 and aperture mask.
See _surface_links, which also returns the unit-amplitude jet shapes.
"""
return _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry, jets, hits,
_shifted_blocked=_shifted_blocked)[:2]
def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""(velocity, aperture, jet shape, jet id); the last two are None without jets.
Host-only and storage-neutral: rows follow the given physical cut-link
order. Returns all None when neither an object carries surface
order. Returns (None, None) when neither an object carries surface
actuation nor a jet is given. Jets need true FP64 ``hits`` (n, 2) and may
not share a link with each other or with a legacy aperture.
Shifted storage is privately enabled only by the all-blocked stepper.
"""
enabled = [obj for obj in objects if obj.surface_actuation is not None]
if not enabled and not jets:
return None, None, None, None
return None, None
from .preprocess.flag_overlay import validate_cut_link_ownership
validate_cut_link_ownership(flags, fluid_idx, dir, cfg.nq)
surface_velocity = np.zeros((len(dir), 2), np.float32)
surface_aperture = np.zeros(len(dir), bool)
shape = jet_id = None
for obj in enabled:
obj.validate_surface_actuation(cfg)
selected = body_id == obj.obj_id
@@ -263,4 +252,4 @@ def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
raise ValueError("Jet overlaps a SurfaceActuation aperture")
surface_velocity[driven] = jet_link_velocity(jets, shape, jet_id)[driven]
surface_aperture |= driven
return surface_velocity, surface_aperture, shape, jet_id
return surface_velocity, surface_aperture
+2 -2
View File
@@ -11,7 +11,7 @@ Responsibilities:
Design::
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler.obs_layout`.
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler_v2.obs_layout`.
Pending edits are lightweight: only the "intent" (add / remove) is stored.
All derived quantities (flags, compact lists, GPU buffers) are built at
@@ -28,7 +28,7 @@ import pycuda.driver as cuda
from .objects import SimObject
from .registry import BodyRegistry
from .preprocess.flag_overlay import merge_flag_masks, validate_cut_link_ownership
from ..cuda.compiler import obs_layout
from ..cuda.compiler_v2 import obs_layout
from ..cuda.obs_layout import ObsLayout
from ..lbm.descriptors import (
FLUID, SOLID,
+1 -1
View File
@@ -7,7 +7,7 @@ Two JSON files:
config_body.json – object list
LBMConfig / BodyConfig are plain dataclasses; they translate to
macro dicts consumed by `cuda/compiler.py` for header generation.
macro dicts consumed by `cuda/compiler_v2.py` for header generation.
"""
import json
+2 -2
View File
@@ -60,14 +60,14 @@ Python `config.py` 只负责读取和校验,不是配置位置。显式配置
**`lbm/kernels/config/*.h` 由编译器根据 `LBMConfig` 自动生成,请勿手改。**
仓库中检入的 `config/*.h` 可能是历史版本。`compiler.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `uniform.cu` 属不受支持用法。
仓库中检入的 `config/*.h` 可能是历史版本。`compiler_v2.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `kernel_v2.cu` 属不受支持用法。
```
config_lbm.json
↓ load_lbm_config()
config.py:LBMConfig (读取 + 校验)
↓ to_macros()
cuda/compiler.py (独立构建目录中生成 config/*.h)
cuda/compiler_v2.py (独立构建目录中生成 config/*.h)
↓
config_grid.h → NX, NY, NZ, NT, LATTICE_MODEL;
DIM / NQ 由 LATTICE_MODEL 推导写入(与 grid.lattice_model 一致,非 JSON 顶层字段)
+1 -1
View File
@@ -1,4 +1,4 @@
"""CUDA build helpers (compilation itself does not need a live context)."""
from . import compiler
from . import compiler_v2 as compiler
__all__ = ["compiler"]
@@ -1,14 +1,11 @@
# CelerisLab/cuda/compiler.py
# CelerisLab/cuda/compiler_v2.py
"""
Kernel configuration, compilation, and module loading.
Owners call build_module(cfg, entry, arch=...), then set_params(module, omega, ...).
build_module runs, in a private directory it removes afterwards:
Workflow:
1. build_dir = generate_config(lbm_cfg, n_objects) — isolated source snapshot
2. compile_kernel(arch, source=entry, build_dir=build_dir) — nvcc -ptx
2. compile_kernel(arch, build_dir=build_dir) — nvcc -ptx kernel_v2.cu
3. load_module(ptx_path) — PyCUDA module from PTX
Entries: uniform.cu (uniform grid), blocked.cu (blocked level), refinement.cu
(dense refinement level).
"""
from __future__ import annotations
@@ -19,6 +16,7 @@ import shutil
import tempfile
import hashlib
import json
from contextvars import ContextVar
from dataclasses import asdict
from pathlib import Path
from typing import Optional
@@ -35,6 +33,9 @@ _KERNEL_DIR = os.path.join(
)
_last_build_dir = ContextVar("celeris_last_build_dir", default=None)
_last_ptx_path = ContextVar("celeris_last_ptx_path", default=None)
def kernel_path(filename: str) -> str:
return os.path.join(_KERNEL_DIR, filename)
@@ -53,16 +54,14 @@ def _write(path: str, content: str):
def generate_config(cfg: LBMConfig, n_objects: int = 0,
*, build_root: Optional[str] = None, block_root_bc: int = 0) -> str:
*, build_root: Optional[str] = None) -> str:
"""Snapshot kernels and render headers in a fresh private directory.
Returns its path; pass it to compile_kernel(build_dir=...). The caller owns cleanup_build_dir() once the loaded module is no longer
Returns its path. Pass it explicitly to compile_kernel for interleaved
builds. Legacy generate_config(); compile_kernel() remains context-local.
The caller owns cleanup_build_dir() once the loaded module is no longer
needed for recompilation/debugging. No package files are changed.
block_root_bc specializes a blocked root module; fine/dense builds use 0.
"""
if (isinstance(block_root_bc, bool) or not isinstance(block_root_bc, int)
or block_root_bc not in (0, 1, 2)):
raise ValueError("block_root_bc must be an integer mode 0, 1 or 2")
macros = cfg.to_macros()
if isinstance(n_objects, bool) or not isinstance(n_objects, int) or n_objects < 0:
raise ValueError("n_objects must be a nonnegative integer")
@@ -72,19 +71,20 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0,
try:
shutil.copytree(_KERNEL_DIR, build_dir, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.ptx", "*.cubin", "*.tmp"))
_render_config(macros, n_objects, os.path.join(build_dir, "config"),
block_root_bc=block_root_bc)
_render_config(macros, n_objects, os.path.join(build_dir, "config"))
digest = hashlib.sha256()
for path in sorted(Path(build_dir).rglob("*")):
if path.is_file():
digest.update(str(path.relative_to(build_dir)).encode())
digest.update(path.read_bytes())
metadata = {"source_sha256": digest.hexdigest(), "config": asdict(cfg),
"n_objects": n_objects, "block_root_bc": block_root_bc}
"n_objects": n_objects}
_write(os.path.join(build_dir, ".celeris-build"), json.dumps(metadata, sort_keys=True))
except BaseException:
shutil.rmtree(build_dir)
raise
_last_build_dir.set(build_dir)
_last_ptx_path.set(None)
return build_dir
@@ -96,9 +96,12 @@ def cleanup_build_dir(build_dir: Optional[str]) -> None:
if path == Path(_KERNEL_DIR).resolve() or not (path / ".celeris-build").is_file():
raise ValueError("Refusing to remove a directory not owned by the compiler")
shutil.rmtree(path)
if _last_build_dir.get() == str(build_dir):
_last_build_dir.set(None)
_last_ptx_path.set(None)
def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0):
def _render_config(m: dict, n_objects: int, cdir: str):
_write(os.path.join(cdir, "config_grid.h"), f"""{_HEADER}\
// Layer: Global / Grid
#ifndef CELERIS_CONFIG_GRID_H
@@ -162,9 +165,6 @@ def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0
#define STORE_PRECISION {m['STORE_PRECISION']}
#define USE_DDF_SHIFTING {m['USE_DDF_SHIFTING']}
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
#define BLOCK_ROOT_BC {block_root_bc}
#define USE_LES {m['USE_LES']}
#define LES_CS {m['LES_CS']}
#define LES_CLOSED_FORM {m['LES_CLOSED_FORM']}
@@ -241,9 +241,13 @@ def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
def compile_kernel(arch: str = "sm_70", source: str = "uniform.cu",
output: Optional[str] = None, *, build_dir: str) -> str:
"""Compile the snapshot in build_dir (from generate_config); never package headers."""
def compile_kernel(arch: str = "sm_70", source: str = "kernel_v2.cu",
output: Optional[str] = None, *,
build_dir: Optional[str] = None) -> str:
"""Compile a generated private source snapshot; never use package headers."""
# A failed attempt must not make implicit load_module() reuse older code.
_last_ptx_path.set(None)
build_dir = build_dir or _last_build_dir.get()
if not build_dir or not (Path(build_dir) / ".celeris-build").is_file():
raise RuntimeError("Call generate_config() and pass its build_dir before compiling")
root = Path(build_dir).resolve()
@@ -268,36 +272,23 @@ def compile_kernel(arch: str = "sm_70", source: str = "uniform.cu",
finally:
if os.path.exists(tmp):
os.unlink(tmp)
_last_ptx_path.set(str(dst))
return str(dst)
def load_module(ptx_path: str):
"""Load one compiled PTX file as a new PyCUDA module."""
def load_module(ptx_path: Optional[str] = None):
"""Load explicit PTX or the last PTX compiled in this Python context."""
ptx_path = ptx_path or _last_ptx_path.get()
if not ptx_path:
raise RuntimeError("No compiled PTX; call compile_kernel() first")
import pycuda.driver as cuda
return cuda.module_from_file(ptx_path)
def build_module(cfg: LBMConfig, source: str, *, arch: str, n_objects: int = 0,
block_root_bc: int = 0):
"""Generate, compile and load one private module; the build directory is removed.
# ---------------------------------------------------------------------------
# Back-compat helpers (used by test scripts)
# ---------------------------------------------------------------------------
The single build path of every owner (Simulation, FixedPatchStepper levels).
Each call returns a NEW module: callers must not share it, because its
globals (d_params, ...) are mutable per module.
"""
build_dir = generate_config(cfg, n_objects=n_objects, block_root_bc=block_root_bc)
try:
return load_module(compile_kernel(arch, source=source, build_dir=build_dir))
finally:
cleanup_build_dir(build_dir)
def set_params(module, omega: float, fx: float = 0.0, fy: float = 0.0, fz: float = 0.0,
n_objects: int = 0) -> None:
"""Write the module's __constant__ LBMParams (core/params.cuh layout)."""
import struct
import pycuda.driver as cuda
ptr, size = module.get_global("d_params")
if size != struct.calcsize("=ffffI"):
raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", omega, fx, fy, fz, n_objects))
def compile_kernel_v2(arch: str = "sm_70") -> str:
"""Alias for compile_kernel() kept for test-script compatibility."""
return compile_kernel(arch=arch)
+1 -1
View File
@@ -2,7 +2,7 @@
"""Packed ``obs`` buffer layout for force / torque / sensor telemetry.
Usage::
Host code (e.g. ``ObjectManager``) and ``compiler.generate_config`` must use
Host code (e.g. ``ObjectManager``) and ``compiler_v2.generate_config`` must use
the same ``dim`` and ``n_objects`` so allocation sizes match device macros.
Design::
+286
View File
@@ -0,0 +1,286 @@
# CelerisLab/lbm/block_stepper.py
"""Opt-in periodic backend for one BlockLayout2D level, optional static circles.
D2Q9/SRT/FP32 unforced double buffers. Each step refreshes every halo from
its owner core, then pulls/collides core cells into the spare buffer, then
swaps. Only halo pair tables are uploaded; dense ownership stays on the host.
The caller owns the CudaContext and must close this backend before it.
Optional ``objects`` are stationary passive circles (static linear BFL via
receiver-local staging: halo refresh -> cut links -> pull, no copy between).
No force/actuation readout, motion, walls, refinement, checkpoint or runtime
parameter mutation; wall/slot/inlet fields in cfg are ignored.
"""
from dataclasses import replace
from numbers import Integral
import struct
from threading import RLock
import numpy as np
import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler
from .blocks import NQ, BlockLayout2D, blocked_link_tables
from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
from .population_storage import _StoredPopulations, _encode_physical, _decode_stored
MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks)
def _static_circles(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
cx, cy = obj.geometry.center
radius = obj.geometry._radius
if not np.isfinite([cx, cy, radius]).all() or radius <= 0:
raise ValueError("Block circle center/radius must be finite with radius > 0")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
tables, _ = blocked_link_tables(
layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
np.array([link.dir for link in links], np.uint8),
np.array([link.q for link in links], np.float32),
np.array([link.fallback for link in links], np.uint8))
return dict(solid=solid, **tables)
class PeriodicBlockStepper:
"""G->G stepping of Q-major (9, ny, nx) postcollision populations.
On a device/launch failure the instance becomes unusable: close and
recreate it rather than assuming a partially advanced buffer rolled back.
"""
def __init__(self, cfg, layout, *, context, objects=()):
if not isinstance(layout, BlockLayout2D) or layout.periodic != (True, True):
raise ValueError("PeriodicBlockStepper requires a doubly periodic BlockLayout2D")
checked = replace(cfg)
checked.validate()
if (not checked.is_d2q9 or checked.collision != "SRT"
or checked.streaming != "double_buffer" or checked.store_precision != "FP32"
or checked.les_enabled
or (checked.ny, checked.nx) != layout.shape):
raise ValueError("PeriodicBlockStepper requires matching D2Q9/SRT/FP32/"
"double_buffer config without LES")
ly, lx = layout.local_shape
pairs = len(layout.halo_dst)
tpb = checked.threads_per_block
if layout.nblocks > MAX_GRID_DIM or ly > MAX_GRID_DIM or not 0 < pairs < 2**31:
raise ValueError("block count, block rows or halo pairs exceed launch limits")
objects = tuple(objects) # an empty iterable takes the body-free path
self.boundary = _static_circles(layout, objects) if objects else None
links = 0 if self.boundary is None else len(self.boundary["cell"])
if links >= 2**31:
raise ValueError("cut links exceed launch limits")
self._ddf_shifting = bool(checked.ddf_shifting)
self.layout = layout
self.ctx = context
self._links = links
self._link_grid = ((links + tpb - 1) // tpb, 1)
# Wall/slot fields are not applied; neutralize them like the fine patch level.
self._local_cfg = replace(checked, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
self._block = (tpb, 1, 1)
self._grid = ((lx + tpb - 1) // tpb, ly, layout.nblocks)
self._copy_grid = ((pairs + tpb - 1) // tpb, 1)
self._bytes = int(np.prod(layout.blocked_shape)) * 4
self._lock = RLock()
self._status = "new"
self._step_count = 0
self._allocations = []
self._stream = self._module = self._copy = self._step = self._curved = None
self._tables = {}
with self.ctx.activate():
try:
free, _ = cuda.mem_get_info()
tables = sum(a.nbytes for k, a in (self.boundary or {}).items() if k != "solid")
if 2 * self._bytes + layout.halo_dst.nbytes + layout.halo_src.nbytes + tables > free:
raise MemoryError("blocked buffers exceed free device memory")
self._stream = cuda.Stream()
config = self._local_cfg
config.validate()
if not np.isclose(config.omega, 1 / (3 * checked.viscosity + .5), rtol=1e-14, atol=0):
raise RuntimeError("block omega/tau mismatch")
build = compiler.generate_config(config)
try:
ptx = compiler.compile_kernel(self.ctx.sm_arch, source="blocks.cu", build_dir=build)
module = compiler.load_module(ptx)
finally:
compiler.cleanup_build_dir(build)
ptr, size = module.get_global("d_params")
if size != struct.calcsize("=ffffI"):
raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", config.omega, 0, 0, 0, 0))
self._module = module
self._current = self._allocate(self._bytes)
self._spare = self._allocate(self._bytes)
self._dst = self._upload(layout.halo_dst)
self._src = self._upload(layout.halo_src)
self._copy = module.get_function("BlockHaloCopy")
self._step = module.get_function("BlockStep")
if self.boundary is not None:
self._tables = {k: self._upload(a) for k, a in self.boundary.items() if k != "solid"}
self._curved = module.get_function("BlockCurvedBoundary")
except BaseException:
try:
self.close()
except Exception:
pass # Preserve the original construction failure.
raise
def _allocate(self, size):
pointer = cuda.mem_alloc(size)
self._allocations.append(pointer)
return pointer
def _upload(self, array):
array = np.ascontiguousarray(array)
pointer = self._allocate(array.nbytes)
cuda.memcpy_htod(pointer, array)
return pointer
def _require(self, *states):
if self._status not in states:
raise RuntimeError(f"Block stepper is {self._status}; expected {' or '.join(states)}")
@property
def step_count(self):
return self._step_count
def initialize(self, g):
"""Upload dense G_0; the spare buffer starts NaN so stale halos cannot hide."""
with self._lock, self.ctx.activate():
self._require("new", "ready")
dense = np.array(g, dtype=np.float64 if self._ddf_shifting else np.float32,
order="C", copy=True)
if dense.shape != (NQ, *self.layout.shape):
raise ValueError("Expected a Q-major (9, ny, nx) array")
fluid = np.ones(self.layout.shape, bool) if self.boundary is None else ~self.boundary["solid"]
if not np.isfinite(dense[:, fluid]).all() or (dense[:, fluid].sum(axis=0) <= 0).any():
raise ValueError("Fluid populations must be finite with positive density")
dense[:, ~fluid] = np.nan # Solid cores are deliberately invalid.
encoded = _encode_physical(dense, self._ddf_shifting)
values = encoded[:, fluid]
if (not np.isfinite(values).all()
or (values.sum(axis=0, dtype=np.float64) + int(self._ddf_shifting) <= 0).any()):
raise ValueError("Stored fluid populations must be finite with positive density")
blocked = self.layout.to_blocked(encoded)
spare = np.full_like(blocked, np.nan)
try:
cuda.memcpy_htod(self._current, blocked)
cuda.memcpy_htod(self._spare, spare)
self._step_count = 0
self._status = "ready"
except BaseException:
self._status = "failed"
raise
def step(self, n=1):
"""Advance n steps: halo refresh, then core pull/collide, per step."""
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
raise ValueError("n must be a nonnegative integer")
with self._lock, self.ctx.activate():
self._require("ready")
kw = dict(block=self._block, stream=self._stream)
try:
self._status = "advancing"
for _ in range(n):
self._copy(self._current, self._dst, self._src,
np.uint32(len(self.layout.halo_dst)), grid=self._copy_grid, **kw)
if self._curved is not None:
t = self._tables
self._curved(self._current, t["block"], t["cell"], t["dir"], t["q"],
t["fallback"], np.uint32(self._links), np.uintp(0), # no actuation
np.uintp(0), np.uintp(0), np.uintp(0), # no observation
grid=self._link_grid, **kw)
self._step(self._current, self._spare, np.uint32(self.layout.halo),
self._tables.get("flags", np.uintp(0)), np.uintp(0), # no kind
grid=self._grid, **kw)
self._stream.synchronize()
self._current, self._spare = self._spare, self._current
self._step_count += 1
self._status = "ready"
except BaseException:
self._status = "failed"
raise
def download(self):
"""Return physical core populations (FP64 when shifted); no host cache."""
snapshot = self._download_raw()
return _decode_stored(snapshot.values[0], snapshot.shifted)
def _download_raw(self):
"""Return one tagged dense FP32 population array without conversion."""
with self._lock, self.ctx.activate():
self._require("ready")
blocked = np.empty(self.layout.blocked_shape, np.float32)
try:
cuda.memcpy_dtoh(blocked, self._current)
except BaseException:
self._status = "failed"
raise
return _StoredPopulations((self.layout.to_dense(blocked),), self._ddf_shifting)
def close(self):
"""Release owned resources; retain them for retry if context activation fails."""
with self._lock:
if self._status == "closed":
return
self._status = "failed"
with self.ctx.activate():
self._status = "closed"
error = None
try:
if self._stream is not None:
self._stream.synchronize()
except Exception as exc:
error = exc
self._copy = self._step = self._curved = self._module = None
self._tables = {}
for pointer in self._allocations:
try:
pointer.free()
except Exception as exc:
error = error or exc
self._allocations.clear()
self._stream = None
if error is not None:
raise error
def __enter__(self):
return self
def __exit__(self, *exc):
try:
self.close()
except Exception:
if exc[0] is None:
raise
+1 -82
View File
@@ -31,7 +31,7 @@ receiver-local boundary values: the solid's owner-core copy of that slot may
be stale, which is distinct from the unique core ownership of stored state.
No moving geometry is implied.
"""
from dataclasses import dataclass, replace
from dataclasses import dataclass
from numbers import Integral
from types import SimpleNamespace
@@ -373,84 +373,3 @@ def blocked_link_tables(layout, flags, fluid_idx, dir, q, fallback):
dir=columns[1].astype(np.uint8), q=columns[2].astype(np.float32),
fallback=columns[3].astype(np.uint8))
return tables, mapped
# One blocked level as FixedPatchStepper builds and launches it (any depth). Raw argument orders of the blocked.cu kernels live only here.
def level_config(cfg, layout, tau):
"""Kernel config of one blocked level: NX/NY = block window incl. halo.
Physical wall/slot/inlet fields are neutralized; a blocked root gets them
from its module globals (d_block_wall/d_block_channel) instead.
"""
ly, lx = layout.local_shape
local = replace(cfg, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
local.validate()
if not np.isclose(local.omega, 1 / tau, rtol=1e-14, atol=0):
raise RuntimeError("block level omega/tau mismatch")
return local
def launch_halo_copy(kernel, buffer, dst, src, count, *, block, stream):
"""BlockHaloCopy over ``count`` uploaded (dst, src) block-cell pairs."""
kernel(buffer, dst, src, np.uint32(count),
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def launch_curved_boundary(kernel, buffer, tables, count, *, block, stream,
velocity=None, rx=None, ry=None, raw=None):
"""BlockCurvedBoundary on uploaded link tables; None = no actuation/observation."""
none = np.uintp(0)
kernel(buffer, tables["block"], tables["cell"], tables["dir"], tables["q"], tables["fallback"],
np.uint32(count), none if velocity is None else velocity,
none if rx is None else rx, none if ry is None else ry, none if raw is None else raw,
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def static_circle_tables(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
from .descriptors import FLUID, SOLID
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
cx, cy = obj.geometry.center
radius = obj.geometry._radius
if not np.isfinite([cx, cy, radius]).all() or radius <= 0:
raise ValueError("Block circle center/radius must be finite with radius > 0")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
tables, _ = blocked_link_tables(
layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
np.array([link.dir for link in links], np.uint8),
np.array([link.q for link in links], np.float32),
np.array([link.fallback for link in links], np.uint8))
return dict(solid=solid, **tables)
+4 -2
View File
@@ -94,10 +94,12 @@ class CurvedLinkSoA:
"""
self.surface_velocity = self.aperture = None
self.jets, self.jet_shape, self.jet_id = tuple(jets), None, None
from ..body.jets import _surface_links
self.surface_velocity, self.aperture, self.jet_shape, self.jet_id = _surface_links(
self.surface_velocity, self.aperture = surface_link_velocity(
objects, cfg, flags, self.fluid_idx, self.dir, self.body_id, self.rx, self.ry,
jets=self.jets, hits=hits)
if self.jets:
from ..body.jets import jet_link_shapes
self.jet_shape, self.jet_id = jet_link_shapes(self.jets, hits)
def set_jet_amplitude(self, index, amplitude, stream=None):
"""Rewrite jet rows from cached shapes; re-upload only the velocity column.
-49
View File
@@ -1,49 +0,0 @@
// CelerisLab/lbm/kernels/blocked.cu
// One fixed-size blocked level (lbm/blocks.py layout), specialized at compile
// time for periodic/fine, slot/reservoir or channel stepping. Separate
// compilation entry: NX, NY are the block window including halo, so index_f
// and linear_index address one block; blockIdx.z selects the block, whose
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "core/cell_kind.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "operators/collision_mrt.cuh"
#endif
#include "streaming/pull_double_buffer.cuh"
#include "boundary/curved_boundary.cuh"
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
#error "Blocks require FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
#ifndef BLOCK_ROOT_BC
#error "Blocks require a generated BLOCK_ROOT_BC mode"
#elif BLOCK_ROOT_BC < 0 || BLOCK_ROOT_BC > 2
#error "BLOCK_ROOT_BC must be 0, 1 or 2"
#endif
#if BLOCK_ROOT_BC == 1
#include "boundary/block_wall.cuh"
#endif
#if BLOCK_ROOT_BC != 2
#include "operators/block_collision.cuh"
#endif
// Kernels, in definition order: transfers, boundary staging, then the one full
// step selected for this module (BlockStep, or BlockChannelStep for a channel root).
#include "step/block_transfer.cu"
#include "step/block_boundary.cu"
#if BLOCK_ROOT_BC != 2
#include "step/block_step.cu"
#else
#include "step/block_channel.cu"
#endif
+206
View File
@@ -0,0 +1,206 @@
// CelerisLab/lbm/kernels/blocks.cu
// One periodic level of fixed-size blocks (lbm/blocks.py layout). Separate
// compilation entry: NX, NY are the block window including halo, so index_f
// and linear_index address one block; blockIdx.z selects the block, whose
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "operators/collision_mrt.cuh"
#endif
#include "streaming/pull_double_buffer.cuh"
#include "boundary/curved_boundary.cuh"
#include "boundary/planar_velocity.cuh"
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
#error "Blocks require FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
// Fixed-patch cell kinds, matching refinement.py ACTIVE/INNER_GHOST/OUTER_GHOST.
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
// Zero-initialized for ordinary blocks/fine levels. Set only for parent walls;
// NX/NY remain local storage strides, never physical wall coordinates.
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
extern "C" { __constant__ BlockWall d_block_wall; }
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
extern "C" __global__ void BlockHaloCopy(
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t d = dst[row], s = src[row];
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
}
// Fixed-patch explosion into blocked fine storage: target = owner block cell
// id of a patch-ghost cell; donors are parent block cell ids. Dense parent
// storage is the one-window case with coarse_cells = full domain size.
extern "C" __global__ void BlockExplode(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const uint64_t d = donor[row];
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
for (unsigned int q = 0; q < NQ; ++q)
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.
// Keep child summation order identical to PatchCoarseStep; no collision here.
extern "C" __global__ void BlockCoalesce(
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t* s = children + 4ul * row;
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
}
// Static cut links, receiver-local staging: cell is the receiver's local core
// cell, so the existing BFL writes the solid-source window image that this
// block's next pull reads (no halo copy between this kernel and BlockStep).
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
extern "C" __global__ void BlockCurvedBoundary(
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
const float* q, const unsigned char* fallback, unsigned int count,
const float* surface_velocity, const float* rx, const float* ry, float* raw)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
float Uw = 0.0f, Vw = 0.0f;
if (surface_velocity != nullptr) {
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
}
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
raw ? raw + 4ul * row + 3 : nullptr);
}
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
// no cross-row reduction and no history allocation proportional to substeps.
extern "C" __global__ void BlockAccumulateExchange(
const float* raw, double* integral, unsigned int count, double h, double dt)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const unsigned long k = 4ul * row;
const double impulse = h * dt;
integral[k] += impulse * (double)raw[k];
integral[k + 1] += impulse * (double)raw[k + 1];
integral[k + 2] += h * impulse * (double)raw[k + 2];
integral[k + 3] += h * h * (double)raw[k + 3];
}
// Pull/collide/store core cells only; halo cells of out are left untouched.
// flags (block cell ids, nullable): solid cores store NaN and never collide.
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
const uint16_t* flags, const unsigned char* kind) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
float f[NQ];
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
stream_pull_load(k, f, in + base, j);
if (cell_kind == KIND_ACTIVE) {
if (d_block_wall.nx != 0) {
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = d_block_wall.nx / bx;
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
if (gy == 1)
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
}
#if COLLISION_MODEL == 2
// Uniform MRT sequence (collide_dispatch, zero forcing); SRT path below unchanged.
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
#if USE_DDF_SHIFTING
// Preserve the density perturbation before adding the unit reference.
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, d_params.omega);
#endif
}
}
stream_pull_store(k, f, out + base);
}
// Root channel closure; fine and periodic levels keep BlockStep.
#include "step/block_channel.cu"
@@ -1,20 +0,0 @@
// Physical parent walls; NX/NY remain local storage strides.
#ifndef CELERIS_BOUNDARY_BLOCK_WALL_CUH
#define CELERIS_BOUNDARY_BLOCK_WALL_CUH
#include "planar_velocity.cuh"
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
extern "C" { __constant__ BlockWall d_block_wall; }
__device__ __forceinline__ void block_wall(float* f, unsigned int x, unsigned int y,
unsigned int halo) {
unsigned int gx, gy;
block_global_xy(x, y, halo, d_block_wall.nx, gx, gy);
if (gy == 1)
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
}
#endif
@@ -15,13 +15,10 @@
// D2Q9 y-wall half-way bounce-back for pull double-buffer streaming.
// ---------------------------------------------------------------------------
#if LATTICE_MODEL == LATTICE_D2Q9
// Rows 1 and top_row (= physical ny - 2) next to walls at rows 0 and ny - 1.
// y is the physical row; k addresses the node in fi_in (a block window may
// have NX/NY strides different from the physical extent).
__device__ inline void apply_wall_bb_d2q9_rows(unsigned int y, unsigned int top_row,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
{
if (y == 1) {
// Directions sourced from y=0 wall in pull step: +y, +x+y, -x+y.
@@ -29,22 +26,14 @@ __device__ inline void apply_wall_bb_d2q9_rows(unsigned int y, unsigned int top_
f[5] = load_ddf(fi_in, index_f(k, 6u));
f[8] = load_ddf(fi_in, index_f(k, 7u));
}
else if (y == top_row) {
// Directions sourced from the y = ny - 1 wall in pull step: -y, -x-y, +x-y.
else if (y == (unsigned int)(NY - 2)) {
// Directions sourced from y=NY-1 wall in pull step: -y, -x-y, +x-y.
f[4] = load_ddf(fi_in, index_f(k, 3u));
f[6] = load_ddf(fi_in, index_f(k, 5u));
f[7] = load_ddf(fi_in, index_f(k, 8u));
}
}
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
{
apply_wall_bb_d2q9_rows(y, (unsigned int)(NY - 2), f, fi_in, k);
}
// ---------------------------------------------------------------------------
// D2Q9 y-wall free-slip (specular reflection) for pull double-buffer streaming.
//
@@ -8,9 +8,6 @@
#define STORE_PRECISION 0
#define USE_DDF_SHIFTING 0
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
#define BLOCK_ROOT_BC 0
#define USE_LES 0
#define LES_CS 0.160000f
#define LES_CLOSED_FORM 1
@@ -1,16 +0,0 @@
// CelerisLab – core/cell_kind.cuh
// Fixed-patch cell kinds stored per block cell, matching lbm/refinement.py
// ACTIVE/INNER_GHOST/OUTER_GHOST: OUTER is invalid (stores NaN, no pull),
// INNER streams without collision, ACTIVE pulls and collides.
#ifndef CELERIS_CORE_CELL_KIND_CUH
#define CELERIS_CORE_CELL_KIND_CUH
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
// NaN populations mark a cell without valid state (OUTER ghost or solid core).
__device__ __forceinline__ void fill_invalid(float* f)
{
for (int q = 0; q < NQ; ++q) f[q] = NAN;
}
#endif
@@ -53,18 +53,6 @@ __device__ __forceinline__ void index_from_thread(unsigned int& x,
k = (unsigned long)y * NX + x;
}
// Global cell of window cell (x, y) in block blockIdx.z of a row-major,
// full-domain block layout domain_nx cells wide. NX/NY are the local strides.
__device__ __forceinline__ void block_global_xy(unsigned int x, unsigned int y,
unsigned int halo, unsigned int domain_nx,
unsigned int& gx, unsigned int& gy)
{
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = domain_nx / bx;
gx = (blockIdx.z % nbx) * bx + x - halo;
gy = (blockIdx.z / nbx) * by + y - halo;
}
// ---------------------------------------------------------------------------
// Neighbor list (periodic BCs via modular arithmetic)
// ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
// CelerisLab – uniform.cu
// CelerisLab – kernel_v2.cu
// ============================================================================
// Compilation entry point (Stage 2 Architecture).
//
@@ -7,7 +7,7 @@
//
// File layout (each <300 lines):
// operators/helpers.cuh — bounce_back_swap, collide_dispatch
// step/init_flow.cu — InitTubeFlow, InitEsoPull
// step/init_flow.cu — InitTubeFlow_v2, InitEsoPull
// step/one_step_double.cu — OneStep (pull double-buffer)
// step/one_step_esopull.cu — EsoPullStep (single-buffer)
// step/aux_kernels.cu — CurvedBoundaryKernel, SensorKernel
@@ -1,31 +0,0 @@
// Collision used by periodic/fine and slot/reservoir BlockStep.
#ifndef CELERIS_OPERATORS_BLOCK_COLLISION_CUH
#define CELERIS_OPERATORS_BLOCK_COLLISION_CUH
#include "../core/params.cuh"
#include "macro.cuh"
#include "collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "collision_mrt.cuh"
#endif
__device__ __forceinline__ void block_collide(float* f) {
#if COLLISION_MODEL == 2
collide_mrt_unforced(f); // SRT path below unchanged.
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
#if USE_DDF_SHIFTING
// Preserve the density perturbation before adding the unit reference.
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, d_params.omega);
#endif
}
#endif
@@ -1,14 +0,0 @@
// CelerisLab – operators/bounce_back_swap.cuh
// Full-way bounce-back at a wall node: swap every opposite pair (paired
// order q, q+1). Needs only NQ; shared by the uniform and blocked steps.
#ifndef CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
#define CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
__device__ __forceinline__ void bounce_back_swap(float* f) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
#endif
@@ -114,16 +114,6 @@ __device__ __forceinline__ void collide_mrt_no_force(float* __restrict__ g,
collide_mrt(g, rho, ux, uy, Fin, omega);
}
// Unforced MRT with the clamped device omega, as in collide_dispatch with zero
// forcing. Shared by the blocked, channel-root and dense-patch collisions.
__device__ __forceinline__ void collide_mrt_unforced(float* __restrict__ g)
{
float rho, ux, uy;
compute_rho_u(g, rho, ux, uy);
collide_mrt_no_force(g, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
}
#elif LATTICE_MODEL == LATTICE_D3Q19
__device__ __forceinline__ void collide_mrt(float* __restrict__ g,
@@ -5,7 +5,12 @@
#ifndef CELERIS_OPERATORS_HELPERS_CUH
#define CELERIS_OPERATORS_HELPERS_CUH
#include "bounce_back_swap.cuh"
__device__ __forceinline__ void bounce_back_swap(float* f) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
__device__ __forceinline__ void collide_dispatch(
float* f, float rho_n,
+93 -3
View File
@@ -9,7 +9,6 @@
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "core/cell_kind.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
@@ -36,5 +35,96 @@ __device__ unsigned int incoming_counts[NQ * TOTAL_CELLS];
}
#endif
// Kernels: patch collision helper, PatchExplode, PatchFineStep, PatchCoarseStep.
#include "step/patch_step.cu"
__device__ __forceinline__ void patch_collide(float* f, unsigned long k) {
#if COLLISION_MODEL == 2
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
collide_srt_no_force(f, feq, d_params.omega);
#endif
#ifdef CELERIS_REFINEMENT_AUDIT
++collision_counts[k];
#endif
}
extern "C" __global__ void PatchExplode(
const float* coarse, float* fine, const uint32_t* targets,
const uint32_t* donors, unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
for (unsigned int q = 0; q < NQ; ++q)
fine[index_f(targets[row], q)] = coarse[q * coarse_cells + donors[row]];
#ifdef CELERIS_REFINEMENT_AUDIT
++explosion_counts[targets[row]];
#endif
}
extern "C" __global__ void PatchFineStep(const float* in, float* out, const uint16_t* flags) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
float f[NQ];
// Outer halo is invalid after EACH hop, including when reusing the buffer
// that held explosion data. Inner halo only streams; it never collides.
if (x < 2 || y < 2 || x >= NX - 2 || y >= NY - 2
|| (flags != nullptr && is_solid(flags[k]))) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // These interior targets never wrap.
stream_pull_load(k, f, in, j);
if (x >= 4 && y >= 4 && x < NX - 4 && y < NY - 4)
patch_collide(f, k);
}
stream_pull_store(k, f, out);
}
extern "C" __global__ void PatchCoarseStep(
const float* in, float* out, const float* fine,
const uint64_t* targets, const uint64_t* children, unsigned int count,
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
unsigned int wall_aware)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
// Both buffers were poisoned at initialization; covered cells stay invalid.
if (x >= x0 && x < x1 && y >= y0 && y < y1) return;
if (wall_aware && (y == 0 || y == NY - 1)) return;
unsigned long j[NQ];
compute_neighbors(x, y, j);
float f[NQ];
stream_pull_load(k, f, in, j);
if (x >= x0 - 1 && x <= x1 && y >= y0 - 1 && y <= y1) {
for (unsigned int q = 1; q < NQ; ++q) {
uint64_t slot = index_f(k, q);
unsigned int lo = 0, hi = count;
while (lo < hi) {
unsigned int mid = lo + (hi - lo) / 2;
if (targets[mid] < slot) lo = mid + 1;
else hi = mid;
}
if (lo < count && targets[lo] == slot) {
const uint64_t* s = children + 4ul * lo;
f[q] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
#ifdef CELERIS_REFINEMENT_AUDIT
++incoming_counts[slot];
#endif
}
}
}
// Includes the eight grazing-corner links with UNCOVERED coarse donors.
// All replacement arrivals must be in place before computing rho/u.
if (wall_aware) {
if (y == 1) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x));
else if (y == NY - 2) reconstruct_top_reservoir(f, RHO);
}
patch_collide(f, k);
stream_pull_store(k, f, out);
}
@@ -3,7 +3,7 @@
// ApplyEsoPullCurvedKernel, SensorKernel, ForceRegionKernel.
//
// Launched on compact lists (n_curved / n_sensor / n_cells threads).
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// Double-buffer mode:
// CurvedBoundaryKernel runs BEFORE OneStep.
@@ -1,42 +0,0 @@
// CelerisLab/lbm/kernels/step/block_boundary.cu
// Receiver-local curved-boundary staging and its raw-exchange accumulation.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
// Static cut links, receiver-local staging: cell is the receiver's local core
// cell, so the existing BFL writes the solid-source window image that this
// block's next pull reads (no halo copy between this kernel and BlockStep).
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
extern "C" __global__ void BlockCurvedBoundary(
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
const float* q, const unsigned char* fallback, unsigned int count,
const float* surface_velocity, const float* rx, const float* ry, float* raw)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
float Uw = 0.0f, Vw = 0.0f;
if (surface_velocity != nullptr) {
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
}
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
raw ? raw + 4ul * row + 3 : nullptr);
}
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
// no cross-row reduction and no history allocation proportional to substeps.
extern "C" __global__ void BlockAccumulateExchange(
const float* raw, double* integral, unsigned int count, double h, double dt)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const unsigned long k = 4ul * row;
const double impulse = h * dt;
integral[k] += impulse * (double)raw[k];
integral[k + 1] += impulse * (double)raw[k + 1];
integral[k + 2] += h * impulse * (double)raw[k + 2];
integral[k + 3] += h * h * (double)raw[k + 3];
}
@@ -1,12 +1,5 @@
#if BLOCK_ROOT_BC != 2
#error "BlockChannelStep requires BLOCK_ROOT_BC == 2"
#endif
// Root-only blocked counterpart of OneStep's D2Q9/SRT channel closure.
// NX/NY are LOCAL strides; all physical boundary decisions use global coordinates.
#include "../core/cell_kind.cuh"
#include "../operators/bounce_back_swap.cuh"
#include "../boundary/bounce_back.cuh"
#include "../boundary/inlet/common.cuh"
#include "../boundary/inlet/zou_he_local.cuh"
#include "../boundary/outlet/pressure_neq.cuh"
@@ -22,13 +15,15 @@ extern "C" __global__ void BlockChannelStep(
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
unsigned int gx, gy;
block_global_xy(x, y, halo, d_block_channel.nx, gx, gy);
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = d_block_channel.nx / bx;
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
float f[NQ];
if (kind != nullptr && kind[cell] == KIND_OUTER) {
fill_invalid(f);
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j);
@@ -36,21 +31,35 @@ extern "C" __global__ void BlockChannelStep(
const bool wall = gy == 0 || gy == d_block_channel.ny - 1;
const bool outlet = gx == d_block_channel.nx - 1;
if (wall) {
bounce_back_swap(f); // Wall owns all four corners; ghosts never collide.
// Wall owns all four corners. Ghosts pull and swap, never collide.
for (int q = 1; q < NQ; q += 2) {
const float t = f[q]; f[q] = f[q + 1]; f[q + 1] = t;
}
} else if (gx == 0) {
apply_zou_he_left_velocity_inlet_d2q9(
f, inlet_target_u((float)gy, d_block_channel.ny, d_block_channel.velocity), 0.0f);
} else if (outlet) {
// Immutable input donor, receiver-local (also valid across a block seam).
float donor[NQ];
load_node(in + base, k - 1, donor);
for (int q = 0; q < NQ; ++q)
donor[q] = load_ddf(in + base, index_f(k - 1, q));
apply_pressure_outlet_d2q9(f, donor);
} else {
apply_wall_bb_d2q9_rows(gy, d_block_channel.ny - 2, f, in + base, k);
} else if (gy == 1) {
f[3] = load_ddf(in + base, index_f(k, 4));
f[5] = load_ddf(in + base, index_f(k, 6));
f[8] = load_ddf(in + base, index_f(k, 7));
} else if (gy == d_block_channel.ny - 2) {
f[4] = load_ddf(in + base, index_f(k, 3));
f[6] = load_ddf(in + base, index_f(k, 5));
f[7] = load_ddf(in + base, index_f(k, 8));
}
if (!wall && !outlet) {
#if COLLISION_MODEL == 2
collide_mrt_unforced(f);
// Match uniform MRT arithmetic (collide_dispatch, zero forcing).
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
// Match uniform SRT arithmetic, including its shifted equilibrium path.
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
@@ -1,35 +0,0 @@
// CelerisLab/lbm/kernels/step/block_step.cu
// Generic blocked pull/collide/store for periodic/fine and slot/reservoir levels.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
#if BLOCK_ROOT_BC == 2
#error "BlockStep is not built for the channel root (BLOCK_ROOT_BC == 2)"
#endif
// Pull/collide/store core cells only; halo cells of out are left untouched.
// flags (block cell ids, nullable): solid cores store NaN and never collide.
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
const uint16_t* flags, const unsigned char* kind) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
float f[NQ];
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
fill_invalid(f);
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
stream_pull_load(k, f, in + base, j);
if (cell_kind == KIND_ACTIVE) {
#if BLOCK_ROOT_BC == 1
block_wall(f, x, y, halo);
#endif
block_collide(f);
}
}
stream_pull_store(k, f, out + base);
}
@@ -1,78 +0,0 @@
// CelerisLab/lbm/kernels/step/block_transfer.cu
// Same-level halo refresh and 2:1 explosion/coalescence between blocked levels.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
extern "C" __global__ void BlockHaloCopy(
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t d = dst[row], s = src[row];
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
}
// Fixed-patch explosion into blocked fine storage: target = owner block cell
// id of a patch-ghost cell; donors are parent block cell ids. Dense parent
// storage is the one-window case with coarse_cells = full domain size.
extern "C" __global__ void BlockExplode(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const uint64_t d = donor[row];
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
for (unsigned int q = 0; q < NQ; ++q)
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.
// Keep child summation order identical to PatchCoarseStep; no collision here.
extern "C" __global__ void BlockCoalesce(
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t* s = children + 4ul * row;
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
}
@@ -3,7 +3,7 @@
//
// Decodes the EsoPull backing layout into physical distributions, then
// computes rho/ux/uy. Output is SoA: rho_out[N], ux_out[N], uy_out[N].
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// This kernel is the sole path for macroscopic diagnostics in EsoPull mode.
// It uses the shared semantic helper layer (esopull_semantic_helpers.cuh).
@@ -5,7 +5,7 @@
// or encodes physical DDF back to EsoPull backing layout.
// Each thread handles one cell from a compact index list.
//
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_ESOPULL_SYNC_CU
+3 -3
View File
@@ -1,6 +1,6 @@
// CelerisLab – step/init_flow.cu
// Channel-flow initialization kernels (double-buffer and EsoPull).
// Included by uniform.cu after all headers. No standalone compilation.
// Included by kernel_v2.cu after all headers. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_INIT_FLOW_CU
@@ -73,9 +73,9 @@ __device__ __forceinline__ uint16_t finalize_domain_flag(
}
// ============================================================================
// InitTubeFlow — double-buffer channel initialization
// InitTubeFlow_v2 — double-buffer channel initialization
// ============================================================================
__global__ void InitTubeFlow(uint16_t* flag, fpxx* fi)
__global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y; unsigned long k;
@@ -1,6 +1,6 @@
// CelerisLab – step/one_step_double.cu
// Double-buffer PULL streaming step kernel.
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_ONE_STEP_DOUBLE_CU
@@ -1,6 +1,6 @@
// CelerisLab – step/one_step_esopull.cu
// Esoteric-Pull single-buffer step kernel.
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// CURVED BC: supported via PrepareEsoPullCurvedKernel + ApplyEsoPullCurvedKernel
// in aux_kernels.cu, launched by stepper.py before EsoPullStep.
@@ -1,95 +0,0 @@
// CelerisLab/lbm/kernels/step/patch_step.cu
// Dense fixed-patch collision, explosion and the fine/coarse steps.
// Part of the refinement.cu compilation entry; relies on its includes, guards
// and the optional CELERIS_REFINEMENT_AUDIT counters declared there.
__device__ __forceinline__ void patch_collide(float* f, unsigned long k) {
#if COLLISION_MODEL == 2
collide_mrt_unforced(f);
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
collide_srt_no_force(f, feq, d_params.omega);
#endif
#ifdef CELERIS_REFINEMENT_AUDIT
++collision_counts[k];
#endif
}
extern "C" __global__ void PatchExplode(
const float* coarse, float* fine, const uint32_t* targets,
const uint32_t* donors, unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
for (unsigned int q = 0; q < NQ; ++q)
fine[index_f(targets[row], q)] = coarse[q * coarse_cells + donors[row]];
#ifdef CELERIS_REFINEMENT_AUDIT
++explosion_counts[targets[row]];
#endif
}
extern "C" __global__ void PatchFineStep(const float* in, float* out, const uint16_t* flags) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
float f[NQ];
// Outer halo is invalid after EACH hop, including when reusing the buffer
// that held explosion data. Inner halo only streams; it never collides.
if (x < 2 || y < 2 || x >= NX - 2 || y >= NY - 2
|| (flags != nullptr && is_solid(flags[k]))) {
fill_invalid(f);
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // These interior targets never wrap.
stream_pull_load(k, f, in, j);
if (x >= 4 && y >= 4 && x < NX - 4 && y < NY - 4)
patch_collide(f, k);
}
stream_pull_store(k, f, out);
}
extern "C" __global__ void PatchCoarseStep(
const float* in, float* out, const float* fine,
const uint64_t* targets, const uint64_t* children, unsigned int count,
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
unsigned int wall_aware)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
// Both buffers were poisoned at initialization; covered cells stay invalid.
if (x >= x0 && x < x1 && y >= y0 && y < y1) return;
if (wall_aware && (y == 0 || y == NY - 1)) return;
unsigned long j[NQ];
compute_neighbors(x, y, j);
float f[NQ];
stream_pull_load(k, f, in, j);
if (x >= x0 - 1 && x <= x1 && y >= y0 - 1 && y <= y1) {
for (unsigned int q = 1; q < NQ; ++q) {
uint64_t slot = index_f(k, q);
unsigned int lo = 0, hi = count;
while (lo < hi) {
unsigned int mid = lo + (hi - lo) / 2;
if (targets[mid] < slot) lo = mid + 1;
else hi = mid;
}
if (lo < count && targets[lo] == slot) {
const uint64_t* s = children + 4ul * lo;
f[q] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
#ifdef CELERIS_REFINEMENT_AUDIT
++incoming_counts[slot];
#endif
}
}
}
// Includes the eight grazing-corner links with UNCOVERED coarse donors.
// All replacement arrivals must be in place before computing rho/u.
if (wall_aware) {
if (y == 1) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x));
else if (y == NY - 2) reconstruct_top_reservoir(f, RHO);
}
patch_collide(f, k);
stream_pull_store(k, f, out);
}
@@ -21,14 +21,6 @@
// i.e. the population traveling in direction i at node n
// arrived from the node at n - c_i = neighbor in direction opp(i)
// ---------------------------------------------------------------------------
// load_node: all NQ populations of node n as stored (no streaming).
__device__ __forceinline__ void load_node(const fpxx* __restrict__ fi, unsigned long n,
float* __restrict__ f)
{
for (int q = 0; q < NQ; ++q)
f[q] = load_ddf(fi, index_f(n, q));
}
__device__ __forceinline__ void stream_pull_load(unsigned long n,
float* __restrict__ f,
const fpxx* __restrict__ fi,
+186 -167
View File
@@ -14,16 +14,15 @@ from threading import RLock
import numpy as np
import pycuda.driver as cuda
from ..cuda import compiler
from .blocks import (BlockLayout2D, launch_curved_boundary, launch_halo_copy, level_config,
static_circle_tables)
from ..cuda import compiler_v2 as compiler
from .blocks import BlockLayout2D
from .channel import channel_masks, validate_channel_config, validate_channel_transfers
from ..body.jets import jet_link_velocity
from .block_stepper import MAX_GRID_DIM
from .hierarchy import build_hierarchy, is_rectangle
from .refinement import ACTIVE, HALO, FixedPatch2D, FixedPatchSet2D, prepare_fine_boundary
from .refinement_tables import (blocked_base_tables, blocked_fine_tables, # noqa: F401 (re-export)
blocked_kind, projected_explosion_tables, _prepare_blocked_boundary,
_BlockedBoundary,
BLOCKED_BOUNDARY_DEVICE_COLUMNS)
from .descriptors import SOLID
from .raw_exchange import summarize_exchange
@@ -66,27 +65,67 @@ def _level_name(level):
return ("base", "fine", "child")[level] if level < 3 else f"level {level}"
MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks)
def _legacy_levels(bounds, *, fine_block, base_block, child, child_block, child_rectangles_in_parent,
fine_objects, jets, child_objects, child_jets):
"""Normalise the original two/three-level keywords to per-level lists (original errors)."""
if child is not None and child_rectangles_in_parent is not None:
raise ValueError("child and child_rectangles_in_parent are mutually exclusive")
nested = child is not None or child_rectangles_in_parent is not None
bounds = tuple(bounds)
if not is_rectangle(bounds) and (fine_block is None or base_block is None):
raise ValueError("several patches require fine_block and base_block")
fine_objects, jets = tuple(fine_objects), tuple(jets)
child_objects, child_jets = tuple(child_objects), tuple(child_jets)
if not nested and (child_objects or child_jets):
raise ValueError("child_objects/child_jets require a nested child")
if base_block is not None and fine_block is None:
raise ValueError("base_block requires fine_block")
refinements, blocks = [bounds], [base_block, fine_block]
objects, level_jets = [(), fine_objects], [(), jets]
if nested:
if child is not None and not is_rectangle(bounds):
raise ValueError("scalar child requires one parent patch; use child_rectangles_in_parent")
if fine_block is None or base_block is None or child_block is None:
raise ValueError("a nested child needs fine_block, base_block and child_block")
if fine_objects or jets:
raise ValueError("a nested child supports bodies/Jets only on L2; use child_objects/child_jets")
refinements.append(tuple(child) if child is not None else tuple(child_rectangles_in_parent))
blocks.append(child_block)
objects.append(child_objects)
level_jets.append(child_jets)
return refinements, blocks, objects, level_jets
def _initial_levels(level_g, coarse_g, fine_g, child_g):
"""One tuple of per-level arrays from initialize()'s positional or legacy named form."""
if coarse_g is None and fine_g is None:
if child_g is not None and len(level_g) != 2:
raise TypeError("child_g needs exactly the coarse and fine arrays before it")
return tuple(level_g) + (() if child_g is None else (child_g,))
if level_g:
raise TypeError("pass level arrays positionally or as coarse_g/fine_g/child_g, not both")
if coarse_g is None or fine_g is None:
raise TypeError("coarse_g and fine_g are both required in the named form")
return (coarse_g, fine_g) + (() if child_g is None else (child_g,))
def _check_levels(refinements, blocks, objects, jets):
"""Candidate limits of the level lists, checked before any device work."""
"""Candidate limits shared by the list and the legacy entry points."""
depth = len(refinements) + 1
if depth < 2:
raise ValueError("at least one refinement level is required")
for name, values in (("level_blocks", blocks), ("level_objects", objects), ("level_jets", jets)):
if len(values) != depth:
raise ValueError(f"{name} needs one entry per level ({depth})")
for level, block in enumerate(blocks):
if block is not None and len(block) != 2:
raise ValueError(f"level_blocks[{level}] must be (by, bx)")
if depth == 1: # one blocked level: the root carries the static circles
if blocks[0] is None or jets[0]:
raise ValueError("a single level must be blocked and has no Jets")
return
raise ValueError(f"{_level_name(level)}_block must be (by, bx)")
if objects[0] or jets[0]:
raise ValueError("base-level bodies/Jets are not supported")
if any(blocks[level - 1] is not None and blocks[level] is None for level in range(1, depth)):
raise ValueError("a blocked level requires blocked refined levels below it")
if not is_rectangle(refinements[0]) and (blocks[0] is None or blocks[1] is None):
raise ValueError("several L1 patches require level_blocks[0] and level_blocks[1]")
raise ValueError("several patches require fine_block and base_block")
if depth > 2:
if any(block is None for block in blocks):
raise ValueError("three or more levels must all be blocked")
@@ -95,17 +134,7 @@ def _check_levels(refinements, blocks, objects, jets):
class FixedPatchStepper:
"""D2Q9 SRT/MRT FP32 fixed 2:1 refinement hierarchy, built from level lists.
``refinements[l]`` gives level l+1 in level-l integer cells: one rectangle
(x0, y0, x1, y1) (FixedPatch2D, a fine window with its own origin) or a
sequence of rectangles (FixedPatchSet2D, a dependency-validated union on one
level-wide grid, public fine arrays (2*ny, 2*nx), NaN outside ACTIVE cells);
see lbm.hierarchy. ``level_blocks``, ``level_objects`` and ``level_jets``
hold one entry per level, L0 included (L0 bodies/Jets are not supported).
``refinements=[]`` is one blocked level: periodic SRT root without LES,
Jets or exchange observation; its ``level_objects[0]`` are stationary
passive circles (static BFL).
"""D2Q9/SRT/FP32, one unforced internal 2:1 patch in an x-periodic box.
``coarse_boundary`` is ``periodic`` (default) or ``slot_reservoir``:
a bottom on-site slot and top fixed-density reservoir from ``cfg``.
@@ -116,55 +145,68 @@ class FixedPatchStepper:
actual channel closure footprint. Channel remapping is not supported.
Periodic mode does not apply the wall/slot boundary fields in ``cfg``.
Outer walls remain coarse; fine halo donors must be ordinary fluid.
``fine_objects`` accepts fixed circles in coarse physical coordinates;
velocities are in coarse lattice units (dx=dt=1). Independent local copies
are projected at fine cell centers. Their complete stencil must be active
and disjoint from the actual two-hop transfer footprint.
These use the edge/cell-center frame (coarse centers I+.5), whereas ordinary Simulation objects use the integer-node frame.
Bodies are fixed circles in root (L0) physical coordinates; velocities are
in root lattice units (dx=dt=1). Independent local copies are projected at
fine cell centers; their complete stencil must be active and disjoint from
the actual two-hop transfer footprint. These use the edge/cell-center frame
(coarse centers I+.5), whereas ordinary Simulation objects use the
integer-node frame. Jets (body.jets.Jet) use the same frame and drive cut
links selected at their true hits; ``set_jet_amplitude(index, amplitude,
level=l)`` re-uploads the per-link velocity column only.
``initialize(*level_g)`` accepts one physical Q-major (9, ny, nx) array per
level, already in postcollision phase. It does not collide or build channel
flags. Every ``step()`` publishes only after all levels complete on one
stream. On a device/launch failure the instance becomes unusable: close and
recreate it, rather than pretending a partially advanced buffer rolled back.
A ``level_blocks`` entry (by, bx) stores that level in blocked.cu storage:
the ENTIRE level shape, including the four-cell patch ghost band, is
partitioned by a non-periodic BlockLayout2D with one-cell same-level halos.
Patch kinds stay per cell (OUTER invalid, INNER stream only, ACTIVE
collide); halos are gathered before each hop. Public arrays keep the dense
shapes; only the allocation differs. A blocked level requires blocked
refined levels below it; several L1 rectangles and depth >= 3 require all
levels blocked. Blocked bodies are stationary circles, optionally
surface-actuated: prepare_fine_boundary decides legality on the dense grid,
then cut links are staged receiver-local per hop: halo -> BFL -> step, same
buffer. A blocked parent gathers halos before receiver-local coalescence
staging and pull/collision; covered cells are poisoned on every step.
``initialize(coarse_g, fine_g)`` accepts physical Q-major (9,ny,nx) arrays
already in postcollision phase. It does not collide or build channel flags.
Every ``step()`` publishes only after both levels complete on one stream.
On a device/launch failure the instance becomes unusable: close and recreate
it, rather than pretending the partially advanced fine buffer rolled back.
``fine_block=(by, bx)`` (opt-in) stores the fine level in
blocks.cu storage: the ENTIRE fine_shape, including the four-cell patch
ghost band, is partitioned by a non-periodic BlockLayout2D with one-cell
same-level halos. Patch kinds stay per cell (OUTER invalid, INNER stream
only, ACTIVE collide); halos are gathered before each fine hop. Public
arrays keep the dense (9, ny, nx) fine shape; only the allocation differs.
Blocked fine_objects are stationary circles, optionally surface-actuated:
prepare_fine_boundary decides legality on the dense fine grid, then cut
links are staged receiver-local per hop: halo -> BFL -> step, same buffer.
``base_block=(by, bx)`` also blocks the parent; it requires ``fine_block``.
Parent halos precede receiver-local coalescence staging and pull/collision;
covered cells are explicitly poisoned on every step. Public shapes stay dense.
``observe_raw_exchange=True`` records blocked physical cut-link exchange
without changing populations. read_raw_exchange reports the last complete
base interval or last substep in base-lattice units per unit depth. This
is boundary momentum reaction, not a validated nozzle/stress load. Dense
body levels are not supported by this diagnostic.
body layouts are not supported by this new diagnostic in this candidate.
``observe_window_exchange=True`` (requires observe_raw_exchange) adds a second
per-link FP64 ledger accumulated by the same kernel between
start_exchange_window() and read_exchange_window(); disabled by default.
``jets`` (body.jets.Jet) use the same edge frame as ``fine_objects`` and
drive fine cut links selected at their true hits; ``set_jet_amplitude``
re-uploads the per-link velocity column only.
Nested levels: a child set's coordinates index the allocated parent array,
including ghost bands and holes: root edge = parent fine_origin + index / 2
(zero origin for a parent set; a scalar parent keeps its shifted window
origin). A scalar child requires a scalar parent. Rectangle unions impose no
fixed gap rule: adjacent, overlapping and concave unions are accepted when
their actual transfer dependencies close (FixedPatchSet2D /
nest_patch_set). All regions share one buffer pair per level and the fixed
schedule of 2**l hops per base step at level l. Bodies/Jets are supported only on the
finest level of a nested hierarchy (L2 dx=dt=.25 in root units, unchanged
lattice velocity); every whole circle and its BFL stencil must fit strictly
away from that level's transfer footprint.
Several patches: pass ``bounds`` as a sequence of (x0, y0, x1, y1)
rectangles (FixedPatchSet2D, dependency-validated union). This requires
fine_block and base_block; fine_objects/jets are projected once onto the
level-wide fine grid (each circle inside the union, body ids = object
positions, jet and writer checks level-wide), and
uses one level-wide fine grid (2*ny, 2*nx): public fine arrays have that
shape, NaN outside the patches' ACTIVE cells. The step sequence is the
blocked one above, with unique union transfers in one launch each.
Three-level separated unions: ``child_rectangles_in_parent=[...]`` is
mutually exclusive with scalar ``child`` and accepts either parent form.
Coordinates index the allocated L1 array, including ghost bands and holes:
root edge = patch.fine_origin + L1 index / 2. A parent set has origin zero;
a scalar parent retains its shifted window origin. The child set's public
extent is twice the allocated L1 shape, even though storage is sparse.
Each pair requires gap >= 4 in its own parent-cell units. All regions share
one buffer pair per level and the fixed 1:2:4 schedule; L2-only bodies/Jets
below apply unchanged, with object IDs assigned over the whole level.
Three-level single chain: ``child`` bounds use L1 window cells; all three
levels must be blocked. ``child_objects`` and ``child_jets`` use the L0
edge frame, with L2 dx=dt=.25 and unchanged lattice velocity. L1 bodies
and Jets remain unsupported in a chain. Every whole circle and its BFL
stencil must fit strictly away from the L2 transfer footprint. L2 runs
halo -> BFL -> step four times per base step. Runtime child Jet changes
require ``set_jet_amplitude(index, amplitude, level=2)``.
``explosion="projected_linear"`` selects experimental transverse linear
reconstruction on each adjacent pair. Default "constant" is unchanged.
@@ -172,21 +214,43 @@ class FixedPatchStepper:
bodies/Jets or LES; this backend supplies no forcing. Geometry is generic,
but union/multilevel GPU and matched-physics qualification remain pending.
One recursive hop advances every level: a child explodes from its parent's
current hop source, takes two hops, then the parent gathers halos, stages
restriction, applies BFL and steps.
Level lists (any depth): ``refinements[l]`` gives level l+1 in level-l
integer cells (scalar window or rectangle list, see lbm.hierarchy);
``level_blocks``/``level_objects``/``level_jets`` hold one entry per level,
L0 included. They are mutually exclusive with the legacy keywords above,
which are normalised to the same lists. One recursive hop advances every
level: a child explodes from its parent's current hop source, takes two
hops, then the parent gathers halos, stages restriction, applies BFL and
steps. Depth >= 3 must be fully blocked with bodies/Jets on the finest level.
"""
def __init__(self, cfg, *, context, refinements, coarse_boundary="periodic",
level_blocks=None, level_objects=None, level_jets=None,
def __init__(self, cfg, bounds=None, *, context, coarse_boundary="periodic", fine_objects=(),
fine_block=None, base_block=None, jets=(), child=None, child_block=None,
child_objects=(), child_jets=(), child_rectangles_in_parent=None,
refinements=None, level_blocks=None, level_objects=None, level_jets=None,
observe_raw_exchange=False, observe_window_exchange=False, explosion="constant"):
if HALO != 4:
raise RuntimeError("refinement.cu requires a four-cell fine halo")
if coarse_boundary not in ("periodic", "slot_reservoir", "channel"):
raise ValueError("coarse_boundary must be periodic, slot_reservoir or channel")
depth = len(refinements) + 1
level_blocks = [None] * depth if level_blocks is None else list(level_blocks)
level_objects = [()] * depth if level_objects is None else list(level_objects)
level_jets = [()] * depth if level_jets is None else list(level_jets)
if refinements is None:
if bounds is None:
raise ValueError("bounds or refinements is required")
if level_blocks is not None or level_objects is not None or level_jets is not None:
raise ValueError("level_blocks/level_objects/level_jets require refinements")
refinements, level_blocks, level_objects, level_jets = _legacy_levels(
bounds, fine_block=fine_block, base_block=base_block, child=child,
child_block=child_block, child_rectangles_in_parent=child_rectangles_in_parent,
fine_objects=fine_objects, jets=jets, child_objects=child_objects,
child_jets=child_jets)
else:
if (bounds is not None or child is not None or child_rectangles_in_parent is not None
or fine_block is not None or base_block is not None or child_block is not None
or tuple(fine_objects) or tuple(jets) or tuple(child_objects) or tuple(child_jets)):
raise ValueError("refinements and the legacy patch keywords are mutually exclusive")
depth = len(refinements) + 1
level_blocks = [None] * depth if level_blocks is None else list(level_blocks)
level_objects = [()] * depth if level_objects is None else list(level_objects)
level_jets = [()] * depth if level_jets is None else list(level_jets)
level_objects = [tuple(o) for o in level_objects]
level_jets = [tuple(j) for j in level_jets]
level_blocks = [None if b is None else tuple(b) for b in level_blocks]
@@ -204,11 +268,6 @@ class FixedPatchStepper:
self._ddf_shifting = bool(cfg.ddf_shifting)
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")
if depth == 1 and (coarse_boundary != "periodic" or cfg.collision != "SRT" or cfg.les_enabled
or explosion != "constant"
or observe_raw_exchange or observe_window_exchange):
raise ValueError("a single level requires a periodic SRT root without LES, projection "
"or exchange observation")
self._channel = coarse_boundary == "channel"
if self._channel:
validate_channel_config(cfg, level_blocks)
@@ -242,14 +301,12 @@ class FixedPatchStepper:
base = blocked_base_tables(pairs[0], self._layouts[0], np.argsort(pairs[0].coalesce_coarse))
validate_channel_transfers(pairs[0], self._layouts[0], base[1])
# Kernel configurations per level (geometry configs live in self.hierarchy).
self._level_cfgs = [replace(cfg)]
if pairs:
fine_cfg = replace(cfg, nx=pairs[0].fine_shape[1], ny=pairs[0].fine_shape[0],
viscosity=2 * cfg.viscosity)
if self._wall_aware:
fine_cfg = replace(fine_cfg, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
self._level_cfgs.append(fine_cfg)
fine_cfg = replace(cfg, nx=pairs[0].fine_shape[1], ny=pairs[0].fine_shape[0],
viscosity=2 * cfg.viscosity)
if self._wall_aware:
fine_cfg = replace(fine_cfg, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
self._level_cfgs = [replace(cfg), fine_cfg]
for pair in pairs[1:]:
parent = self._level_cfgs[-1]
self._level_cfgs.append(replace(parent, nx=pair.fine_shape[1], ny=pair.fine_shape[0],
@@ -290,18 +347,12 @@ class FixedPatchStepper:
self._exchange_centers = tuple(tuple(tuple(o.geometry.center) for o in objects)
for objects in level_objects)
self._level_jets = level_jets
root = None
if depth == 1 and level_objects[0]:
tables = static_circle_tables(self._layouts[0], level_objects[0])
root = _BlockedBoundary(None, tables, (), False)
self._level_boundaries = [root] + [
self._level_boundaries = [None] + [
_prepare_blocked_boundary(self._boundary_hosts[level], self._layouts[level],
self._level_cfgs[level], level_jets[level],
_shifted_blocked=self._ddf_shifting)
for level in range(1, depth)]
self._leaf = [kind == ACTIVE for kind in self.hierarchy.kinds]
if root is not None:
self._leaf[0] &= ~root.tables["solid"]
for level in range(1, depth):
host = self._boundary_hosts[level]
if host is not None:
@@ -324,9 +375,6 @@ class FixedPatchStepper:
self._block = (cfg.threads_per_block, 1, 1)
with self.ctx.activate():
try:
free, _ = cuda.mem_get_info()
if self._planned_bytes() > free: # before any stream, module or buffer
raise MemoryError("level buffers exceed free device memory")
self._stream = cuda.Stream()
for level in range(depth):
self._levels.append(self._build_level(level))
@@ -348,9 +396,7 @@ class FixedPatchStepper:
b.window_integral = self._allocate(count * 4 * 8)
b.accumulate = level.module.get_function("BlockAccumulateExchange")
b.kernel = level.module.get_function("BlockCurvedBoundary")
if depth == 1:
pass
elif self._level_boundaries[1] is not None:
if self._level_boundaries[1] is not None:
b = self._level_boundaries[1]
self._fine_flags, self._link_tables, self._curved = b.flags, b.device, b.kernel
elif self._boundary_hosts[1] is not None:
@@ -372,47 +418,32 @@ class FixedPatchStepper:
pass # Preserve the original construction failure.
raise
def _planned_bytes(self):
"""Both G buffers, halo pairs and boundary columns of every level (transfer tables excluded).
A fail-fast lower bound only: passing it cannot guarantee that later
allocations succeed (other consumers, fragmentation, races).
"""
total = 0
for index, layout in enumerate(self._layouts):
cells = int(np.prod(layout.blocked_shape if layout is not None
else (9, *self.hierarchy.kinds[index].shape)))
total += 2 * 4 * cells
if layout is not None:
total += layout.halo_dst.nbytes + layout.halo_src.nbytes
b = self._level_boundaries[index]
if b is not None:
total += sum(np.asarray(v).nbytes for k, v in b.tables.items()
if k == "flags" or k in BLOCKED_BOUNDARY_DEVICE_COLUMNS)
return total
def _build_level(self, index):
"""Compile one level's step module and allocate its two G buffers."""
layout, config = self._layouts[index], self._level_cfgs[index]
pairs = self.hierarchy.pairs
tau = (pairs[0].tau_coarse if pairs else 3 * config.viscosity + .5) if index == 0 \
else pairs[index - 1].tau_fine
tau = pairs[0].tau_coarse if index == 0 else pairs[index - 1].tau_fine
name, source = ("PatchCoarseStep" if index == 0 else "PatchFineStep"), "refinement.cu"
if layout is not None:
config = level_config(config, layout, tau)
name, source = ("BlockChannelStep" if index == 0 and self._channel else "BlockStep"), "blocked.cu"
else:
config.validate()
if not np.isclose(config.omega, 1 / tau, rtol=1e-14, atol=0):
raise RuntimeError("level omega/tau mismatch")
config = replace(config, nx=layout.local_shape[1], ny=layout.local_shape[0],
y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
name, source = ("BlockChannelStep" if index == 0 and self._channel else "BlockStep"), "blocks.cu"
config.validate()
if not np.isclose(config.omega, 1 / tau, rtol=1e-14, atol=0):
raise RuntimeError("level omega/tau mismatch")
host = self._boundary_hosts[index]
n_objects = len(host.objects) if name == "PatchFineStep" and host is not None else 0
block_root_bc = 0
if index == 0 and layout is not None:
block_root_bc = 2 if self._channel else 1 if self._wall_aware else 0
module = compiler.build_module(config, source, arch=self.ctx.sm_arch, n_objects=n_objects,
block_root_bc=block_root_bc)
compiler.set_params(module, config.omega, n_objects=n_objects)
build = compiler.generate_config(config, n_objects=n_objects)
try:
ptx = compiler.compile_kernel(self.ctx.sm_arch, source=source, build_dir=build)
module = compiler.load_module(ptx)
finally:
compiler.cleanup_build_dir(build)
ptr, size = module.get_global("d_params")
if size != struct.calcsize("=ffffI"):
raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", config.omega, 0, 0, 0, n_objects))
shape = (9, *self.hierarchy.kinds[index].shape)
size = int(np.prod(shape if layout is None else layout.blocked_shape)) * 4
grid = ((config.nx + self._block[0] - 1) // self._block[0], config.ny,
@@ -509,13 +540,13 @@ class FixedPatchStepper:
if self._ddf_shifting and any(layout is None for layout in self._layouts):
raise ValueError("shifted populations require all-blocked levels")
def initialize(self, *level_g):
def initialize(self, *level_g, coarse_g=None, fine_g=None, child_g=None):
"""Upload physical postcollision G, encoding before any FP32 rounding.
Shifted input should be physical FP64 to retain small perturbations.
Public initialization resets the step count; non-leaves are discarded.
"""
given = tuple(level_g)
given = _initial_levels(level_g, coarse_g, fine_g, child_g)
with self._lock, self.ctx.activate():
self._require("new", "ready")
self._check_storage()
@@ -557,9 +588,7 @@ class FixedPatchStepper:
try:
for level, array in zip(self._levels, arrays):
cuda.memcpy_htod(level.current, array)
# A single level starts its spare NaN so stale halos cannot hide.
cuda.memcpy_htod(level.spare, np.full_like(array, np.nan)
if len(self._levels) == 1 else array)
cuda.memcpy_htod(level.spare, array)
if self._fine_curved is not None and self._fine_curved.link_exchange_gpu is not None:
cuda.memset_d8(self._fine_curved.link_exchange_gpu, 0, self._fine_curved.count * 12)
self._step_count = 0
@@ -641,9 +670,10 @@ class FixedPatchStepper:
old, new, source, base_count=count, level_steps=(count, 2*count, 4*count),
phase="postcollision_g", **options)
candidate = type(self)(
cfg, context=self.ctx, refinements=[pairs[0].bounds, new.child.bounds],
level_blocks=[base_block, fine_block, child_block],
level_objects=[(), (), child_objects], level_jets=[(), (), jets],
cfg, pairs[0].bounds, context=self.ctx, base_block=base_block,
fine_block=fine_block, child_block=child_block,
child_rectangles_in_parent=new.child.bounds,
child_objects=child_objects, child_jets=jets,
observe_raw_exchange=self._observe_raw_exchange,
observe_window_exchange=self._observe_window, explosion=self._explosion)
try:
@@ -723,12 +753,15 @@ class FixedPatchStepper:
return
if b.kernel is None or b.flags is None:
raise RuntimeError("Boundary device buffers are missing")
t = b.device
t, count = b.device, len(b.tables["cell"])
velocity = t.get("surface_velocity")
if b.actuated and velocity is None:
raise RuntimeError("Surface velocity device buffer is missing")
launch_curved_boundary(b.kernel, buffer, t, len(b.tables["cell"]), velocity=velocity,
rx=t.get("rx"), ry=t.get("ry"), raw=b.last_raw, **kw)
b.kernel(buffer, t["block"], t["cell"], t["dir"], t["q"], t["fallback"],
np.uint32(count), velocity if velocity is not None else np.uintp(0),
t.get("rx", np.uintp(0)), t.get("ry", np.uintp(0)),
b.last_raw if b.last_raw is not None else np.uintp(0),
grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
def _advance(self, index, parent_buffer, **kw):
"""Explode from the given parent buffer, then two hops of level ``index``."""
@@ -780,50 +813,36 @@ class FixedPatchStepper:
np.float64(h), np.float64(h),
grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
flags = np.uintp(0) if level.boundary is None else level.boundary.flags
kind = np.uintp(0) if level.kind is None else level.kind # single level: all ACTIVE
level.kernel(src, dst, np.uint32(level.layout.halo), flags, kind, grid=level.grid, **kw)
level.kernel(src, dst, np.uint32(level.layout.halo), flags, level.kind, grid=level.grid, **kw)
def _gather_halo(self, level, buffer, **kw):
pairs = len(level.layout.halo_dst)
if pairs:
launch_halo_copy(level.halo_copy, buffer, level.halo_dst, level.halo_src, pairs, **kw)
level.halo_copy(buffer, level.halo_dst, level.halo_src, np.uint32(pairs),
grid=((pairs + self._block[0] - 1) // self._block[0], 1), **kw)
def step(self, n=1):
"""Advance G→G by n base intervals: level l takes 2**l hops per interval.
All n intervals are enqueued in order on the one stream, and the host waits
once, before returning; step(0) launches and waits for nothing. On return
the device work is complete, step_count and an open window cover all n
intervals, and the raw-exchange ledger holds the final interval only.
Asynchronous device errors surface at that single wait, so they are not
attributed to the interval that caused them; call step(1) repeatedly to
localize one (same launches, same bits). On any failure the instance is
failed, the exchange ledger and window are invalid, and step_count keeps
its value from entry to the failing call.
"""
"""Advance G→G by n base intervals: level l takes 2**l hops per interval."""
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
raise ValueError("n must be a nonnegative integer")
with self._lock, self.ctx.activate():
self._require("ready")
if n == 0:
return
root = self._levels[0]
kw = dict(block=self._block, stream=self._stream)
try:
self._status = "advancing"
self._exchange_valid = False
for _ in range(n):
if self._observe_raw_exchange: # stream-ordered: holds the last interval only
if self._observe_raw_exchange:
self._exchange_valid = False
for b in self._level_boundaries:
if b is not None:
cuda.memset_d8_async(b.interval_integral, 0,
len(b.tables["cell"]) * 32, self._stream)
self._hop(0, root.current, root.spare, **kw)
root.current, root.spare = root.spare, root.current # host handles only
self._stream.synchronize()
self._step_count += n
self._exchange_valid = self._observe_raw_exchange
self._stream.synchronize()
root.current, root.spare = root.spare, root.current
self._step_count += 1
self._exchange_valid = self._observe_raw_exchange
self._status = "ready"
except BaseException:
self._window_valid = False
@@ -986,8 +1005,8 @@ class FixedPatchStepper:
if exc[0] is None:
raise
# Read-only compatibility views over the level lists, kept on purpose for the existing
# tests and docs. Internal code must not use them; it reads hierarchy/_layouts/_level_*.
# Read-only compatibility views over the level lists. Internal code must not use them;
# REMOVE AFTER the existing tests/docs migrate to hierarchy/_layouts/_level_* lists.
patch = property(lambda self: self.hierarchy.pairs[0])
child_patch = property(lambda self: self.hierarchy.pairs[1] if self.hierarchy.depth > 2 else None)
base_layout = property(lambda self: self._layouts[0])
+4 -6
View File
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
import numpy as np
from .blocks import blocked_link_tables
from ..body.jets import _surface_links
from ..body.jets import jet_link_shapes, surface_link_velocity
from .descriptors import D2Q9_EX, D2Q9_EY
from .refinement import ACTIVE, OUTER_GHOST
@@ -142,13 +142,11 @@ def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=F
tables.update(body_id=np.asarray(body_id, np.int32), rx=np.asarray(rx, np.float32),
ry=np.asarray(ry, np.float32), aperture=np.zeros(len(dirs), bool))
actuated = bool(jets) or any(o.surface_actuation is not None for o in boundary.objects)
jet_cache = None
if actuated:
cfg = replace(cfg)
cfg.validate()
tables["surface_velocity"], tables["aperture"], shape, jet_id = _surface_links(
tables["surface_velocity"], tables["aperture"] = surface_link_velocity(
boundary.objects, cfg, boundary.flags, fluid_idx, dirs, body_id, rx, ry,
jets=jets, hits=boundary.curved_hits, _shifted_blocked=_shifted_blocked)
if jets:
jet_cache = (shape, jet_id)
return _BlockedBoundary(boundary, tables, jets, actuated, jet_cache)
return _BlockedBoundary(boundary, tables, jets, actuated,
jet_link_shapes(jets, boundary.curved_hits) if jets else None)
+1 -1
View File
@@ -28,7 +28,7 @@ class LBMStepper:
self.boundary_values_gpu = cuda.mem_alloc((6 * cfg.nx + 2 * cfg.ny * cfg.nq) * 4)
else:
self.step_fn = module.get_function("OneStep")
self.init_fn = module.get_function("InitTubeFlow")
self.init_fn = module.get_function("InitTubeFlow_v2")
self.curved_fn = module.get_function("CurvedBoundaryKernel")
self.prepare_esopull_curved_fn = module.get_function("PrepareEsoPullCurvedKernel")
+17 -4
View File
@@ -25,7 +25,7 @@ import pycuda.driver as cuda
from .config import LBMConfig, BodyConfig, load_lbm_config, load_body_config
from .cuda.context import CudaContext, context_bound
from .cuda import compiler
from .cuda import compiler_v2 as compiler
from .lbm.field import LBMField
from .lbm.stepper import LBMStepper
from .body.objects import SimObject
@@ -46,6 +46,7 @@ class Simulation:
device_id: int = 0):
# Internal stream for async DTOH coordination
self._stream: Optional[cuda.Stream] = None
self._build_dir = None
self._closed = False
# Load configs
@@ -58,7 +59,9 @@ class Simulation:
try:
with self.ctx.activate():
arch = self._resolve_compile_arch()
self._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch)
self._build_dir = compiler.generate_config(self.lbm_cfg, n_objects=0)
self._ptx_path = compiler.compile_kernel(arch=arch, build_dir=self._build_dir)
self._module = compiler.load_module(self._ptx_path)
self._compiled_n_objects = 0
self._allocation_signature = self.lbm_cfg.allocation_signature()
self._compiled_viscosity = self.lbm_cfg.viscosity
@@ -409,8 +412,15 @@ class Simulation:
self.lbm_cfg.validate_runtime()
self._assert_allocation_contract()
arch = self._resolve_compile_arch()
self._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch,
n_objects=expected_n_objects)
next_dir = compiler.generate_config(self.lbm_cfg, n_objects=expected_n_objects)
try:
next_ptx = compiler.compile_kernel(arch=arch, build_dir=next_dir)
next_module = compiler.load_module(next_ptx)
except Exception:
compiler.cleanup_build_dir(next_dir)
raise
old_dir = self._build_dir
self._build_dir, self._ptx_path, self._module = next_dir, next_ptx, next_module
self._compiled_n_objects = int(expected_n_objects)
# Reconnect field and stepper to new module
@@ -428,6 +438,7 @@ class Simulation:
self.stepper._step_count = _prev_step_count
self._assert_object_count_contract(expected_count=expected_n_objects)
compiler.cleanup_build_dir(old_dir)
def recompile(self):
"""Re-generate config headers and recompile kernel.
@@ -704,6 +715,8 @@ class Simulation:
self._module = None
self._stream = None
ctx.close()
compiler.cleanup_build_dir(getattr(self, "_build_dir", None))
self._build_dir = None
self._closed = True
def __enter__(self):
@@ -6,14 +6,14 @@ import pycuda.driver as cuda
import pytest
import CelerisLab
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2
from ._cases import make_sim
def test_source_provenance():
root = Path(__file__).resolve().parents[2] / "src/CelerisLab"
assert Path(CelerisLab.__file__).resolve().parent == root
assert Path(compiler.kernel_path("uniform.cu")).resolve() == root / "lbm/kernels/uniform.cu"
assert Path(compiler_v2.kernel_path("kernel_v2.cu")).resolve() == root / "lbm/kernels/kernel_v2.cu"
def test_two_contexts_interleaved_and_non_lifo_close():
@@ -21,7 +21,7 @@ def test_two_contexts_interleaved_and_non_lifo_close():
first = make_sim(nx=32, ny=16)
second = make_sim(nx=48, ny=24, streaming="esopull", shifted=True)
try:
assert first._module is not second._module
assert first._build_dir != second._build_dir
assert cuda.Context.get_current() == before
first.initialize()
second.initialize()
+1 -2
View File
@@ -16,7 +16,6 @@
#define NZ 1
#define NT 1
#define COLLISION_MODEL 0
#define BLOCK_ROOT_BC 2
#define USE_LES 0
#define RHO 1.f
#define U0 .025f
@@ -41,7 +40,7 @@ inline float __fdiv_rn(float a,float b){return a/b;}
#include "boundary/inlet_outlet.cuh"
#include "step/one_step_double.cu"
#else
#include "core/cell_kind.cuh"
constexpr unsigned char KIND_OUTER=2;
#include "step/block_channel.cu"
#endif
int main(){
-46
View File
@@ -1,46 +0,0 @@
"""Check historical source manifests against their own recorded commit, not today's checkout."""
import io
from pathlib import Path
import subprocess
import tarfile
ROOT = Path(__file__).resolve().parents[2]
def _require(commit):
"""Missing recorded history is an explicit failure, never a skip or a repin."""
found = subprocess.run(
["git", "-C", str(ROOT), "cat-file", "-e", commit + "^{commit}"],
capture_output=True,
)
if found.returncode:
raise AssertionError(
f"Required history missing: commit {commit} is not in this clone; "
"fetch it instead of repinning or substituting"
)
def tracked(commit, prefix="src"):
"""Tracked paths under prefix at the recorded commit."""
_require(commit)
return set(
subprocess.check_output(
["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", commit, prefix],
text=True,
).splitlines()
)
def historical_tree(commit, dest):
"""Extract the tracked tree of the recorded commit into dest."""
_require(commit)
archive = subprocess.check_output(
["git", "-C", str(ROOT), "archive", "--format=tar", commit]
)
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
extra = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
tar.extractall(dest, **extra)
return dest
+5 -29
View File
@@ -83,32 +83,6 @@ assert abs(sum(compute_feq_d2q9(1.,0.,0.)) - 1.) < 1e-6
class BuildContracts(unittest.TestCase):
def test_block_root_modes_are_rendered_and_recorded(self):
with tempfile.TemporaryDirectory() as root:
for mode in (0, 1, 2):
with self.subTest(mode=mode):
build = compiler.generate_config(LBMConfig(), build_root=root, block_root_bc=mode)
try:
method = Path(build, "config/config_method.h").read_text()
self.assertIn(f"#define BLOCK_ROOT_BC {mode}\n", method)
metadata = json.loads(Path(build, ".celeris-build").read_text())
self.assertEqual(metadata["block_root_bc"], mode)
self.assertNotIn("block_root_bc", metadata["config"])
finally:
compiler.cleanup_build_dir(build)
build = compiler.generate_config(LBMConfig(), build_root=root)
try:
self.assertEqual(json.loads(Path(build, ".celeris-build").read_text())["block_root_bc"], 0)
finally:
compiler.cleanup_build_dir(build)
def test_invalid_block_root_mode_fails_before_snapshot(self):
for mode in (-1, 3, True, False, 1.0, "1", None):
with self.subTest(mode=mode), patch.object(compiler.tempfile, "mkdtemp") as allocate:
with self.assertRaisesRegex(ValueError, "block_root_bc"):
compiler.generate_config(LBMConfig(), block_root_bc=mode)
allocate.assert_not_called()
@staticmethod
def fake_compile(command, check):
source = Path(command[-3])
@@ -126,7 +100,8 @@ class BuildContracts(unittest.TestCase):
def work(nx, count):
build = compiler.generate_config(LBMConfig(nx=nx), count, build_root=root)
barrier.wait()
output = compiler.compile_kernel(build_dir=build)
# Compatibility form is context-local even without explicit path.
output = compiler.compile_kernel()
data = json.loads(Path(output).read_text())
self.assertEqual(data, {"nx": nx, "objects": count})
metadata = json.loads(Path(output + ".json").read_text())
@@ -155,7 +130,7 @@ class BuildContracts(unittest.TestCase):
compiler.cleanup_build_dir(a)
compiler.cleanup_build_dir(b)
with self.assertRaises(RuntimeError):
compiler.compile_kernel(build_dir=a) # removed snapshot is never compiled
compiler.compile_kernel()
def test_failed_compile_cannot_implicitly_load_previous_artifact(self):
with tempfile.TemporaryDirectory() as root:
@@ -168,7 +143,8 @@ class BuildContracts(unittest.TestCase):
with self.assertRaises(subprocess.CalledProcessError):
compiler.compile_kernel(build_dir=build)
self.assertEqual(Path(previous).read_bytes(), old_bytes)
self.assertEqual(sorted(Path(build).glob("uniform-*.ptx")), []) # no partial output
with self.assertRaisesRegex(RuntimeError, "No compiled PTX"):
compiler.load_module()
compiler.cleanup_build_dir(build)
+5 -6
View File
@@ -50,7 +50,7 @@ def test_masks_and_minimal_footprint():
def test_actual_closure_read_conflicts():
pair, layout, targets = transfer((6, 6, 18, 14))
validate_channel_transfers(pair, layout, targets)
for target in (layout.slot(int(layout.owner[5, -1]) - 1, 2), # integer under NumPy 1 and 2
for target in (layout.slot(layout.owner[5, -1]-1, 2),
layout.slot(layout.owner[1, 4], 4),
layout.slot(layout.owner[-2, 4], 3)):
with pytest.raises(ValueError, match="immutable"):
@@ -74,14 +74,14 @@ def test_evolved_ghost_snapshot_and_physical_download():
from .test_refinement_three_level_multipatch import host_module
host = host_module()
storage = {}
host.compiler.build_module.return_value.get_global.side_effect = lambda name: (name, 12 if name == "d_block_channel" else 20)
host.compiler.load_module.return_value.get_global.side_effect = lambda name: (name, 12 if name == "d_block_channel" else 20)
host.cuda.mem_alloc.side_effect = lambda size: Mock()
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)])
context = Mock(activate=nullcontext, sm_arch="sm_70")
cfg = LBMConfig(nx=24, ny=20, viscosity=.1, velocity=.025)
backend = host.FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
refinements=[(6, 6, 18, 14)], level_blocks=[(4, 6), (4, 4)])
backend = host.FixedPatchStepper(cfg, (6, 6, 18, 14), context=context,
coarse_boundary="channel", base_block=(4, 6), fine_block=(4, 4))
try:
weights = np.array([4/9]+[1/9]*4+[1/36]*4)[:, None, None]
arrays = [np.broadcast_to(weights, level.shape).copy() for level in backend._levels]
@@ -96,7 +96,6 @@ def test_evolved_ghost_snapshot_and_physical_download():
arrays[0][0, 0, 0] = np.nan
with pytest.raises(ValueError, match="finite"):
backend.initialize(*arrays)
assert backend._levels[0].kernel == host.compiler.build_module.return_value.get_function("BlockChannelStep")
assert [call.kwargs["block_root_bc"] for call in host.compiler.build_module.call_args_list] == [2, 0]
assert backend._levels[0].kernel == host.compiler.load_module.return_value.get_function("BlockChannelStep")
finally:
backend.close()
+54
View File
@@ -75,5 +75,59 @@ class Re20Contract(unittest.TestCase):
self.assertEqual((out["status"], out["verdict"]), ("INCOMPLETE", None))
class Re20Provenance(unittest.TestCase):
def test_records_current_checkout(self):
record = v.provenance(rr.DEFAULT_SOURCE)
self.assertEqual(record["source_mode"], "current_checkout")
self.assertEqual(len(record["checkout_revision"]), 40)
self.assertEqual(len(record["src_tree"]), 40)
self.assertEqual(len(record["runner_sha256"]), 64)
self.assertFalse(record["checkout_dirty"])
self.assertEqual(v.RESULT_SCHEMA, 2)
def test_reader_accepts_both_schemas_and_rejects_unknown(self):
import json
import tempfile
from pathlib import Path
case = dict(case="refined", status="NOT_STEADY", verdict=None, rows=[])
with tempfile.TemporaryDirectory() as d:
old, new, bad = Path(d, "old.json"), Path(d, "new.json"), Path(d, "bad.json")
old.write_text(json.dumps([case]))
new.write_text(json.dumps(dict(schema=2, provenance=dict(src_tree="a" * 40),
cases=[case])))
bad.write_text(json.dumps(dict(schema=3, cases=[case])))
self.assertEqual(v.read_result(old), dict(schema=1, provenance=None, cases=[case]))
self.assertEqual(v.read_result(new)["cases"][0], case)
self.assertEqual(v.read_result(new)["provenance"]["src_tree"], "a" * 40)
with self.assertRaisesRegex(ValueError, "unknown DFG Re20 result schema"):
v.read_result(bad)
def test_dirty_checkout_is_refused(self):
from unittest.mock import patch
# Three git calls in order: HEAD:src, HEAD, status --porcelain.
with patch("subprocess.check_output",
side_effect=["e" * 40 + "\n", "f" * 40 + "\n", " M src/x.py\n"]) as git:
with self.assertRaisesRegex(RuntimeError, "clean checkout"):
v.provenance(rr.DEFAULT_SOURCE)
self.assertEqual(git.call_count, 3)
self.assertEqual([c.args[0][-1] for c in git.call_args_list[:2]], ["HEAD:src", "HEAD"])
self.assertIn("--porcelain", git.call_args_list[2].args[0])
def test_output_inside_checkout_is_refused_before_any_work(self):
import tempfile
from pathlib import Path
from unittest.mock import patch
source = rr.DEFAULT_SOURCE.resolve()
for output in (source, source / "re20-out", source / "src" / "x"):
argv = ["run_dfg_re20.py", "--plan", "--source", str(source), "--output", str(output)]
with patch("sys.argv", argv), \
patch.object(v, "provenance", side_effect=AssertionError("work started")):
with self.assertRaisesRegex(SystemExit, "outside the source checkout"):
v.main()
self.assertFalse((source / "re20-out").exists())
with tempfile.TemporaryDirectory() as d:
v.reject_output_inside(Path(d) / "out", source) # outside: accepted
if __name__ == "__main__":
unittest.main()
+71 -6
View File
@@ -8,7 +8,6 @@ import numpy as np
ROOT = Path(__file__).resolve().parents[2]
REPO = ROOT
sys.path.insert(0, str(ROOT / "tests/validation"))
sys.path.insert(0, str(ROOT / "tests/unit"))
sys.path.insert(0, str(REPO / "src"))
from run_jet_circle_cv import (
CX,
@@ -20,7 +19,6 @@ from run_jet_circle_cv import (
cv_from_macros,
aperture_probe,
measured_aperture_flux,
PIN,
verify,
)
@@ -168,13 +166,80 @@ class Host(unittest.TestCase):
self.assertEqual(a["steps"] * 4, b["steps"])
def test_source_pin(self):
from tempfile import TemporaryDirectory
from historical_tree import historical_tree
"""Historical integrity: the manifest matches its pinned commit, read with git."""
from run_jet_circle_cv import verify_historical
from source_provenance import HistoryUnavailable, history_unavailable
try:
verify_historical(REPO)
except HistoryUnavailable as error:
history_unavailable(self, error)
def test_pinned_mode_still_rejects_changed_source(self):
"""Negative control: the frozen-source guard refuses a tree that differs from PIN."""
import json
import run_jet_circle_cv as v
manifest = json.loads((v.HERE / "data/jet_circle_cv/source_manifest.json").read_text())
import hashlib
current = {f: hashlib.sha256((REPO / f).read_bytes()).hexdigest()
for f in manifest["files"] if (REPO / f).exists()}
if current == manifest["files"]:
self.skipTest("working tree equals PIN; no changed source to reject")
with self.assertRaisesRegex(RuntimeError, "Source hash mismatch"):
verify(REPO)
with TemporaryDirectory() as directory:
verify(historical_tree(PIN, directory))
def test_current_checkout_provenance(self):
from run_jet_circle_cv import current_checkout
record = current_checkout(REPO)
self.assertEqual(record["source_mode"], "current_checkout")
self.assertIn("not the frozen", record["source_capture"])
self.assertEqual(len(record["src_tree"]), 40)
self.assertTrue(record["src_sha256"])
def test_final_source_recheck_completes_or_invalidates_receipt(self):
import json
import tempfile
import run_jet_circle_cv as v
recorded = dict(source_mode="current_checkout", checkout_revision="a" * 40,
checkout_dirty=False, src_tree="b" * 40)
payload = dict(status="PILOT_DIAGNOSTIC_ONLY", run_status="INCOMPLETE", windows=[1, 2])
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "re100_s1.json"
path.write_text(json.dumps(payload))
done = v.finish_receipt(path, payload, recorded, lambda: dict(recorded))
self.assertEqual(json.loads(path.read_text()), done)
self.assertEqual((done["run_status"], done["source_recheck"]), ("COMPLETE", "UNCHANGED"))
for recheck, state in ((lambda: dict(recorded, checkout_dirty=True), "CHANGED"),
(lambda: v.verify(Path(d)), "RECHECK_FAILED")):
path.write_text(json.dumps(payload))
with self.assertRaisesRegex(RuntimeError, "Source changed during the run"):
v.finish_receipt(path, payload, recorded, recheck)
bad = json.loads(path.read_text())
self.assertEqual((bad["run_status"], bad["source_recheck"]),
("INVALID_SOURCE_CHANGED", state))
self.assertEqual(bad["source_after"] is None, state == "RECHECK_FAILED")
self.assertEqual(bad["windows"], [1, 2])
self.assertFalse(Path(str(path) + ".tmp").exists())
def test_receipts_written_during_the_run_are_incomplete(self):
"""Static check (run_case needs CUDA): the in-run payload is INCOMPLETE and only
main() -> finish_receipt can mark it COMPLETE."""
import inspect
import run_jet_circle_cv as v
self.assertIn('run_status="INCOMPLETE"', inspect.getsource(v.run_case))
self.assertNotIn("COMPLETE\"", inspect.getsource(v.run_case).replace("INCOMPLETE\"", ""))
self.assertIn("finish_receipt(", inspect.getsource(v.main))
def test_output_inside_checkout_is_refused_before_source_or_cuda(self):
from unittest import mock
import run_jet_circle_cv as v
argv = ["run_jet_circle_cv.py", "--repo", str(REPO), "--output", str(REPO / "v1-out")]
with mock.patch("sys.argv", argv), mock.patch.dict("os.environ", CUDA_VISIBLE_DEVICES="0"), \
mock.patch.object(v, "source_identity", side_effect=AssertionError("work started")):
with self.assertRaisesRegex(SystemExit, "outside the source checkout"):
v.main()
self.assertFalse((REPO / "v1-out").exists())
if __name__ == "__main__":
+34
View File
@@ -151,6 +151,8 @@ class Measurements(unittest.TestCase):
(path / "samples_s2.json").write_text(
json.dumps(dict(case=c, sample_file="samples_s2.npz"))
)
(path / f"re{c['re_d']}_s2.json").write_text(json.dumps(
dict(case=c, run_status="COMPLETE", source_recheck="UNCHANGED")))
np.savez(
path / "samples_s2.npz",
t_reference=t,
@@ -163,6 +165,7 @@ class Measurements(unittest.TestCase):
velocity_error=np.zeros(n),
)
result = analyze(path)
self.assertEqual(result["receipt_gate"], "COMPLETE_SOURCE_UNCHANGED")
self.assertEqual(len(result["windows"]), 32)
np.testing.assert_allclose(
result["windows"][0]["normalized_cv_residual"], 0, atol=1e-14
@@ -178,5 +181,36 @@ class Measurements(unittest.TestCase):
np.testing.assert_allclose(integrate(t, y), [112, 8], atol=1e-15)
def test_receipt_gate_refuses_incomplete_invalid_and_unmarked_legacy(self):
import json
from analyze_jet_cv_continuation import receipt_gate
c = case(2, steps=3200)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp)
receipt = path / f"re{c['re_d']}_s2.json"
with self.assertRaisesRegex(ValueError, "No sibling run receipt"):
receipt_gate(path, c)
for fields, message in (
(dict(run_status="INCOMPLETE"), "INCOMPLETE"),
(dict(run_status="INVALID_SOURCE_CHANGED", source_recheck="CHANGED"), "INVALID"),
(dict(run_status="COMPLETE", source_recheck="RECHECK_FAILED"), "RECHECK_FAILED"),
(dict(run_status="COMPLETE"), "source_recheck=None"),
(dict(), "Legacy run receipt"),
):
receipt.write_text(json.dumps(dict(case=c, **fields)))
with self.assertRaisesRegex(ValueError, message):
receipt_gate(path, c)
receipt.write_text(json.dumps(dict(case=dict(c, scale=1), run_status="COMPLETE",
source_recheck="UNCHANGED")))
with self.assertRaisesRegex(ValueError, "different case"):
receipt_gate(path, c)
receipt.write_text(json.dumps(dict(case=c)))
self.assertEqual(receipt_gate(path, c, legacy=True), "LEGACY_NO_FINAL_SOURCE_RECHECK")
receipt.write_text(json.dumps(dict(case=c, run_status="INCOMPLETE")))
with self.assertRaisesRegex(ValueError, "INCOMPLETE"):
receipt_gate(path, c, legacy=True) # legacy opt-in never admits new statuses
if __name__ == "__main__":
unittest.main()
+47 -36
View File
@@ -13,12 +13,15 @@ from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
from run_uniform_les_flow import source_guard, decode_macros, HERE, HEADER
from source_provenance import (HistoryUnavailable, historical_files, historical_sha256,
historical_tree, history_unavailable)
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT / "tests/unit"))
from historical_tree import historical_tree, tracked
A135252 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
A135 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
def a135252_pins():
return json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
class GuardTests(unittest.TestCase):
@@ -45,24 +48,35 @@ class GuardTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "velocity/profile"):
decode_macros(invalid, w, cx, cy)
def test_current_corrected_pins(self):
def materialize(self, root):
"""The pinned a135252 tree, read with git (the guard's own qualification)."""
try:
historical_files(ROOT, A135252, a135252_pins()["sources"], root)
except HistoryUnavailable as error:
history_unavailable(self, error)
def test_a135252_pins_qualify_their_own_commit(self):
with TemporaryDirectory() as directory:
root = historical_tree(A135, directory)
with patch("run_uniform_les_flow.subprocess.check_output", return_value="historical-tree\n"):
identity = source_guard(root, "corrected", "a135252")
# base_commit comes from the pins file; the mock only feeds checkout_commit.
self.assertEqual(identity["base_commit"], A135)
self.assertEqual(identity["checkout_commit"], "historical-tree")
root = Path(directory)
self.materialize(root)
with patch("run_uniform_les_flow.subprocess.check_output",
return_value="isolated-fixture\n"):
self.assertEqual(source_guard(root, "corrected", "a135252")["base_commit"], A135252)
def test_current_tree_is_not_the_a135252_qualification(self):
"""Explicit record: the guard refuses today's src wherever it differs from the pin."""
pins = a135252_pins()["sources"]
changed = sorted(n for n, sha in pins.items() if (ROOT / n).exists()
and hashlib.sha256((ROOT / n).read_bytes()).hexdigest() != sha)
if not changed:
self.skipTest("current src equals the a135252 pin")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, "corrected", "a135252")
def test_corrected_and_tamper_guard(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory:
origin = historical_tree(A135, Path(directory) / "origin")
root = Path(directory) / "fixture"
for name in pins["sources"]:
dest = root / name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(origin / name, dest)
root = Path(directory)
self.materialize(root)
with patch(
"run_uniform_les_flow.subprocess.check_output",
return_value="isolated-fixture\n",
@@ -93,28 +107,25 @@ class GuardTests(unittest.TestCase):
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())
self.assertEqual(pins["base_commit"], A135)
self.assertEqual(set(pins["sources"]), tracked(A135))
with TemporaryDirectory() as directory:
root = historical_tree(A135, directory)
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_manifest_pins_the_a135252_corrected_tree(self):
pins = a135252_pins()
self.assertEqual(pins["base_commit"], A135252)
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")
try:
names = historical_tree(ROOT, A135252)
actual = historical_sha256(ROOT, A135252, pins["sources"])
except HistoryUnavailable as error:
history_unavailable(self, error)
self.assertEqual(set(pins["sources"]), set(names))
self.assertEqual(actual, pins["sources"])
def test_named_baseline_accepts_only_reversed_header(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory:
origin = historical_tree(A135, Path(directory) / "origin")
root = Path(directory) / "fixture"
for name in pins["sources"]:
dest = root / name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(origin / name, dest)
root = Path(directory)
self.materialize(root)
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"):
source_guard(root, "baseline", "a135252")
+1 -1
View File
@@ -135,6 +135,6 @@ def test_d2_entrypoint_compiles_in_isolation(tmp_path, model, shift, storage):
source=re.sub(r"(#define\s+"+key+r"\s+)\S+",lambda match: match[1]+str(value),source)
path.write_text(source)
result=subprocess.run([nvcc,"-ptx","-arch=sm_70","-std=c++17",
str(copy/"uniform.cu"),"-o",str(tmp_path/"kernel.ptx")],
str(copy/"kernel_v2.cu"),"-o",str(tmp_path/"kernel.ptx")],
capture_output=True,text=True)
assert result.returncode == 0, result.stderr
+2 -2
View File
@@ -9,7 +9,7 @@ import pytest
import CelerisLab
from CelerisLab.config import LBMConfig, load_lbm_config
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint
from .test_numerical_operators import compile_run
from .test_state_regressions import device, make_field, manager
@@ -18,7 +18,7 @@ from .test_state_regressions import device, make_field, manager
def test_planar_helper_moments_and_known_populations(tmp_path):
root = Path(__file__).resolve().parents[2]
assert Path(CelerisLab.__file__).resolve() == root/'src/CelerisLab/__init__.py'
assert Path(compiler.kernel_path('uniform.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/uniform.cu'
assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/kernel_v2.cu'
lines = compile_run(tmp_path, 'planar_velocity.cpp', dict(WALL_NORMAL_VELOCITY='0.0f'))
cx = np.array([0,1,-1,0,0,1,-1,1,-1])
cy = np.array([0,0,0,1,-1,1,-1,-1,1])
+1 -1
View File
@@ -162,7 +162,7 @@ class ProjectedKernelTests(unittest.TestCase):
targets = child.owner.ravel()[p.ghost_fine]
donors = parent.owner.ravel()[p.ghost_coarse]
source = (Path(__file__).resolve().parents[2] /
"src/CelerisLab/lbm/kernels/step/block_transfer.cu").read_text()
"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):
+1 -1
View File
@@ -29,7 +29,7 @@ def backend(**options):
name=name, side_effect=lambda *a, **kw: calls.append((name, a, kw)))
return module
host.compiler.build_module.side_effect = lambda *a, **k: load(None)
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)
+3 -4
View File
@@ -25,7 +25,7 @@ def backend(observe=True, obj=None):
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(side_effect=lambda *a, **k: calls.append((name, a, k)))
host.compiler.build_module.return_value = module
host.compiler.load_module.return_value = module
host.cuda.mem_alloc.side_effect = lambda n: Mock(size=n)
host.cuda.memcpy_dtoh.side_effect = lambda array, pointer: array.fill(0)
ctx = Mock(activate=nullcontext, sm_arch='sm_70')
@@ -157,9 +157,8 @@ class RawExchange(unittest.TestCase):
host = host_module()
ctx = Mock()
with self.assertRaisesRegex(ValueError, 'blocked body'):
host.FixedPatchStepper(LBMConfig(nx=32, ny=32, viscosity=.1), context=ctx,
refinements=[(4, 4, 28, 28)],
level_objects=[(), (body(radius=2.15),)], observe_raw_exchange=True)
host.FixedPatchStepper(LBMConfig(nx=32, ny=32, viscosity=.1), (4, 4, 28, 28),
context=ctx, fine_objects=[body(radius=2.15)], observe_raw_exchange=True)
ctx.activate.assert_not_called()
def test_all_depth_weights(self):
+21 -7
View File
@@ -5,7 +5,6 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests/validation"))
sys.path.insert(0, str(ROOT / "tests/unit"))
import re100_helpers as h
import periodic as p
@@ -63,16 +62,31 @@ def test_periodic_synthetic():
assert not p.assess(rows()[:100])["periodic"]
def test_production_unmodified(tmp_path):
from historical_tree import historical_tree
def test_manifest_matches_its_pinned_commit():
"""Historical integrity only: the manifest describes the h.PIN tree (read with git)."""
m = json.loads(
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text()
)
assert m["commit"] == h.PIN
origin = historical_tree(m["commit"], tmp_path)
for name, digest in m["production_sha256"].items():
assert hashlib.sha256((origin / name).read_bytes()).hexdigest() == digest, name
import unittest
from source_provenance import HistoryUnavailable, history_unavailable, verify_manifest
try:
verify_manifest(ROOT, m["commit"], m["production_sha256"])
except HistoryUnavailable as error:
history_unavailable(unittest.TestCase(), error)
def test_current_src_is_not_the_pinned_qualification():
"""Explicit record: today's src differs from h.PIN, so its qualification does not carry over."""
m = json.loads(
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text()
)
changed = sorted(n for n, d in m["production_sha256"].items()
if not (ROOT / n).exists()
or hashlib.sha256((ROOT / n).read_bytes()).hexdigest() != d)
if not changed:
pytest.skip("current src equals the pinned tree")
assert changed # e.g. src/CelerisLab/lbm/channel.py since 47b05bc
def test_reference():
+31 -45
View File
@@ -7,37 +7,38 @@ import numpy as np
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import run_refined_dfg as r
from historical_tree import historical_tree
from tempfile import TemporaryDirectory
h, periodic = r.helpers(r.DEFAULT_SOURCE)
def historical_manifest(collision="SRT", observation_mode="last_base_step"):
"""The manifest verify_source would select, without hashing the working tree.
Receipt/plan logic tests use it; source integrity is checked once in test_source_pin.
"""
name = ("source_manifest_window.json" if observation_mode == "window" else
"source_manifest.json" if collision == "SRT" else "source_manifest_mrt.json")
return json.loads((r.HERE / "data/refined_dfg" / name).read_text())
def test_source_pin():
historical = json.loads((r.HERE / "data/refined_dfg/source_manifest.json").read_text())
historical = historical_manifest("SRT")
assert historical["commit"].startswith("cd3a657")
mrt_history = json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text())
mrt_history = historical_manifest("MRT")
assert mrt_history["commit"].startswith("3ea131e")
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
r.verify_source(r.DEFAULT_SOURCE, "MRT", "window")
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
r.verify_source(r.DEFAULT_SOURCE, "MRT")
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
r.verify_source(r.DEFAULT_SOURCE, "SRT")
with TemporaryDirectory() as directory:
for collision, mode, prefix in (
("SRT", "last_base_step", "cd3a657"),
("MRT", "last_base_step", "3ea131e"),
("MRT", "window", "a233c90"),
):
name = {"SRT": "source_manifest.json", "MRT": "source_manifest_mrt.json"}[collision]
name = "source_manifest_window.json" if mode == "window" else name
commit = json.loads((r.HERE / "data/refined_dfg" / name).read_text())["commit"]
assert commit.startswith(prefix)
root = historical_tree(commit, Path(directory) / commit)
assert r.verify_source(root, collision, mode)["commit"] == commit
assert historical_manifest("MRT", "window")["commit"].startswith("a233c90")
# Negative controls first, so missing history can never hide them: every pinned
# mode refuses a working tree that differs from its pin.
for collision, mode in (("MRT", "last_base_step"), ("SRT", "last_base_step"), ("MRT", "window")):
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
r.verify_source(r.DEFAULT_SOURCE, collision, mode)
from source_provenance import HistoryUnavailable, history_unavailable, verify_manifest
for manifest in (historical, mrt_history, historical_manifest("MRT", "window")):
try: # historical integrity: each manifest matches the tree of its own commit
verify_manifest(r.DEFAULT_SOURCE, manifest["commit"], manifest["sha256"])
except HistoryUnavailable as error:
history_unavailable(unittest.TestCase(), error)
def test_matched_units_and_planes():
@@ -157,32 +158,17 @@ def test_axis_specific_sampling_phase():
np.testing.assert_allclose(fine_y - ((k + 43) - 0.5) * 0.00125, 0, atol=1e-15)
def frozen_manifest(observation_mode):
"""Frozen MRT manifest JSON as recorded, without hashing the working tree."""
name = "source_manifest_window.json" if observation_mode == "window" else "source_manifest_mrt.json"
return json.loads((r.HERE / "data/refined_dfg" / name).read_text())
def selected_manifest(collision, observation_mode):
"""The frozen manifest verify_source selects for (collision, observation_mode).
Source identity is owned by test_source_pin; this reads no checkout files.
"""
return r.source_manifest(collision, observation_mode)
def test_full_requires_completed_pilot():
with unittest.TestCase().assertRaisesRegex(ValueError, "pilot-results"):
r.verify_pilot(None, frozen_manifest("window"))
r.verify_pilot(None, historical_manifest("MRT", "window"))
def pilot_fixture(path, observation_mode="window", jet_scenario="none", case_selection="both", uniform_diameter=80, placement="wide"):
"""Synthetic contract fixture only; never a physical result or run receipt."""
# Receipt-logic fixture: the frozen manifest verify_source returns for a matching tree.
# Source identity itself is checked only by test_source_pin.
manifest = (r.verify_source(r.DEFAULT_SOURCE, "MRT", "window", jet_scenario)
if observation_mode == "window" and jet_scenario != "none" else
frozen_manifest(observation_mode))
manifest = ((historical_manifest("MRT", "window") if jet_scenario == "none" else
r.verify_source(r.DEFAULT_SOURCE, "MRT", "window", jet_scenario))
if observation_mode == "window" else
json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text()))
selection = dict(case_selection=case_selection, uniform_diameter=uniform_diameter, placement=placement)
(path / "plan.json").write_text(
json.dumps(
@@ -380,7 +366,7 @@ def test_record_actual_checkout_separately():
actual = checkout_provenance(r.DEFAULT_SOURCE)
assert len(actual["checkout_revision"]) == 40
assert isinstance(actual["checkout_dirty"], bool)
assert selected_manifest("MRT", "window")["production_origin_commit"].startswith("d930")
assert historical_manifest("MRT", "window")["production_origin_commit"].startswith("d930")
def test_window_plan_preserves_physics():
@@ -390,7 +376,7 @@ def test_window_plan_preserves_physics():
assert baseline.pop("observation_mode") == "last_base_step"
assert window.pop("observation_mode") == "window"
assert baseline == window
assert selected_manifest(collision, "window")["observation_mode"] == "window"
assert historical_manifest(collision, "window")["observation_mode"] == "window"
with unittest.TestCase().assertRaises(ValueError):
r.verify_source(r.DEFAULT_SOURCE, "MRT", "unknown")
+5 -14
View File
@@ -22,16 +22,13 @@ class RefinementMRTHost(unittest.TestCase):
def test_srt_statements_and_guards_preserved(self):
"""MRT is an additive compile-time branch; SRT statements and other guards remain."""
blocks = (KERNELS / "blocked.cu").read_text() + (KERNELS / "step/block_step.cu").read_text()
blocks = (KERNELS / "blocks.cu").read_text()
channel = (KERNELS / "step/block_channel.cu").read_text()
block_collision = (KERNELS / "operators/block_collision.cuh").read_text()
self.assertIn('#include "operators/block_collision.cuh"', blocks)
self.assertEqual(blocks.count("block_collide(f);"), 1)
refinement = (KERNELS / "refinement.cu").read_text() + (KERNELS / "step/patch_step.cu").read_text()
refinement = (KERNELS / "refinement.cu").read_text()
for text in ("compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);",
"compute_feq(rho, ux, uy, feq);",
"collide_srt_no_force(f, feq, d_params.omega);"):
self.assertIn(text, block_collision)
self.assertIn(text, blocks)
self.assertIn("collide_srt(f, feq, forcing, omega);", channel)
self.assertIn("collide_srt_no_force(f, feq, d_params.omega);", refinement)
for source in (blocks, refinement):
@@ -40,14 +37,8 @@ class RefinementMRTHost(unittest.TestCase):
self.assertIn("STORE_PRECISION != 0", source)
self.assertIn('#if COLLISION_MODEL == 2\n#include "operators/collision_mrt.cuh"\n#endif', source)
self.assertIn("USE_DDF_SHIFTING ||", refinement) # dense/mixed shifted stays rejected
# One shared unforced MRT body; each blocked/patch path calls it exactly once.
mrt = (KERNELS / "operators/collision_mrt.cuh").read_text()
self.assertEqual(mrt.count("void collide_mrt_unforced("), 1)
for source in (blocks + "\n" + block_collision, channel, refinement):
self.assertEqual(source.count("collide_mrt_unforced(f);"), 1)
self.assertNotIn("collide_mrt_no_force(", source)
self.assertNotIn("compute_rho_u(f, rho, ux, uy);\n collide_mrt", source)
self.assertNotRegex(source, r"compute_rho_u\(f, rho, ux, uy\);\s*collide_mrt")
for source in (blocks, channel, refinement):
self.assertEqual(source.count("collide_mrt_no_force("), 1)
if __name__ == "__main__": unittest.main()
@@ -22,13 +22,12 @@ def host_module():
tree.body=[node for node in tree.body if not (
isinstance(node,ast.Import) and any(a.name=='pycuda.driver' for a in node.names)
or isinstance(node,ast.ImportFrom) and node.module in (
'cuda','curved_links'))]
'cuda','block_stepper','curved_links'))]
module=types.ModuleType('CelerisLab.lbm._test_host_refinement')
module.__package__='CelerisLab.lbm'
from CelerisLab.body.jets import surface_link_velocity
module.__dict__.update(cuda=Mock(),compiler=Mock(),
module.__dict__.update(cuda=Mock(),compiler=Mock(),MAX_GRID_DIM=65535,
surface_link_velocity=surface_link_velocity)
module.cuda.mem_get_info.return_value=(1<<40,1<<40) # free-memory preflight passes
exec(compile(tree,str(path),'exec'),module.__dict__)
return module
@@ -174,10 +173,8 @@ def bodies_and_jets(speed=.002):
return bodies,jets
def levels(parents=PARENTS,children=CHILDREN,blocks=((4,4),)*3,objects=((),(),()),jets=((),(),())):
"""Level-list constructor keywords for the L0 set, L1 set and L2 set used here."""
return dict(refinements=[parents,children],level_blocks=list(blocks),
level_objects=list(objects),level_jets=list(jets))
KW=dict(base_block=(4,4),fine_block=(4,4),child_block=(4,4),
child_rectangles_in_parent=CHILDREN)
def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_upload=False):
@@ -194,9 +191,9 @@ def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_up
gpu.get_function.side_effect=lambda name: Mock(name=name)
gpu.get_global.side_effect=lambda name: (Mock(),20 if name=='d_params' else 16)
return gpu
module.compiler.build_module.side_effect=lambda *a,**k: load(None)
module.compiler.load_module.side_effect=load
if fail_compile:
module.compiler.build_module.side_effect=[load(None),load(None),RuntimeError('L2 compile failed')]
module.compiler.compile_kernel.side_effect=['first','second',RuntimeError('L2 compile failed')]
if fail_upload:
def upload(*args):
if len(allocations)>20:
@@ -204,8 +201,9 @@ def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_up
module.cuda.memcpy_htod.side_effect=upload
objects,jets=bodies_and_jets() if bodies else ((),())
try:
run=module.FixedPatchStepper(CFG,context=context,
**levels(children=children,objects=((),(),objects),jets=((),(),jets)))
run=module.FixedPatchStepper(CFG,PARENTS,context=context,
child_objects=objects,child_jets=jets,
**dict(KW,child_rectangles_in_parent=children))
except RuntimeError:
for allocation in allocations:
allocation.free.assert_called_once_with()
@@ -216,29 +214,28 @@ def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_up
class ConstructorAndSchedule(unittest.TestCase):
def test_invalid_inputs_fail_before_context_or_allocations(self):
context=Mock()
b=(4,4)
cases=[dict(children=(8,8,16,16)),dict(children=[]),
dict(children=[(16,16,32)]),
dict(children=[(16.5,16,32,40)]),
dict(children=[(16,16,32,24),(16,27,32,55)]),
dict(children=[(40,16,48,40)]),
dict(children=[(8,16,32,40)]),
dict(blocks=(b,b,None)),dict(blocks=(b,None,b)),dict(blocks=(None,b,b)),
dict(blocks=(b,b,(5,4))),dict(blocks=(b,(5,4),b)),
dict(blocks=(b,b,(4,))),dict(children=(16,16,32,40))]
cases=[dict(child=(8,8,16,16)),dict(child_rectangles_in_parent=[]),
dict(child_rectangles_in_parent=[(16,16,32)]),
dict(child_rectangles_in_parent=[(16.5,16,32,40)]),
dict(child_rectangles_in_parent=[(16,16,32,24),(16,27,32,55)]),
dict(child_rectangles_in_parent=[(40,16,48,40)]),
dict(child_rectangles_in_parent=[(8,16,32,40)]),
dict(child_block=None),dict(fine_block=None),dict(base_block=None),
dict(child_block=(5,4)),dict(fine_block=(5,4)),
dict(child_block=(4,)),dict(child_rectangles_in_parent=(16,16,32,40))]
for changes in cases:
with self.subTest(changes=changes),self.assertRaises((ValueError,TypeError)):
host.FixedPatchStepper(CFG,context=context,**levels(**changes))
host.FixedPatchStepper(CFG,PARENTS,context=context,**dict(KW,**changes))
with self.assertRaisesRegex(ValueError,'margin'):
host.FixedPatchStepper(CFG,context=context,**levels(parents=[(1,4,20,28),(23,4,44,28)]))
host.FixedPatchStepper(CFG,[(1,4,20,28),(23,4,44,28)],context=context,**KW)
objects,jets=bodies_and_jets()
with self.assertRaisesRegex(ValueError,'only on the finest level'):
host.FixedPatchStepper(CFG,context=context,**levels(objects=((),objects,())))
with self.assertRaisesRegex(ValueError,'only on L2'):
host.FixedPatchStepper(CFG,PARENTS,context=context,fine_objects=objects,**KW)
from CelerisLab.body.jets import Jet,Tube,Parallel
spanning=Jet(Tube((10.5,14.),0.,25.,.3),Parallel(0.),amplitude=0.)
with self.assertRaisesRegex(ValueError,'one jet'):
host.FixedPatchStepper(CFG,context=context,
**levels(objects=((),(),objects),jets=((),(),[spanning])))
host.FixedPatchStepper(CFG,PARENTS,context=context,child_objects=objects,
child_jets=[spanning],**KW)
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
@@ -316,23 +313,21 @@ class ConstructorAndSchedule(unittest.TestCase):
class RemovedLegacyAPI(unittest.TestCase):
"""The pre-level-list constructor/initialize forms are gone and fail loudly."""
class InitialLevels(unittest.TestCase):
"""initialize() entry adapter: positional per-level arrays or the legacy names, never both."""
def test_removed_constructor_forms_raise_type_error(self):
import inspect
context = Mock()
with self.assertRaises(TypeError): # positional bounds
host.FixedPatchStepper(CFG, PARENTS, context=context)
removed = dict(bounds=PARENTS, fine_block=(4, 4), base_block=(4, 4), fine_objects=(),
jets=(), child=(8, 8, 16, 16), child_block=(4, 4), child_objects=(),
child_jets=(), child_rectangles_in_parent=CHILDREN)
for name, value in removed.items():
with self.subTest(keyword=name), self.assertRaises(TypeError):
host.FixedPatchStepper(CFG, context=context, **levels(), **{name: value})
context.activate.assert_not_called()
self.assertEqual(list(inspect.signature(host.FixedPatchStepper.initialize).parameters),
["self", "level_g"]) # no coarse_g/fine_g/child_g
def test_forms(self):
a, b, c = object(), object(), object()
f = host._initial_levels
self.assertEqual(f((a, b), None, None, None), (a, b))
self.assertEqual(f((a, b, c), None, None, None), (a, b, c))
self.assertEqual(f((a, b), None, None, c), (a, b, c)) # old initialize(c, f, child_g=g)
self.assertEqual(f((), a, b, None), (a, b))
self.assertEqual(f((), a, b, c), (a, b, c))
for args in (((a,), None, None, c), ((a, b, c), None, None, c), ((a,), a, b, None),
((), a, None, None), ((), None, b, c)):
with self.subTest(args=args), self.assertRaises(TypeError):
f(*args)
if __name__=='__main__':
+1 -1
View File
@@ -86,7 +86,7 @@ class RuntimeChildRemap(unittest.TestCase):
before = run.download()
old_allocations = list(allocations)
if failure == 'construction':
operation = patch.object(module.compiler, 'build_module', side_effect=RuntimeError('candidate compile'))
operation = patch.object(module.compiler, 'compile_kernel', side_effect=RuntimeError('candidate compile'))
else:
initialize = type(run).initialize
def fail(candidate, *arrays):
+7 -6
View File
@@ -54,15 +54,16 @@ class ShiftedJets(unittest.TestCase):
(Jet(Tube((10.,14.),0.,26.,.3),Parallel(0.)),))]
for objects,jets in cases:
with self.subTest(objects=len(objects),jets=len(jets)),self.assertRaises(ValueError):
module.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
level_blocks=[(4,4)]*3,level_objects=[(),(),objects],level_jets=[(),(),jets])
module.FixedPatchStepper(CFG,backend.PARENTS,context=None,
base_block=(4,4),fine_block=(4,4),child_block=(4,4),
child_rectangles_in_parent=A,child_objects=objects,child_jets=jets)
for blocks in ((None,None,None),((4,4),None,(4,4))):
with self.assertRaises(ValueError):
module.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
level_blocks=list(blocks),level_objects=[(),(),(body(),)],
level_jets=[(),(),(jet(),)])
module.FixedPatchStepper(CFG,backend.PARENTS,context=None,
base_block=blocks[0],fine_block=blocks[1],child_block=blocks[2],
child_rectangles_in_parent=A,child_objects=(body(),),child_jets=(jet(),))
module.cuda.mem_alloc.assert_not_called()
module.compiler.build_module.assert_not_called()
module.compiler.compile_kernel.assert_not_called()
def test_switch_upload_only_then_raw_remap_keeps_live_velocity(self):
run,module,allocations,storage,objects=ready();clone=None
+24 -15
View File
@@ -1,4 +1,7 @@
"""CPU host contracts; mocked CUDA executes real lifecycle, not numerical kernels."""
import ast
import types
from pathlib import Path
from contextlib import nullcontext
from dataclasses import replace
import unittest
@@ -75,15 +78,13 @@ class ShiftedStorage(unittest.TestCase):
def test_shifted_density_and_tag_validation(self):
old,new = topology(A),topology(B)
a = tuple(np.where(m[None], np.float32(-.001), np.float32(np.nan)).repeat(9,axis=0)
for m in old.leaf_masks) # FP32 under NumPy 1 and 2 promotion rules
a = tuple(np.where(m[None], np.float32(-.001), np.nan).repeat(9,axis=0) for m in old.leaf_masks)
remap(old,new,a)
with self.assertRaisesRegex(ValueError,'representation'): remap(old,new,a,False)
with self.assertRaisesRegex(ValueError,'FP32'): remap(old,new,tuple(x.astype(np.float64) for x in a))
a[0][:,old.leaf_masks[0]] = -1
with self.assertRaisesRegex(ValueError,'positive density'): remap(old,new,a)
rest = tuple(np.where(m[None],np.float32(0),np.float32(np.nan)).repeat(9,axis=0)
for m in old.leaf_masks)
rest = tuple(np.where(m[None],np.float32(0),np.nan).repeat(9,axis=0) for m in old.leaf_masks)
remap(old,new,rest)
with self.assertRaisesRegex(ValueError,'positive density'):
remap_synchronized(old,new,rest,base_count=7,level_steps=(7,14,28),phase='postcollision_g')
@@ -128,21 +129,30 @@ class ShiftedStorage(unittest.TestCase):
def test_dense_or_mixed_shifted_rejected_before_compilation(self):
module=backend.host_module()
cfg=replace(backend.CFG,ddf_shifting=True)
for blocks in ([None,None],[(4,4),None],[None,(4,4)]):
with self.assertRaisesRegex(ValueError,'all-blocked|blocked refined levels below'):
module.FixedPatchStepper(cfg,context=None,refinements=[(4,4,20,28)],
level_blocks=blocks)
module.compiler.build_module.assert_not_called()
for kwargs in ({},dict(base_block=(4,4)),dict(fine_block=(4,4))):
with self.assertRaisesRegex(ValueError,'all-blocked|base_block requires fine_block'):
module.FixedPatchStepper(cfg,(4,4,20,28),context=None,**kwargs)
module.compiler.compile_kernel.assert_not_called()
def test_uniform_block_codec_and_raw_snapshot(self):
module = backend.host_module() # single blocked level: FixedPatchStepper(refinements=[])
cuda, compiler = module.cuda, module.compiler
path = Path(backend.r.__file__).with_name('block_stepper.py')
tree = ast.parse(path.read_text())
tree.body = [n for n in tree.body if not (
isinstance(n, ast.Import) and any(a.name == 'pycuda.driver' for a in n.names)
or isinstance(n, ast.ImportFrom) and n.module == 'cuda')]
module = types.ModuleType('CelerisLab.lbm._test_block_host')
module.__package__ = 'CelerisLab.lbm'
cuda, compiler = Mock(), Mock()
module.__dict__.update(cuda=cuda,compiler=compiler)
exec(compile(tree,str(path),'exec'),module.__dict__)
cuda.mem_get_info.return_value=(10**9,10**9)
cuda.mem_alloc.side_effect=lambda size: Mock()
gpu=Mock();gpu.get_global.return_value=(Mock(),20)
compiler.build_module.return_value=gpu
compiler.load_module.return_value=gpu
context=Mock(activate=nullcontext,sm_arch='sm_70')
layout=module.BlockLayout2D.build(16,16,4,4)
cfg=replace(backend.CFG,nx=16,ny=16,ddf_shifting=True)
run=module.FixedPatchStepper(cfg,context=context,refinements=[],level_blocks=[(4,4)])
run=module.PeriodicBlockStepper(cfg,layout,context=context)
storage={}
cuda.memcpy_htod.side_effect=lambda p,a: storage.__setitem__(id(p),np.array(a,copy=True))
cuda.memcpy_dtoh.side_effect=lambda a,p: np.copyto(a,storage[id(p)])
@@ -153,8 +163,7 @@ class ShiftedStorage(unittest.TestCase):
self.assertTrue(raw.shifted)
self.assertEqual(len(raw.values),1)
self.assertTrue((raw.values[0][1]==np.float32(2**-35)).all())
(down,)=run.download()
np.testing.assert_array_equal(down,physical)
np.testing.assert_array_equal(run.download(),physical)
finally: run.close()
def test_passive_shifted_body_raw_remap_preserves_support(self):
+9 -2
View File
@@ -36,10 +36,12 @@ def make_simulation(monkeypatch):
sim = module.Simulation.__new__(module.Simulation)
sim.ctx = Owner()
sim._closed = False
sim._build_dir = "private-build"
for name in ("stepper", "bodies", "field", "_module", "_stream"):
setattr(sim, name, object())
monkeypatch.setattr(module, "cuda", SimpleNamespace(
Context=SimpleNamespace(synchronize=lambda: None)))
monkeypatch.setattr(module.compiler, "cleanup_build_dir", lambda path: None)
return sim
@@ -55,7 +57,7 @@ def test_close_inside_activation_preserves_resources(monkeypatch):
assert sim._closed and sim.ctx.is_closed
@pytest.mark.parametrize("failure", ["synchronize", "detach"])
@pytest.mark.parametrize("failure", ["synchronize", "detach", "directory"])
def test_close_failure_is_retryable(monkeypatch, failure):
sim = make_simulation(monkeypatch)
if failure == "detach":
@@ -66,12 +68,17 @@ def test_close_failure_is_retryable(monkeypatch, failure):
calls.append(args)
if len(calls) == 1:
raise RuntimeError(failure + " failed")
monkeypatch.setattr(module.cuda.Context, "synchronize", fail_once)
if failure == "synchronize":
monkeypatch.setattr(module.cuda.Context, "synchronize", fail_once)
else:
monkeypatch.setattr(module.compiler, "cleanup_build_dir", fail_once)
with pytest.raises(RuntimeError, match="failed"):
sim.close()
assert not sim._closed
assert sim._build_dir == "private-build"
sim.close()
assert sim._closed and sim.ctx.is_closed
assert sim._build_dir is None
sim.close()
-218
View File
@@ -1,218 +0,0 @@
"""Host contract of FixedPatchStepper(refinements=[]): one blocked level (mocked driver).
Per step: halo refresh -> static BFL (circles only) -> BlockStep with a null
kind, one stream wait per step(n) call, NaN spare after initialize. An upload,
launch, wait or download failure fails the instance and keeps its step count;
close retains resources for retry when context activation fails.
"""
from contextlib import nullcontext
from unittest.mock import MagicMock, Mock
import unittest
import numpy as np
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from tests.unit.test_refinement_three_level_multipatch import host_module
def circle(center=(15.5, 11.5), radius=4.3):
return SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
def backend(objects=(), failing_kernel=None, initialize=True):
host, log = host_module(), []
host.armed = False # set True to make failing_kernel raise
def launch(name):
def run(*a, **k):
log.append((name, a))
if name == failing_kernel and host.armed:
raise RuntimeError(name + " launch failed")
return run
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(side_effect=launch(name))
host.compiler.build_module.return_value = module
host.cuda.mem_alloc.side_effect = lambda size: Mock(size=size)
host.cuda.memcpy_htod.side_effect = lambda pointer, array: log.append(("upload", (pointer, array)))
run = host.FixedPatchStepper(LBMConfig(nx=32, ny=24, viscosity=.1),
context=Mock(activate=nullcontext, sm_arch="sm_70"),
refinements=[], level_blocks=[(12, 16)], level_objects=[objects])
run._stream.synchronize.side_effect = lambda: log.append(("sync", ()))
log.clear()
if initialize:
run.initialize(np.full(run._levels[0].shape, 1 / 9, np.float32))
return run, log, host
def state():
return np.full((9, 24, 32), 1 / 9, np.float32)
class SingleLevel(unittest.TestCase):
def test_spare_starts_nan_and_current_holds_state(self):
run, log, _ = backend()
level = run._levels[0]
uploads = {id(a[0]): a[1] for name, a in log if name == "upload"}
self.assertTrue(np.isnan(uploads[id(level.spare)]).all())
self.assertTrue(np.isfinite(level.layout.to_dense(uploads[id(level.current)])).all())
def test_launch_order_per_step(self):
for objects, order in (((), ["BlockHaloCopy", "BlockStep"]),
((circle(),), ["BlockHaloCopy", "BlockCurvedBoundary", "BlockStep"])):
with self.subTest(bodies=len(objects)):
run, log, _ = backend(objects)
log.clear()
run.step(2)
self.assertEqual([name for name, _ in log], order * 2 + ["sync"])
step = [a for name, a in log if name == "BlockStep"][0]
self.assertEqual(int(step[4]), 0) # null kind: every core cell ACTIVE
def test_circle_solids_are_not_state(self):
run, _, _ = backend((circle(),))
solid = run._level_boundaries[0].tables["solid"]
self.assertTrue(solid.any())
np.testing.assert_array_equal(run._state[0], ~solid)
def test_initialize_upload_failure_fails_instance(self):
run, _, host = backend(initialize=False)
host.cuda.memcpy_htod.side_effect = RuntimeError("upload failed")
with self.assertRaisesRegex(RuntimeError, "upload failed"):
run.initialize(state())
with self.assertRaisesRegex(RuntimeError, "failed"):
run.step(1)
def test_launch_failure_fails_instance_and_keeps_step_count(self):
for kernel, objects in (("BlockHaloCopy", ()), ("BlockCurvedBoundary", (circle(),)),
("BlockStep", ())):
with self.subTest(kernel=kernel):
run, log, host = backend(objects, failing_kernel=kernel)
run.step(1)
host.armed = True
log.clear()
with self.assertRaisesRegex(RuntimeError, kernel + " launch failed"):
run.step(2)
self.assertEqual(run.step_count, 1)
self.assertNotIn("sync", [name for name, _ in log])
with self.assertRaisesRegex(RuntimeError, "failed"):
run.download()
def test_free_memory_preflight_before_stream_and_buffers(self):
for cfg, options in ((LBMConfig(nx=32, ny=24, viscosity=.1),
dict(refinements=[], level_blocks=[(12, 16)])),
(LBMConfig(nx=48, ny=48, viscosity=.02), # every level counts
dict(refinements=[[(6, 6, 26, 26)]], level_blocks=[(4, 4)] * 2))):
with self.subTest(depth=len(options["refinements"]) + 1):
host = host_module()
host.cuda.mem_get_info.return_value = (1024, 1 << 30)
with self.assertRaisesRegex(MemoryError, "exceed free device memory"):
host.FixedPatchStepper(cfg, context=Mock(activate=nullcontext, sm_arch="sm_70"),
**options)
host.cuda.Stream.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
host.compiler.build_module.assert_not_called()
def test_wait_failure_after_a_good_step_keeps_its_count(self):
run, _, _ = backend()
run.step(1)
run._stream.synchronize.side_effect = RuntimeError("device fault")
with self.assertRaises(RuntimeError):
run.step(2)
self.assertEqual((run.step_count, run._status), (1, "failed"))
def test_final_wait_failure_fails_instance_and_keeps_step_count(self):
run, _, _ = backend()
run._stream.synchronize.side_effect = RuntimeError("device error")
with self.assertRaisesRegex(RuntimeError, "device error"):
run.step(3)
self.assertEqual(run.step_count, 0)
with self.assertRaisesRegex(RuntimeError, "failed"):
run.step(1)
def test_step_n_waits_once_and_matches_n_single_steps(self):
many, log_many, _ = backend()
many.step(3)
single, log_single, _ = backend()
for _ in range(3):
single.step(1)
names = [name for name, _ in log_many]
self.assertEqual(names.count("sync"), 1)
self.assertEqual(names[-1], "sync")
self.assertEqual(names.count("BlockStep"), 3)
def roles(run, log): # same launch order and buffer roles as three step(1) calls
level = run._levels[0]
tags = {id(level.current): "c", id(level.spare): "s"}
return [(n, tuple(tags.get(id(x), "-") for x in a)) for n, a in log if n != "sync"]
self.assertEqual(roles(many, log_many), roles(single, log_single))
self.assertIsNot(many._levels[0].current, many._levels[0].spare)
self.assertEqual((many.step_count, single.step_count), (3, 3))
def test_step_zero_launches_and_waits_for_nothing(self):
run, log, _ = backend()
log.clear()
run.step(0)
self.assertEqual((log, run.step_count), ([], 0))
def test_launch_failure_mid_batch_fails_without_waiting(self):
run, log, host = backend()
step = run._levels[0].kernel
calls = []
def failing(*args, **kwargs):
calls.append(1)
if len(calls) == 2:
raise RuntimeError("launch failure")
return step(*args, **kwargs)
run._levels[0].kernel = failing
log.clear()
with self.assertRaises(RuntimeError):
run.step(3)
self.assertNotIn("sync", [name for name, _ in log])
self.assertEqual((run.step_count, run._status), (0, "failed"))
with self.assertRaises(RuntimeError):
run.download()
def test_initialize_rejects_bad_state_before_upload(self):
run, log, _ = backend(initialize=False)
good = state()
for bad in (good[:, :-1], np.where(np.arange(32) == 3, np.nan, good), -good):
with self.assertRaises(ValueError):
run.initialize(bad)
self.assertNotIn("upload", [name for name, _ in log])
run.initialize(good)
self.assertEqual([name for name, _ in log].count("upload"), 2)
def test_download_failure_fails_instance(self):
run, _, host = backend()
host.cuda.memcpy_dtoh.side_effect = RuntimeError("read failed")
with self.assertRaisesRegex(RuntimeError, "read failed"):
run.download()
for operation in (run.download, run.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
def test_activation_failure_retains_resources_for_retry(self):
run, _, _ = backend()
allocations, stream = run._allocations[:], run._stream
unavailable = MagicMock()
unavailable.__enter__.side_effect = RuntimeError("activation failed")
run.ctx.activate = Mock(side_effect=[unavailable, nullcontext()])
with self.assertRaisesRegex(RuntimeError, "activation failed"):
run.close()
self.assertEqual(run._status, "failed")
self.assertEqual(run._allocations, allocations)
self.assertIs(run._stream, stream)
run.close()
self.assertEqual(run._status, "closed")
self.assertEqual(run._levels, [])
for allocation in allocations:
allocation.free.assert_called_once()
if __name__ == "__main__":
unittest.main()
-170
View File
@@ -1,170 +0,0 @@
"""Host contract of FixedPatchStepper.step(n): one stream wait per call (mocked driver).
All n base intervals are enqueued in order on the backend stream and the host
waits once before step returns. Launch order and buffer arguments must equal n
step(1) calls; completion metadata, raw-exchange and window ledgers, and failure
invalidation are published only after that wait.
"""
from contextlib import nullcontext
from unittest.mock import Mock
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from tests.unit.test_raw_exchange import body
from tests.unit.test_refinement_three_level_multipatch import host_module
BODY_LEVEL, HOPS = 3, 8 # body on L3: 2**3 hops per base interval
def backend(window=False):
"""Four-level host backend; every launch, memset and stream wait goes to one log."""
host = host_module()
log, allocations = [], []
def allocate(size):
allocations.append(Mock(size=size))
return allocations[-1]
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(
side_effect=lambda *a, **k: log.append((name, a, k)))
host.compiler.build_module.return_value = module
host.cuda.mem_alloc.side_effect = allocate
host.cuda.memcpy_dtoh.side_effect = lambda array, pointer: array.fill(0)
host.cuda.memset_d8_async.side_effect = lambda *a: log.append(("memset", a, {}))
run = host.FixedPatchStepper(
LBMConfig(nx=48, ny=48, viscosity=.02), context=Mock(activate=nullcontext, sm_arch="sm_70"),
refinements=[[(6, 6, 26, 26)], [(20, 20, 44, 44)], [(56, 56, 72, 72)]],
level_blocks=[(4, 4)] * 4, level_objects=[(), (), (), (body(),)],
observe_raw_exchange=True, observe_window_exchange=window)
run._stream.synchronize.side_effect = lambda: log.append(("sync", (), {}))
run.initialize(*[np.full(level.shape, 1 / 9, np.float32) for level in run._levels])
log.clear()
return run, log, allocations
def signature(log, allocations):
"""Event names with device-pointer arguments replaced by allocation index."""
index = {id(a): i for i, a in enumerate(allocations)}
return [(name, tuple(index.get(id(a), "-") for a in args)) for name, args, _ in log]
def names(log):
return [name for name, _, _ in log]
class StepSync(unittest.TestCase):
def test_step1_waits_once_after_all_launches(self):
run, log, _ = backend()
run.step(1)
self.assertEqual(names(log).count("sync"), 1)
self.assertEqual(names(log)[-1], "sync")
self.assertEqual(names(log)[0], "memset")
self.assertEqual(names(log).count("BlockStep"), 1 + 2 + 4 + 8)
self.assertEqual(run.step_count, 1)
run.close()
def test_step_n_equals_n_step1_launches_with_one_wait(self):
single, single_log, single_alloc = backend()
for _ in range(3):
single.step(1)
batched, batched_log, batched_alloc = backend()
batched.step(3)
expected = [e for e in signature(single_log, single_alloc) if e[0] != "sync"]
got = signature(batched_log, batched_alloc)
self.assertEqual(got[-1][0], "sync")
self.assertEqual(names(batched_log).count("sync"), 1)
self.assertEqual(got[:-1], expected) # same order, same ping-pong buffers
self.assertEqual(names(batched_log).count("memset"), 3)
self.assertEqual(batched.step_count, single.step_count)
index = [{id(a): i for i, a in enumerate(alloc)} for alloc in (single_alloc, batched_alloc)]
self.assertEqual(index[1][id(batched._levels[0].current)],
index[0][id(single._levels[0].current)])
single.close()
batched.close()
def test_step0_launches_and_waits_for_nothing(self):
run, log, _ = backend()
run.step(2)
log.clear()
run.step(0)
self.assertEqual(log, [])
self.assertEqual(run.step_count, 2)
value = run.read_raw_exchange(0, level=BODY_LEVEL) # step(0) keeps the ledger valid
self.assertEqual((value["time_start"], value["time_end"]), (1, 2))
run.close()
def test_completion_metadata_covers_all_intervals(self):
run, _, _ = backend()
with self.assertRaisesRegex(RuntimeError, "completed"):
run.read_raw_exchange(0, level=BODY_LEVEL)
run.step(3)
value = run.read_raw_exchange(0, level=BODY_LEVEL)
self.assertEqual((value["time_start"], value["time_end"], value["sample_count"]),
(2, 3, HOPS)) # step(n) retains only the final base interval
run.step(2)
self.assertEqual(run.read_raw_exchange(0, level=BODY_LEVEL)["time_end"], 5)
run.close()
def test_window_accounting_across_batched_calls(self):
windows = []
for calls in ((1, 3), (4,), (1, 1, 1, 1)):
run, log, _ = backend(window=True)
run.step(1)
run.start_exchange_window()
log.clear()
for n in calls:
run.step(n)
self.assertEqual(names(log).count("BlockAccumulateExchange"), 2 * 4 * HOPS)
self.assertEqual(names(log).count("sync"), len(calls))
out = run.read_exchange_window(0, level=BODY_LEVEL)
windows.append((out["time_start"], out["time_end"], out["sample_count"]))
run.close()
self.assertEqual(windows, [(1., 5, 4 * HOPS)] * 3)
def test_failed_wait_publishes_nothing(self):
run, _, _ = backend(window=True)
run.step(2)
run.start_exchange_window()
run._stream.synchronize.side_effect = RuntimeError("device failure")
with self.assertRaisesRegex(RuntimeError, "device failure"):
run.step(3)
self.assertEqual(run.step_count, 2) # value at entry to the failing call
self.assertFalse(run._exchange_valid)
self.assertFalse(run._window_valid)
for read in (lambda: run.read_raw_exchange(0, level=BODY_LEVEL),
lambda: run.read_exchange_window(0, level=BODY_LEVEL), run.download,
lambda: run.step(1)):
with self.assertRaisesRegex(RuntimeError, "failed"):
read()
run._stream.synchronize.side_effect = None
run.close()
def test_launch_error_mid_batch_skips_wait_and_fails(self):
run, log, _ = backend()
run.step(1)
log.clear()
hop, calls = run._hop, []
def failing(index, *args, **kw):
if index == 0:
calls.append(1)
if len(calls) == 2:
raise RuntimeError("launch failure")
return hop(index, *args, **kw)
run._hop = failing
with self.assertRaisesRegex(RuntimeError, "launch failure"):
run.step(3)
self.assertNotIn("sync", names(log))
self.assertEqual(run.step_count, 1)
self.assertFalse(run._exchange_valid)
with self.assertRaisesRegex(RuntimeError, "failed"):
run.read_raw_exchange(0, level=BODY_LEVEL)
run.close()
if __name__ == "__main__":
unittest.main()
+76 -17
View File
@@ -8,19 +8,19 @@ import sys
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests/validation"))
from source_provenance import checkout_provenance
import pytest
from source_provenance import (HistoryUnavailable, checkout_provenance, historical_sha256,
historical_tree, history_unavailable, verify_manifest)
import unittest
import re100_helpers
import run_jet_circle_cv
sys.path.insert(0, str(ROOT / "tests/unit"))
from historical_tree import historical_tree, tracked
ORIGIN = "47b05bc8abfc957fe876b0c63bdb386e3c6cbaa5"
def test_manifests_cover_current_source_and_preserve_origin(tmp_path):
assert re100_helpers.PIN == run_jet_circle_cv.PIN == ORIGIN
manifests = {}
def test_manifests_match_their_recorded_source_and_preserve_origin():
"""Historical evidence integrity: each manifest describes the tree of its own commit."""
manifests = []
for group, hashes_key, revision_key in (
("dfg_re100", "production_sha256", "commit"),
("jet_circle_cv", "files", "revision"),
@@ -30,6 +30,7 @@ def test_manifests_cover_current_source_and_preserve_origin(tmp_path):
ROOT / "tests/validation/data" / group / "source_manifest.json"
).read_text()
)
# Structural checks need no history and always run.
assert manifest[revision_key] == ORIGIN
assert manifest["source_origin_commits"][-1] == ORIGIN
assert (
@@ -37,16 +38,15 @@ def test_manifests_cover_current_source_and_preserve_origin(tmp_path):
== "d79e3f6f0a424f5bcc3a1a6d610313e49daf97a2"
)
assert "not inherited from d79" in manifest["experiment"]
manifests[group] = manifest[hashes_key]
# History-dependent checks last, so a missing commit masks nothing above.
names = tracked(ORIGIN)
origin = historical_tree(ORIGIN, tmp_path)
for group, hashes in manifests.items():
assert set(hashes) == set(names), group
for name, expected in hashes.items():
assert (
hashlib.sha256((origin / name).read_bytes()).hexdigest() == expected
), name
manifests.append(manifest[hashes_key])
try:
names = historical_tree(ROOT, ORIGIN)
for digests in manifests:
assert set(digests) == set(names)
verify_manifest(ROOT, ORIGIN, digests)
except HistoryUnavailable as error:
history_unavailable(unittest.TestCase(), error)
assert re100_helpers.PIN == run_jet_circle_cv.PIN == ORIGIN
def test_runtime_checkout_identity_is_separate_from_source_origin(tmp_path):
@@ -78,3 +78,62 @@ def test_runtime_checkout_identity_is_separate_from_source_origin(tmp_path):
assert checkout_provenance(tmp_path) == dict(
checkout_revision=expected, checkout_dirty=True
)
def test_historical_check_rejects_tampering_and_reports_missing_history(tmp_path):
with pytest.raises(HistoryUnavailable):
historical_sha256(tmp_path, ORIGIN, ["src/x.py"]) # not a repository
with pytest.raises(HistoryUnavailable):
historical_sha256(ROOT, "0" * 40, ["src/x.py"]) # commit absent
manifest = json.loads(
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text())
digests = dict(manifest["production_sha256"])
name = sorted(digests)[0]
tampered = dict(digests, **{name: ("0" if digests[name][0] != "0" else "1") + digests[name][1:]})
try:
historical_tree(ROOT, ORIGIN)
except HistoryUnavailable as error:
history_unavailable(unittest.TestCase(), error)
with pytest.raises(RuntimeError, match="Historical manifest mismatch"):
verify_manifest(ROOT, ORIGIN, tampered) # one flipped digest is caught
with pytest.raises(ValueError, match="not part of"):
historical_sha256(ROOT, ORIGIN, ["src/CelerisLab/does_not_exist.py"])
def test_historical_files_reports_unreadable_blob_as_history_unavailable(tmp_path):
"""A blob missing from the clone is HistoryUnavailable, not a raw CalledProcessError."""
import source_provenance
from unittest.mock import patch
missing = subprocess.CompletedProcess(["git", "show"], 128, b"", b"fatal: missing blob")
with patch.object(source_provenance, "historical_sha256", return_value={"src/a.py": "0" * 64}), \
patch.object(source_provenance.subprocess, "run", return_value=missing):
with pytest.raises(HistoryUnavailable, match="blob not in this clone"):
source_provenance.historical_files(ROOT, ORIGIN, ["src/a.py"], tmp_path)
assert not (tmp_path / "src/a.py").exists()
def test_final_source_check_unchanged_changed_and_failed_recheck():
from source_provenance import final_source_check
recorded = dict(revision="a" * 40, dirty=False)
assert final_source_check(recorded, lambda: dict(recorded)) == dict(source_recheck="UNCHANGED")
changed = final_source_check(recorded, lambda: dict(recorded, dirty=True))
assert changed == dict(source_recheck="CHANGED", source_after=dict(recorded, dirty=True),
source_recheck_error=None)
def fails():
raise RuntimeError("Source hash mismatch: src/x.py")
failed = final_source_check(recorded, fails)
assert (failed["source_recheck"], failed["source_after"]) == ("RECHECK_FAILED", None)
assert "Source hash mismatch" in failed["source_recheck_error"]
def test_reject_output_inside_checkout(tmp_path):
from source_provenance import reject_output_inside
for output in (ROOT, ROOT / "out", ROOT / "src" / "x.json"):
with pytest.raises(SystemExit, match="outside the source checkout"):
reject_output_inside(output, ROOT)
reject_output_inside(tmp_path / "out", ROOT)
@@ -14,11 +14,35 @@ def integrate(t, y):
)
def analyze(directory):
def receipt_gate(directory, c, legacy=False):
"""Side files are used only if the sibling run receipt finished with an unchanged source.
Receipts from runners before the final source recheck have no run_status; they are
refused unless legacy=True (--legacy-receipts), and the result is labelled as such.
"""
path = Path(directory) / f"re{c['re_d']}_s{c['scale']}.json"
if not path.exists():
raise ValueError(f"No sibling run receipt {path.name}")
receipt = json.loads(path.read_text())
if receipt.get("case") != c:
raise ValueError("Sibling run receipt describes a different case")
if "run_status" not in receipt:
if not legacy:
raise ValueError("Legacy run receipt without run_status/final source recheck; "
"pass --legacy-receipts to analyze it as such")
return "LEGACY_NO_FINAL_SOURCE_RECHECK"
if receipt["run_status"] != "COMPLETE" or receipt.get("source_recheck") != "UNCHANGED":
raise ValueError(f"Run receipt is {receipt['run_status']} "
f"(source_recheck={receipt.get('source_recheck')})")
return "COMPLETE_SOURCE_UNCHANGED"
def analyze(directory, legacy=False):
meta_path = next(Path(directory).glob("samples_s*.json"))
meta = json.loads(meta_path.read_text())
c = meta["case"]
s = c["scale"]
gate = receipt_gate(directory, c, legacy)
sample_path = meta_path.parent / meta["sample_file"]
checksum_verified = False
if "sample_sha256" in meta:
@@ -97,6 +121,7 @@ def analyze(directory):
)
return dict(
scale=s,
receipt_gate=gate,
complete_schedule=bool(complete_schedule),
sample_checksum_verified=checksum_verified,
windows=records,
@@ -115,8 +140,10 @@ def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("directories", nargs="+", type=Path)
p.add_argument("--output", type=Path, required=True)
p.add_argument("--legacy-receipts", action="store_true",
help="Accept receipts written before the final source recheck (labelled)")
a = p.parse_args()
results = [analyze(d) for d in a.directories]
results = [analyze(d, a.legacy_receipts) for d in a.directories]
payload = dict(
status="TRANSIENT_DIAGNOSTIC_COMPARISON",
results=results,
@@ -30,7 +30,7 @@ original_module = gate.compile_module
cache = {}
def retain_compile(*args, **kwargs):
result = original_compile(*args, **kwargs)
source = kwargs.get('source', 'uniform.cu')
source = kwargs.get('source', 'kernel_v2.cu')
shutil.copyfile(result, a.output / (Path(source).stem + '.ptx'))
metadata = Path(result + '.json')
if metadata.exists(): shutil.copyfile(metadata, a.output / (Path(source).stem + '.ptx.json'))
+117 -50
View File
@@ -1,4 +1,4 @@
"""Single blocked level, FixedPatchStepper(refinements=[]): CPU contracts plus short GPU cases.
"""Periodic body-free block backend: CPU contract doubles plus one short GPU case.
Frozen GPU gates (32x24, 8 steps, positive low-Mach non-equilibrium state):
finite cores; bit-identical 1x1, 2x2 and 4x2 splits; max |G - FP64 oracle|
@@ -11,8 +11,9 @@ Mass/momentum drift is reported, not gated.
from contextlib import nullcontext
from pathlib import Path
import resource
from threading import RLock
import unittest
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pycuda.driver as cuda
@@ -20,10 +21,10 @@ import pycuda.driver as cuda
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.lbm import refinement_stepper as implementation
from CelerisLab.lbm.blocks import NO_DONOR, BlockLayout2D, static_circle_tables
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm import block_stepper as implementation
from CelerisLab.lbm.blocks import NO_DONOR, BlockLayout2D
from CelerisLab.lbm.block_stepper import PeriodicBlockStepper
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
ROOT = Path(__file__).resolve().parents[3]
@@ -111,78 +112,72 @@ def totals(g):
return g.sum(), (CX[:, None, None] * g).sum(), (CY[:, None, None] * g).sum()
def single(cfg=None, *, context, block=(12, 16), objects=(), **kw):
"""One periodic blocked level (the former PeriodicBlockStepper capability)."""
return FixedPatchStepper(cfg or config(), context=context, refinements=[], level_blocks=[block],
level_objects=[tuple(objects)], **kw)
class CpuContracts(unittest.TestCase):
"""No device: driver doubles prove rejection before allocation."""
"""No device: driver doubles prove rejection before allocation and lifecycle."""
def forbidden_context(self):
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
def test_unsupported_inputs_rejected_before_cuda(self):
for cfg, kw in ((None, dict(block=None)),
(None, dict(coarse_boundary="channel")),
(None, dict(coarse_boundary="slot_reservoir")),
(config(collision="MRT"), {}),
(config(les_enabled=True), {}),
(None, dict(explosion="projected_linear")),
(None, dict(observe_raw_exchange=True)),
(None, dict(observe_raw_exchange=True, observe_window_exchange=True)),
(None, dict(level_jets=[(object(),)]))):
with self.subTest(kw=kw), self.assertRaises(ValueError):
single(cfg, context=self.forbidden_context(), **kw)
layout = BlockLayout2D.build(NY, NX, 12, 16)
cases = ((config(), BlockLayout2D.build(NY, NX, 12, 16, periodic=(True, False))),
(config(), BlockLayout2D.build(NY, NX * 2, 12, 16)),
(config(collision="MRT"), layout),
(config(), "layout"))
for cfg, lay in cases:
with self.subTest(cfg=cfg.collision, layout=getattr(lay, "periodic", lay)):
with self.assertRaises(ValueError):
PeriodicBlockStepper(cfg, lay, context=self.forbidden_context())
moving = circle()
moving.state.vx = .01
build_links = CircleGeometry.build_cut_links
for objects in ([moving], [circle((1.5, 11.5), 2.0)], [circle(), circle()]):
with self.assertRaises(ValueError):
single(context=self.forbidden_context(), objects=objects)
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(), objects=objects)
for center, radius in (((16.0, 12.0), -.1), ((16.0, 12.0), -.5), ((15.5, 11.5), np.nan),
((np.inf, 11.5), 4.3), ((15.5, np.nan), 4.3)):
with self.subTest(center=center, radius=radius), self.assertRaises(ValueError):
single(context=self.forbidden_context(), objects=[circle(center, radius)])
with patch.object(implementation, "static_circle_tables") as prepare:
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(),
objects=[circle(center, radius)])
with patch.object(implementation, "_static_circles") as prepare:
with self.assertRaisesRegex(AssertionError, "CUDA reached"): # body-free path
single(context=self.forbidden_context(), objects=iter(()))
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(), objects=iter(()))
prepare.assert_not_called()
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
single(context=self.forbidden_context(), objects=[circle()])
with patch.object(CircleGeometry, "build_cut_links",
lambda geometry, *a, **k: build_links(geometry, *a, **k)[1:]):
with self.assertRaisesRegex(ValueError, "cover every fluid pull"):
single(context=self.forbidden_context(), objects=[circle()])
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(),
objects=[circle()])
with patch.object(implementation, "MAX_GRID_DIM", 3):
with self.assertRaisesRegex(ValueError, "launch limits"):
single(context=self.forbidden_context())
PeriodicBlockStepper(config(), layout, context=self.forbidden_context())
def test_memory_and_param_layout_rejected_before_allocation(self):
layout = BlockLayout2D.build(NY, NX, 12, 16)
context = Mock(activate=nullcontext, sm_arch="sm_70")
with patch.object(cuda, "mem_get_info", return_value=(1024, 1 << 30)), \
patch.object(cuda, "Stream") as stream, patch.object(cuda, "mem_alloc") as alloc:
with self.assertRaisesRegex(MemoryError, "exceed free device memory"):
single(context=context, objects=[circle()])
with self.assertRaises(MemoryError):
PeriodicBlockStepper(config(), layout, context=context)
stream.assert_not_called()
alloc.assert_not_called()
module = Mock()
module.get_global.return_value = (Mock(), 16)
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "mem_get_info", return_value=(1 << 30, 1 << 30)), \
patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel") as compile_kernel, \
patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc") as alloc, patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
single(context=context)
self.assertEqual(compile_kernel.call_args.kwargs["source"], "blocked.cu")
PeriodicBlockStepper(config(), layout, context=context)
self.assertEqual(compile_kernel.call_args.kwargs["source"], "blocks.cu")
alloc.assert_not_called()
upload.assert_not_called()
def test_static_circle_tables(self):
layout = BlockLayout2D.build(NY, NX, 12, 16)
tables = static_circle_tables(layout, (circle(),))
tables = implementation._static_circles(layout, (circle(),))
links = CircleGeometry(*CENTER, RADIUS).build_cut_links(NX, NY, domain_flags=dense_flags())
self.assertEqual(len(tables["cell"]), len(links))
np.testing.assert_array_equal(tables["q"], np.array([l.q for l in links], np.float32))
@@ -190,11 +185,84 @@ class CpuContracts(unittest.TestCase):
self.assertTrue((tables["flags"][solid_cells] & 2).all())
self.assertEqual(int(np.count_nonzero(tables["flags"] & 2)), int(tables["solid"].sum()))
def fake_backend(self):
backend = PeriodicBlockStepper.__new__(PeriodicBlockStepper)
backend.layout = BlockLayout2D.build(NY, NX, 12, 16)
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "ready"
backend._step_count = 0
backend._stream = Mock()
backend._module = Mock()
backend._copy = backend._step = Mock()
backend.boundary, backend._curved, backend._tables, backend._links = None, None, {}, 0
backend._dst, backend._src = Mock(), Mock()
backend._block, backend._grid, backend._copy_grid = (32, 1, 1), (1, 1, 1), (1, 1)
backend._allocations = [Mock(), Mock()]
backend._current, backend._spare = backend._allocations
return backend
class SingleLevelGPU(unittest.TestCase):
def test_initialize_rejects_bad_state_before_upload(self):
backend = self.fake_backend()
good = initial_state()
bad = (good[:, :-1], np.where(np.arange(NX) == 3, np.nan, good), -good)
with patch.object(cuda, "memcpy_htod") as upload:
for g in bad:
with self.assertRaises(ValueError):
backend.initialize(g)
upload.assert_not_called()
backend.initialize(good)
self.assertEqual(upload.call_count, 2)
self.assertTrue(np.isnan(upload.call_args_list[1].args[1]).all())
backend.close()
def test_failed_launch_and_download_make_backend_unusable(self):
for failure in ("step", "download"):
backend = self.fake_backend()
if failure == "step":
backend._step = Mock(side_effect=RuntimeError("launch failed"))
with self.assertRaisesRegex(RuntimeError, "launch failed"):
backend.step()
else:
with patch.object(cuda, "memcpy_dtoh", side_effect=RuntimeError("read failed")):
with self.assertRaisesRegex(RuntimeError, "read failed"):
backend.download()
for operation in (backend.download, backend.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
backend.close()
for allocation in backend_allocations(backend):
allocation.free.assert_called_once()
def test_activation_failure_retains_resources_for_retry(self):
backend = self.fake_backend()
allocations, stream, module = backend._allocations[:], backend._stream, backend._module
unavailable = MagicMock()
unavailable.__enter__.side_effect = RuntimeError("activation failed")
backend.ctx.activate = Mock(side_effect=[unavailable, nullcontext()])
with self.assertRaisesRegex(RuntimeError, "activation failed"):
backend.close()
self.assertEqual(backend._status, "failed")
self.assertEqual(backend._allocations, allocations)
self.assertIs(backend._stream, stream)
self.assertIs(backend._module, module)
backend.close()
self.assertEqual(backend._status, "closed")
self.assertIsNone(backend._module)
self.assertIsNone(backend._copy)
self.assertIsNone(backend._step)
for allocation in allocations:
allocation.free.assert_called_once()
def backend_allocations(backend):
return (backend._current, backend._spare)
class PeriodicBlockGPU(unittest.TestCase):
def setUp(self):
self.assertEqual(Path(implementation.__file__).resolve(),
ROOT / "src/CelerisLab/lbm/refinement_stepper.py")
ROOT / "src/CelerisLab/lbm/block_stepper.py")
def tearDown(self):
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
@@ -205,13 +273,13 @@ class SingleLevelGPU(unittest.TestCase):
g0 = initial_state()
results = []
with CudaContext(0) as context:
for split in SPLITS:
with single(context=context, block=split) as backend:
for by, bx in SPLITS:
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context) as backend:
backend.initialize(g0)
backend.step(STEPS)
self.assertEqual(backend.step_count, STEPS)
(g,) = backend.download()
results.append(g)
results.append(backend.download())
for (by, bx), g in zip(SPLITS, results):
self.assertTrue(np.isfinite(g).all(), (by, bx))
np.testing.assert_array_equal(g, results[0], err_msg=f"split {(by, bx)}")
@@ -238,17 +306,16 @@ class SingleLevelGPU(unittest.TestCase):
g0 = initial_state()
results = []
with CudaContext(0) as context:
for split in SPLITS:
with single(context=context, block=split, objects=[circle()]) as backend:
tables = backend._level_boundaries[0].tables
for by, bx in SPLITS:
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context, objects=[circle()]) as backend:
for key, values, dtype in (("q", [l.q for l in links], np.float32),
("dir", [l.dir for l in links], np.uint8),
("fallback", [l.fallback for l in links], np.uint8)):
np.testing.assert_array_equal(tables[key], np.array(values, dtype))
np.testing.assert_array_equal(backend.boundary[key], np.array(values, dtype))
backend.initialize(g0)
backend.step(STEPS)
(g,) = backend.download()
results.append(g)
results.append(backend.download())
for (by, bx), g in zip(SPLITS, results):
self.assertTrue(np.isfinite(g[:, ~solid]).all(), (by, bx))
self.assertTrue(np.isnan(g[:, solid]).all(), (by, bx))
@@ -11,7 +11,7 @@ import numpy as np
import pycuda.driver as cuda
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.descriptors import FLUID, SOLID, BC_WALL, BC_INLET, BC_OUTLET
@@ -27,7 +27,7 @@ def state(ny, nx):
def compile_module(cfg, context, source):
cfg.validate()
build = compiler.generate_config(cfg, block_root_bc=2 if source == "blocked.cu" else 0)
build = compiler.generate_config(cfg)
try:
module = compiler.load_module(compiler.compile_kernel(context.sm_arch, source=source, build_dir=build))
finally:
@@ -46,7 +46,7 @@ def run_root(cfg, context, initial, block=None, steps=8):
try:
layout = None if block is None else BlockLayout2D.build(cfg.ny, cfg.nx, *block)
local = cfg if layout is None else replace(cfg, nx=layout.local_shape[1], ny=layout.local_shape[0])
module = compile_module(local, context, "uniform.cu" if layout is None else "blocked.cu")
module = compile_module(local, context, "kernel_v2.cu" if layout is None else "blocks.cu")
data = initial if layout is None else layout.to_blocked(initial)
current, spare = upload(data), upload(np.full_like(data, np.nan))
tpb = cfg.threads_per_block
@@ -106,10 +106,9 @@ class ChannelRootGPU(unittest.TestCase):
cfg = LBMConfig(nx=48, ny=32, viscosity=.1, velocity=.015, ddf_shifting=shifted)
reference = None
for block in ((32,48), (8,8), (4,1)):
with FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
refinements=[(8,6,40,26)], level_blocks=[block, (8,8)],
level_objects=[(), (body,) if bodies else ()],
level_jets=[(), (jet,) if bodies else ()]) as backend:
with FixedPatchStepper(cfg, (8,6,40,26), context=context,
coarse_boundary="channel", base_block=block, fine_block=(8,8),
fine_objects=(body,) if bodies else (), jets=(jet,) if bodies else ()) as backend:
arrays = [state(*level.shape[1:]) for level in backend._levels]
backend.initialize(*arrays)
backend.step(4)
@@ -25,8 +25,8 @@ EPS = np.finfo(np.float64).eps
def build(m, window=True, raw=True):
return FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), (16, 20)], level_objects=[(), (circle(),)],
return FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"], base_block=(3, 4),
fine_block=(16, 20), fine_objects=[circle()],
observe_raw_exchange=raw, observe_window_exchange=window)
@@ -109,11 +109,10 @@ class ExchangeWindowGPU(unittest.TestCase):
from test_raw_exchange_gpu import BOUNDS as RB, CFG, circle as rc, jet, state
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx:
kw = dict(context=ctx, refinements=[RB], level_blocks=[None, (16, 20)],
level_objects=[(), (rc(),)], level_jets=[(), (jet(),)],
kw = dict(context=ctx, fine_block=(16, 20), fine_objects=[rc()], jets=[jet()],
observe_raw_exchange=True)
with FixedPatchStepper(CFG, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(CFG, **kw) as plain:
with FixedPatchStepper(CFG, RB, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(CFG, RB, **kw) as plain:
arrays = [state(level.shape) for level in windowed._levels]
windowed.initialize(*arrays)
plain.initialize(*arrays)
@@ -201,11 +200,11 @@ class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
from CelerisLab.cuda.context import CudaContext
cfg = LBMConfig(nx=32, ny=24, viscosity=.1, collision="MRT", ddf_shifting=True)
with CudaContext(0) as ctx:
kw = dict(context=ctx, refinements=[RB], level_blocks=[(12, 8), (16, 20)],
level_objects=[(), (rc(),)], observe_raw_exchange=True)
with FixedPatchStepper(cfg, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(cfg, observe_window_exchange=True, **kw) as split, \
FixedPatchStepper(cfg, **kw) as plain:
kw = dict(context=ctx, base_block=(12, 8), fine_block=(16, 20), fine_objects=[rc()],
observe_raw_exchange=True)
with FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as split, \
FixedPatchStepper(cfg, RB, **kw) as plain:
arrays = [state(level.shape) for level in windowed._levels]
for run in (windowed, split, plain):
run.initialize(*arrays)
@@ -18,7 +18,7 @@ import CelerisLab
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm import refinement_stepper as implementation
from CelerisLab.lbm.descriptors import FLUID, SOLID
@@ -140,8 +140,7 @@ def run_patch(speed, *, audit=True):
build = (patch.object(compiler, "compile_kernel", audit_compiler(compiler.compile_kernel))
if audit else nullcontext())
with build:
backend = implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
level_objects=[(), (source,)])
backend = implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source])
with backend, context.activate():
c, f = backend._levels
cl = backend._fine_curved
@@ -282,8 +281,7 @@ def test_fine_surface_four_hops(speed):
@pytest.mark.parametrize("missing", ["surface_velocity_gpu", "link_exchange_gpu"])
def test_missing_surface_device_buffer_rejects_before_launch(missing):
backend = implementation.FixedPatchStepper.__new__(implementation.FixedPatchStepper)
backend._boundary_hosts = [None, SimpleNamespace(objects=[object_spec(.002)])]
backend._level_jets = [(), ()]
backend.fine_boundary = SimpleNamespace(objects=[object_spec(.002)])
backend._fine_curved = SimpleNamespace(fluid_idx_gpu=object(),
surface_velocity_gpu=object(), link_exchange_gpu=object())
setattr(backend._fine_curved, missing, None)
@@ -327,7 +325,6 @@ def test_cross_interface_donor_rejects_before_cuda():
context.activate.side_effect = AssertionError("must reject before CUDA activation")
with patch.object(cuda, "mem_alloc") as allocate:
with pytest.raises(ValueError, match="donor crosses"):
implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
level_objects=[(), (source,)])
implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source])
context.activate.assert_not_called()
allocate.assert_not_called()
@@ -48,8 +48,9 @@ def make(context):
for (x, y), amplitude in zip(centers, (.003, -.002))
)
run = FixedPatchStepper(
CFG, context=context, refinements=[PARENTS, START], level_blocks=[(4, 4)] * 3,
level_objects=[(), (), objects], level_jets=[(), (), jets],
CFG, PARENTS, context=context, base_block=(4, 4), fine_block=(4, 4),
child_block=(4, 4), child_rectangles_in_parent=START,
child_objects=objects, child_jets=jets,
)
return run, objects
@@ -25,8 +25,9 @@ B = ((16,16,32,40), (60,16,76,40))
def make(ctx):
body = SimObject(71, geometry=CircleGeometry(12.,14.,.8),center=(12.,14.),radius=.8)
jet = Jet(Tube((10.5,14.),0.,3.,.3),Parallel(0.),'parabolic',.003)
run = FixedPatchStepper(CFG,context=ctx,refinements=[PARENTS,A],level_blocks=[(4,4)]*3,
level_objects=[(),(),(body,)],level_jets=[(),(),(jet,)])
run = FixedPatchStepper(CFG,PARENTS,context=ctx,base_block=(4,4),fine_block=(4,4),
child_block=(4,4),child_rectangles_in_parent=A,
child_objects=(body,),child_jets=(jet,))
return run,(body,)
@@ -90,18 +90,14 @@ def census(run, index, buffer):
return totals(dense, leaf), external, independent
def fine(block):
"""Two-level circle+jet construction on BOUNDS; block None keeps the dense fine level."""
return dict(refinements=[BOUNDS], level_blocks=[None, block],
level_objects=[(), (circle(),)], level_jets=[(), (jet(),)])
class RawExchangeGPU(unittest.TestCase):
def test_row_ledger_interval_jet_switch_and_no_population_effect(self):
with CudaContext(0) as ctx:
for block in ((32, 40), (16, 20), (8, 10)):
with FixedPatchStepper(CFG, context=ctx, **fine(block), observe_raw_exchange=True) as measured, \
FixedPatchStepper(CFG, context=ctx, **fine(block)) as plain:
with FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=block,
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as measured, \
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=block,
fine_objects=[circle()], jets=[jet()]) as plain:
arrays = [state(l.shape) for l in measured._levels]
measured.initialize(*arrays)
plain.initialize(*arrays)
@@ -150,8 +146,9 @@ class RawExchangeGPU(unittest.TestCase):
"""Same fine resolution/initial data with dense storage: a numerical
reference for cut-link rows, not a uniform-whole-domain convergence study."""
with CudaContext(0) as ctx:
with FixedPatchStepper(CFG, context=ctx, **fine(None)) as dense, \
FixedPatchStepper(CFG, context=ctx, **fine((16, 20)), observe_raw_exchange=True) as blocked:
with FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_objects=[circle()], jets=[jet()]) as dense, \
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=(16, 20),
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as blocked:
arrays = [state(l.shape) for l in blocked._levels]
dense.initialize(*arrays)
blocked.initialize(*arrays)
@@ -224,13 +221,16 @@ class RawExchangeGPU(unittest.TestCase):
Eight local hops are a short-time equivalence check, not convergence.
The test adapter only enables the standalone kernel's optional inputs.
"""
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.block_stepper import PeriodicBlockStepper
fine_cfg = replace(CFG, nx=64, ny=48, viscosity=.2)
obj = SimObject(0, geometry=CircleGeometry(31.5, 23.5, 4.3),
center=(31.5, 23.5), radius=4.3)
layout = BlockLayout2D.build(48, 64, 8, 8)
with CudaContext(0) as ctx:
with FixedPatchStepper(fine_cfg, context=ctx, refinements=[], level_blocks=[(8, 8)],
level_objects=[(obj,)]) as uniform, \
FixedPatchStepper(CFG, context=ctx, **fine((16, 20)), observe_raw_exchange=True) as amr:
with PeriodicBlockStepper(fine_cfg, layout, context=ctx, objects=[obj]) as uniform, \
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=(16, 20),
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as amr:
amr.initialize(*(state(l.shape) for l in amr._levels))
uniform.initialize(state((9, 48, 64)))
b = amr._level_boundaries[1]
@@ -238,21 +238,18 @@ class RawExchangeGPU(unittest.TestCase):
links = obj.geometry.build_cut_links(64, 48)
np.testing.assert_array_equal([v.dir for v in links], b.tables['dir'])
np.testing.assert_allclose([v.q for v in links], b.tables['q'], atol=2e-7, rtol=0)
# Same fine-grid force arms after translation to the same center: the
# single level's static BFL gets the AMR Jet column and force arms; the raw
# output pointer is substituted at launch (no stepper-side accumulation).
root = uniform._level_boundaries[0]
# Same fine-grid force arms after translation to the same center.
rx = uniform._upload(b.tables['rx'])
ry = uniform._upload(b.tables['ry'])
velocity = uniform._upload(b.tables['surface_velocity'])
root.device.update(surface_velocity=velocity, rx=uniform._upload(b.tables['rx']),
ry=uniform._upload(b.tables['ry']))
raw = uniform._allocate(count*16)
original = root.kernel
original = uniform._curved
samples = []
def measured_boundary(*args, **kw):
original(*args[:10], raw, **kw) # BlockCurvedBoundary raw-exchange output
original(*args[:7], velocity, rx, ry, raw, **kw)
uniform._stream.synchronize()
samples.append(download(raw, (count, 4)))
root.kernel = measured_boundary
uniform._curved = measured_boundary
for speed in (0., .002, -.001, 0.):
amr.set_jet_amplitude(0, speed)
cuda.memcpy_htod(velocity, b.tables['surface_velocity'])
@@ -2,34 +2,6 @@
Run CPU and GPU classes in separate processes. GPU callers must first check
resources/device availability; this file never selects or reserves a GPU.
Dense/blocked route parity (DenseBlockedRouteParity): the same configuration,
initial state, body/Jet schedule and tilings on three storage routes, only
level_blocks varying: D/D [None, None], D/B [None, FINE_BLOCK] (dense root,
the mixed pattern of current examples) and B/B [(12, 8), FINE_BLOCK]. Gate:
every public and owner array identical in FP32 bits and NaN masks at steps
0, 1, 2, 8 and after re-initialize; D/B and B/B raw-exchange ledgers equal.
No tolerance. A mismatch is reported with route pair/level/step and diagnosed
(boundary semantics vs arithmetic ordering), never widened.
Route capabilities (rejections checked before CUDA by test_route_capability_matrix):
combination D/D D/B B/B
periodic / slot_reservoir root, fine circle yes yes yes (parity gate)
actuated circle, velocity Jet + amplitude yes yes yes (parity gate)
raw/window exchange on a body level rejected yes yes
shifted storage rejected rejected yes
projected explosion rejected rejected yes
channel root rejected rejected yes
several L1 rectangles rejected rejected yes
depth >= 3 with a dense level rejected rejected yes
Slot/reservoir walls are compile-time WALL_* macros on a dense root and the
d_block_wall constant on a blocked root: BC-encoding parity, bit-identical by
this gate. Dense fine bodies use CurvedLinkSoA + CurvedBoundaryKernel
(n_objects > 0), blocked ones BlockCurvedBoundary; same links and physics,
compared through the population rows. Parked here: MRT (covered by
test_refinement_mrt_gpu::test_dense_versus_blocked_mrt_two_level), shifted and
depth >= 3 (no dense route to compare), several bodies (later slice).
"""
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import replace
@@ -41,14 +13,13 @@ from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm import refinement_stepper
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST, FixedPatch2D
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, blocked_base_tables
from test_refinement_blocked_fine import NX, NY, BOUNDS, config, state, circle, actuation
from test_refinement_jets import SPEED, jet
BASE_BLOCKS = ((24, 32), (12, 8), (3, 4), (1, 1))
FINE_BLOCK = (16, 20)
@@ -102,7 +73,6 @@ def mocked_backend(mode="periodic", fail_alloc=None):
stack.enter_context(patch.object(compiler, name))
stack.enter_context(patch.object(compiler, "load_module", return_value=module))
stack.enter_context(patch.object(cuda, "Stream", return_value=stream))
stack.enter_context(patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)))
stack.enter_context(patch.object(cuda, "mem_alloc", side_effect=allocate))
stack.enter_context(patch.object(cuda, "memcpy_htod",
side_effect=lambda p, a: uploads.append((p, a))))
@@ -113,10 +83,11 @@ def mocked_backend(mode="periodic", fail_alloc=None):
class BlockedBaseCPU(unittest.TestCase):
def test_reject_before_cuda(self):
context = Mock(activate=Mock(side_effect=AssertionError("CUDA reached")))
for blocks in ([(12, 8), None], [(7, 8), FINE_BLOCK], [(12,), FINE_BLOCK]):
with self.subTest(blocks=blocks), self.assertRaises(ValueError):
FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=blocks)
for kwargs in ({"base_block": (12, 8)},
{"base_block": (7, 8), "fine_block": FINE_BLOCK},
{"base_block": (12,), "fine_block": FINE_BLOCK}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
FixedPatchStepper(config("periodic"), BOUNDS, context=context, **kwargs)
context.activate.assert_not_called()
def test_all_arrival_rows_donors_and_kind_masks(self):
@@ -222,10 +193,9 @@ class BlockedBaseCPU(unittest.TestCase):
def test_launch_order_stream_wall_constants_and_buffer_swaps(self):
with mocked_backend("slot_reservoir") as m:
backend = FixedPatchStepper(config("slot_reservoir"), context=m["context"],
coarse_boundary="slot_reservoir", refinements=[BOUNDS],
level_blocks=[(3, 4), FINE_BLOCK],
level_objects=[(), (circle(actuation(.002)),)])
backend = FixedPatchStepper(config("slot_reservoir"), BOUNDS, context=m["context"],
coarse_boundary="slot_reservoir", fine_block=FINE_BLOCK,
base_block=(3, 4), fine_objects=[circle(actuation(.002))])
try:
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
coarse, fine = backend._levels
@@ -253,8 +223,6 @@ class BlockedBaseCPU(unittest.TestCase):
self.assertIs(collide[4], coarse.kind)
walls = [a for ptr, a in m["uploads"] if isinstance(ptr, str) and ptr == "d_block_wall"]
self.assertEqual(len(walls), 1)
self.assertEqual([call.kwargs["block_root_bc"] for call in
compiler.generate_config.call_args_list], [1, 0])
width, height, slot, speed = struct.unpack("=IIIf", walls[0])
self.assertEqual((width, height, slot), (NX, NY, 8))
self.assertAlmostEqual(speed, .001)
@@ -268,16 +236,16 @@ class BlockedBaseCPU(unittest.TestCase):
for fail_at in (1, 6, 10):
with self.subTest(fail_at=fail_at), mocked_backend(fail_alloc=fail_at) as m:
with self.assertRaisesRegex(MemoryError, "injected allocation"):
FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), FINE_BLOCK])
FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"],
fine_block=FINE_BLOCK, base_block=(3, 4))
self.assertEqual(len(m["allocations"]), fail_at)
for allocation in m["allocations"]:
allocation.free.assert_called_once_with()
def test_failed_parent_stage_cannot_publish_or_resume(self):
with mocked_backend() as m:
backend = FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), FINE_BLOCK])
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"],
fine_block=FINE_BLOCK, base_block=(3, 4))
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
m["functions"]["BlockCoalesce"].side_effect = RuntimeError("injected parent stage failure")
current = backend._levels[0].current
@@ -321,10 +289,9 @@ class BlockedBaseGPU(unittest.TestCase):
for base_block in (None, *layouts):
label = f"{mode} width={width} speed={speed} base={base_block}"
snapshots = []
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[BOUNDS], level_blocks=[base_block, FINE_BLOCK],
level_objects=[(), () if speed is None
else (circle(actuation(speed)),)]) as backend:
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
fine_block=FINE_BLOCK, base_block=base_block,
fine_objects=() if speed is None else [circle(actuation(speed))]) as backend:
backend.initialize(*arrays)
for step in (0, 1, 2, 8):
if step == 1 and base_block is not None:
@@ -353,128 +320,5 @@ class BlockedBaseGPU(unittest.TestCase):
print(f"PASS {label}: full finite bits/NaN masks at 0,1,2,8 + reset", flush=True)
ROUTES = (("D/D", [None, None]), ("D/B", [None, FINE_BLOCK]), ("B/B", [(12, 8), FINE_BLOCK]))
def same_summary(a, b):
"""Exact equality of nested exchange summaries (dicts, sequences, arrays, numbers)."""
if isinstance(a, dict):
return a.keys() == b.keys() and all(same_summary(a[k], b[k]) for k in a)
if isinstance(a, (list, tuple)):
return len(a) == len(b) and all(same_summary(x, y) for x, y in zip(a, b))
a, b = np.asarray(a), np.asarray(b)
if a.dtype.kind in "biufc" and b.dtype.kind in "biufc":
return a.shape == b.shape and np.array_equal(a, b, equal_nan=True)
return a.dtype == b.dtype and np.array_equal(a, b) # labels such as the window name
class DenseBlockedRouteCPU(unittest.TestCase):
def test_same_summary_compares_labels_and_every_number(self):
ledger = {"window": "last_base_step", "time_start": 3, "total": {"force": [1.0, -2.0],
"torque": 0.5}, "aperture": {"force": np.array([np.nan, 1.0])}}
self.assertTrue(same_summary(ledger, replace_value(ledger, ())))
for path, value in ((("window",), "last_substep"), (("time_start",), 4),
(("total", "force"), [1.0, -2.000001]), (("total", "torque"), 0.25),
(("aperture", "force"), np.array([0.0, 1.0]))):
with self.subTest(path=path):
self.assertFalse(same_summary(ledger, replace_value(ledger, path, value)))
self.assertFalse(same_summary(replace_value(ledger, path, value), ledger))
def test_route_capability_matrix(self):
context = Mock(activate=Mock(side_effect=AssertionError("CUDA reached")))
periodic = config("periodic")
inner = (BOUNDS[0] + 2, BOUNDS[1] + 2, BOUNDS[2] - 2, BOUNDS[3] - 2)
second = (BOUNDS[0], BOUNDS[1], BOUNDS[0] + 2, BOUNDS[1] + 2)
cases = (
("raw exchange on dense body level", periodic, dict(
level_blocks=[None, None], level_objects=[(), (circle(),)], observe_raw_exchange=True)),
("shifted D/D", replace(periodic, ddf_shifting=True), dict(level_blocks=[None, None])),
("shifted D/B", replace(periodic, ddf_shifting=True), dict(level_blocks=[None, FINE_BLOCK])),
("projected D/D", periodic, dict(level_blocks=[None, None], explosion="projected_linear")),
("projected D/B", periodic, dict(level_blocks=[None, FINE_BLOCK], explosion="projected_linear")),
("channel D/B", periodic, dict(level_blocks=[None, FINE_BLOCK], coarse_boundary="channel")),
("several L1 rectangles", periodic, dict(refinements=[[BOUNDS, second]], level_blocks=[None, None])),
("depth 3 dense", periodic, dict(refinements=[BOUNDS, inner], level_blocks=[None, None, None])),
)
for label, cfg, kw in cases:
kw.setdefault("refinements", [BOUNDS])
with self.subTest(label), self.assertRaises(ValueError):
FixedPatchStepper(cfg, context=context, **kw)
context.activate.assert_not_called()
def replace_value(tree, path, value=None):
"""Deep copy of a nested dict with the item at ``path`` replaced (no change for ())."""
if isinstance(tree, dict):
return {k: (value if path == (k,) else replace_value(v, path[1:], value) if path[:1] == (k,)
else replace_value(v, (), None)) for k, v in tree.items()}
return np.array(tree, copy=True) if isinstance(tree, np.ndarray) else tree
class DenseBlockedRouteParity(unittest.TestCase):
"""D/D vs D/B vs B/B, unshifted SRT, one circle or one velocity Jet; see module docstring."""
def run_routes(self, mode, objects=(), jets=(), switch=None):
from CelerisLab.cuda.context import CudaContext
cfg = config(mode)
p = FixedPatch2D.from_config(cfg, BOUNDS)
arrays = state(NY, NX, 1), state(*p.fine_shape, 2)
results = {}
with CudaContext(0) as context:
for name, blocks in ROUTES:
observe = bool(objects) and blocks[1] is not None
snapshots, ledgers = [], []
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode, refinements=[BOUNDS],
level_blocks=blocks, level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)],
observe_raw_exchange=observe) as backend:
backend.initialize(*arrays)
for step in (0, 1, 2, 8):
if step == 8 and switch is not None:
backend.set_jet_amplitude(0, switch)
backend.step(step - backend.step_count)
snapshots.append(backend.download() + raw(backend))
if observe and step:
ledgers.append(tuple(backend.read_raw_exchange(0, window=window)
for window in ("last_base_step", "last_substep")))
backend.initialize(*arrays)
snapshots.append(backend.download() + raw(backend))
results[name] = snapshots, ledgers
return results
def check(self, label, results):
reference, _ = results["D/D"]
for name in ("D/B", "B/B"):
for step, actual, expected in zip((0, 1, 2, 8, "reset"), results[name][0], reference):
for index, (a, b) in enumerate(zip(actual, expected)):
exact(self, a, b, f"{label} {name} vs D/D step={step} array={index}")
if results["D/B"][1]:
for step, a, b in zip((1, 2, 8), results["D/B"][1], results["B/B"][1]):
self.assertTrue(same_summary(a, b), f"{label} raw exchange D/B vs B/B step={step}")
for name, (snapshots, _) in results.items():
masses = [float(np.nansum(level.astype(np.float64))) for level in snapshots[3][:2]]
print(f"{label} {name}: step 8 mass by level {masses}", flush=True)
print(f"PASS {label}: D/D = D/B = B/B bits/NaN masks at 0,1,2,8 + reset", flush=True)
def test_body_free(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} body-free", self.run_routes(mode))
def test_passive_circle(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} circle", self.run_routes(mode, objects=[circle()]))
def test_actuated_circle(self):
for mode in ("periodic", "slot_reservoir"):
for speed in (SPEED, -SPEED):
self.check(f"{mode} actuated {speed}",
self.run_routes(mode, objects=[circle(actuation(speed))]))
def test_velocity_jet_with_amplitude_switch(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} jet switch",
self.run_routes(mode, objects=[circle()], jets=[jet()], switch=-SPEED))
if __name__ == "__main__":
unittest.main()
@@ -15,7 +15,7 @@ import pycuda.driver as cuda
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject, SurfaceActuation
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm import curved_links
from CelerisLab.lbm.blocks import NO_DONOR, BlockLayout2D, blocked_link_tables
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
@@ -105,10 +105,9 @@ class BlockedFineCPU(unittest.TestCase):
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
def test_rejections_before_cuda(self):
for kw in ({"level_blocks": [None, (7, 7)]}, {"level_blocks": [None, (16,)]}):
for kw in ({"fine_block": (7, 7)}, {"fine_block": (16,)}):
with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(config("periodic"), context=self.forbidden_context(),
refinements=[BOUNDS], **kw)
FixedPatchStepper(config("periodic"), BOUNDS, context=self.forbidden_context(), **kw)
def test_single_block_layout_allocates_no_empty_halo_tables(self):
# A 1x1 non-periodic fine layout has zero halo pairs; CUDA rejects mem_alloc(0).
@@ -122,12 +121,12 @@ class BlockedFineCPU(unittest.TestCase):
raise AssertionError("zero-byte device allocation")
sizes.append(size)
return Mock()
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=alloc), patch.object(cuda, "memcpy_htod"):
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=[None, FINE_BLOCKS[0]])
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
fine_block=FINE_BLOCKS[0])
self.assertEqual(len(backend.fine_layout.halo_dst), 0)
self.assertIsNone(backend._levels[1].halo_dst)
backend._gather_halo(backend._levels[1], Mock()) # no launch without pairs
@@ -172,15 +171,14 @@ class BlockedFineCPU(unittest.TestCase):
return functions.setdefault(name, Mock(side_effect=lambda *a, **k: calls.append((name, a))))
module = Mock(get_function=Mock(side_effect=get_function))
module.get_global.return_value = (Mock(), 20)
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
patch.object(cuda, "memcpy_htod"), \
patch.object(curved_links, "CurvedLinkSoA", side_effect=AssertionError("dense SoA built")):
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=[None, FINE_BLOCKS[1]],
level_objects=[(), tuple(objects)])
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
fine_objects=objects, fine_block=FINE_BLOCKS[1])
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
return backend, calls
@@ -291,9 +289,8 @@ class BlockedFineGPU(unittest.TestCase):
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[BOUNDS],
level_blocks=[None, fine_block]) as backend:
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
fine_block=fine_block) as backend:
backend.initialize(g_coarse, g_fine)
backend.step(STEPS)
self.assertEqual(backend.step_count, STEPS)
@@ -321,9 +318,8 @@ class BlockedFineGPU(unittest.TestCase):
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[BOUNDS], level_blocks=[None, fine_block],
level_objects=[(), (circle(),)]) as backend:
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
fine_objects=[circle()], fine_block=fine_block) as backend:
backend.initialize(g_coarse, g_fine)
backend.step(STEPS)
results.append((backend.download(), raw(backend)))
@@ -350,9 +346,9 @@ class BlockedFineGPU(unittest.TestCase):
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[BOUNDS], level_blocks=[None, fine_block],
level_objects=[(), (circle(actuation(speed)),)]) as backend:
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
fine_objects=[circle(actuation(speed))],
fine_block=fine_block) as backend:
backend.initialize(g_coarse, g_fine)
backend.step(STEPS)
results.append((backend.download(), raw(backend)))
@@ -94,8 +94,7 @@ class FaultGuardsCPU(unittest.TestCase):
("slot_reservoir", (12, 4, 20, 21))):
with self.subTest(mode=mode, bounds=bounds):
with self.assertRaises(ValueError):
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[bounds])
implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode)
context.activate.assert_not_called()
# Both exact channel limits and the original periodic two-cell margin
# must reach context activation, with no allocation or device required.
@@ -103,14 +102,11 @@ class FaultGuardsCPU(unittest.TestCase):
("periodic", (2, 2, 6, 5))):
with self.subTest(mode=mode, bounds=bounds):
with self.assertRaisesRegex(RuntimeError, "validated before CUDA"):
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[bounds])
implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode)
def fake_backend(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend._layouts = [None, None] # dense storage on both levels
backend._ddf_shifting = False # unshifted on every level, as _check_storage expects
backend._level_cfgs = [LBMConfig(nx=16, ny=16, viscosity=.1)] * 2 # ddf_shifting off
backend.fine_layout = None # dense fine storage
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "ready"
@@ -158,31 +154,20 @@ class FaultGuardsCPU(unittest.TestCase):
context = Mock(activate=nullcontext, sm_arch="sm_70")
with patch.object(implementation, "HALO", 2):
with self.assertRaisesRegex(RuntimeError, "four-cell"):
PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)])
PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context)
p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
from CelerisLab.lbm import hierarchy
bad = patch.object(implementation.FixedPatch2D, "from_config",
return_value=replace(p, tau_coarse=.9))
# A pair tau inconsistent with its level config is rejected twice over, both before
# any device allocation or upload: first by the hierarchy chain check, and, if that
# is bypassed, by the per-level omega/tau check in _build_level.
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), bad, patch.object(cuda, "mem_alloc") as alloc, \
patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "config chain is inconsistent"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
with patch.object(hierarchy, "_check_chain"), \
self.assertRaisesRegex(RuntimeError, "omega/tau"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
alloc.assert_not_called()
upload.assert_not_called()
with patch.object(cuda, "Stream"), patch.object(implementation.FixedPatch2D, "from_config",
return_value=replace(p, tau_coarse=.9)):
with self.assertRaisesRegex(RuntimeError, "omega/tau"):
PeriodicPatchStepper(cfg, p.bounds, context=context)
module = Mock()
module.get_global.return_value = (Mock(), 16)
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
PeriodicPatchStepper(cfg, p.bounds, context=context)
upload.assert_not_called()
@@ -256,7 +241,7 @@ class NormalBuildGPU(unittest.TestCase):
max_error = max_bc_error = 0.0
with CudaContext(0) as context:
with patch.object(compiler, "generate_config", side_effect=record_config):
backend = implementation.FixedPatchStepper(cfg, context=context, refinements=[bounds],
backend = implementation.FixedPatchStepper(cfg, bounds, context=context,
coarse_boundary="slot_reservoir")
with backend:
self.assertEqual(len(generated), 2)
@@ -326,8 +311,7 @@ class NormalBuildGPU(unittest.TestCase):
for a in (initial.coarse_f, initial.fine_f)))
max_pop = max_drift = max_moment = 0.0
with CudaContext(0) as context, PeriodicPatchStepper(
LBMConfig(nx=16, ny=16, viscosity=.1), context=context,
refinements=[(4, 4, 12, 12)]) as backend:
LBMConfig(nx=16, ny=16, viscosity=.1), (4, 4, 12, 12), context=context) as backend:
backend.initialize(*postcollision(state, spec, maps))
before = totals(backend.download(), maps)
for step in range(1, 9):
@@ -358,7 +342,7 @@ class NormalBuildGPU(unittest.TestCase):
fine_mask[4:-4, 4:-4] = True
initial[1][:, ~fine_mask] = np.nan
expected = scalar_interval(*[a.astype(np.float64) for a in initial], bounds)
with CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[bounds]) as backend:
with CudaContext(0) as context, PeriodicPatchStepper(cfg, bounds, context=context) as backend:
self.assertEqual(backend.patch.fine_shape, (14, 16))
p = backend.patch
allocated = 2 * 9 * (17*13+16*14) * 4 + sum(a.nbytes for a in
@@ -20,7 +20,7 @@ import pycuda.driver as cuda
import CelerisLab
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, PeriodicPatchStepper
@@ -84,13 +84,13 @@ class CoupledGPU(unittest.TestCase):
cases = (("dense_unshifted", False, {}),
("blocked_shifted", True,
dict(level_blocks=[(4, 4), (6, 6)])))
dict(base_block=(4, 4), fine_block=(6, 6))))
with CudaContext(0) as context:
for label, shifted, storage in cases:
with self.subTest(storage=label):
cfg = LBMConfig(nx=16, ny=16, viscosity=.0192,
ddf_shifting=shifted)
with FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context,
**storage) as backend:
self.assertAlmostEqual(backend.patch.tau_coarse, spec.tau_coarse)
self.assertAlmostEqual(backend.patch.tau_fine, spec.tau_fine)
@@ -148,7 +148,7 @@ class CoupledGPU(unittest.TestCase):
maps = ref.build_maps(spec)
with CudaContext(0) as context:
with PeriodicPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1),
context=context, refinements=[(4, 4, 12, 12)]) as backend:
(4, 4, 12, 12), context=context) as backend:
backend.initialize(*postcollision(ref.make_state(spec), spec, maps))
coarse, fine = backend._levels
@@ -187,7 +187,7 @@ class CoupledGPU(unittest.TestCase):
with CudaContext(0) as context:
with patch.object(compiler, "compile_kernel", audit_compile):
backend = PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)])
backend = PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context)
with backend:
# Production allocations: four DDF arrays and four compact tables.
device_bytes = 2 * 9 * (16**2 + 24**2) * 4
@@ -270,7 +270,7 @@ class CoupledGPU(unittest.TestCase):
backend.download()
# Compile and execute the actual non-audited entry as well: one step.
with PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)]) as plain:
with PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as plain:
rest = postcollision(ref.make_state(spec), spec, maps)
plain.initialize(*rest)
plain.step()
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.lbm.descriptors import SOLID
from CelerisLab.lbm.refinement import ACTIVE, FixedPatch2D, prepare_fine_boundary
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
@@ -36,14 +36,13 @@ def mocked(objects, jets, fine_block):
module = Mock(get_function=Mock(return_value=Mock()))
module.get_global.return_value = (Mock(), 20)
htod = Mock()
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
patch.object(cuda, "memcpy_htod", htod):
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=[None, fine_block], level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)])
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
fine_objects=objects, fine_block=fine_block, jets=jets)
return backend, htod
@@ -150,9 +149,8 @@ class JetStepperCPU(unittest.TestCase):
def test_jet_overlapping_legacy_aperture_rejected_before_cuda(self):
with patch.object(cuda, "Stream", side_effect=AssertionError("CUDA touched")):
with self.assertRaisesRegex(ValueError, "SurfaceActuation"):
FixedPatchStepper(config("periodic"), context=Mock(), refinements=[BOUNDS],
level_objects=[(), (circle(actuation(SPEED)),)],
level_jets=[(), (jet(),)])
FixedPatchStepper(config("periodic"), BOUNDS, context=Mock(),
fine_objects=[circle(actuation(SPEED))], jets=[jet()])
def run(mode, fine_block, *, objects, jets, switch=None):
@@ -160,9 +158,8 @@ def run(mode, fine_block, *, objects, jets, switch=None):
cfg = config(mode)
patch_ = FixedPatch2D.from_config(cfg, BOUNDS)
with CudaContext(0) as context, FixedPatchStepper(
cfg, context=context, coarse_boundary=mode, refinements=[BOUNDS],
level_blocks=[None, fine_block], level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)]) as backend:
cfg, BOUNDS, context=context, coarse_boundary=mode, fine_objects=objects,
fine_block=fine_block, jets=jets) as backend:
backend.initialize(state(NY, NX, 1), state(*patch_.fine_shape, 2))
if switch is None:
backend.step(STEPS)
@@ -42,9 +42,8 @@ class RefinementMRTGPU(unittest.TestCase):
collision="MRT", ddf_shifting=shifted)
reference = None
for block in ((32, 48), (8, 8), (4, 1)):
with FixedPatchStepper(cfg, context=context, coarse_boundary=boundary,
refinements=[(8, 6, 40, 26)], level_blocks=[block, (8, 8)],
level_objects=[(), (body,)]) as backend:
with FixedPatchStepper(cfg, (8, 6, 40, 26), context=context, coarse_boundary=boundary,
base_block=block, fine_block=(8, 8), fine_objects=(body,)) as backend:
backend.initialize(*[state(*level.shape[1:]) for level in backend._levels])
backend.step(8)
raw = backend._download_raw()
@@ -64,8 +63,8 @@ class RefinementMRTGPU(unittest.TestCase):
results = {}
layouts = ((None, None), *((base, FINE_BLOCK) for base in (None, *BASE_BLOCKS)))
for base_block, fine_block in layouts:
with FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
level_blocks=[base_block, fine_block]) as backend:
with FixedPatchStepper(cfg, BOUNDS, context=context, fine_block=fine_block,
base_block=base_block) as backend:
backend.initialize(*[fixture.state(*level.shape[1:], seed) for seed, level in enumerate(backend._levels)])
backend.step(4)
results[base_block, fine_block] = raw(backend)
@@ -76,9 +75,9 @@ class RefinementMRTGPU(unittest.TestCase):
def test_shifted_all_blocked_three_level_mrt_smoke(self):
"""Per-level shear rate omega_l = 1/(3*2**l*nu + 1/2); finite ACTIVE leaves."""
cfg = LBMConfig(nx=16, ny=16, viscosity=.1, collision="MRT", ddf_shifting=True)
kw = dict(refinements=[(4, 4, 12, 12), (8, 8, 16, 16)], level_blocks=[(4, 4)] * 3)
kw = dict(fine_block=(4, 4), base_block=(4, 4), child=(8, 8, 16, 16), child_block=(4, 4))
with CudaContext(0) as context:
with FixedPatchStepper(cfg, context=context, **kw) as backend:
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context, **kw) as backend:
self.assertEqual(len(backend._level_cfgs), 3)
for level, level_cfg in enumerate(backend._level_cfgs):
checked = replace(level_cfg)
@@ -26,12 +26,6 @@ FACE = ((4, 4, 8, 12), (12, 4, 16, 12))
S1 = ((4, 4, 12, 12), (20, 4, 28, 12))
def levels(bounds, blocks=(None, None), objects=(), jets=(), **rest):
"""Two-level constructor keywords: one L1 rectangle or set, per-level blocks, L1 bodies/Jets."""
return dict(refinements=[bounds], level_blocks=list(blocks),
level_objects=[(), tuple(objects)], level_jets=[(), tuple(jets)], **rest)
def level_fine(patch_set, windows):
"""Level-wide fine array with each patch window filled from `windows`."""
out = np.full((9, *patch_set.fine_shape), np.nan, np.float32)
@@ -51,21 +45,21 @@ class MultipatchCPU(unittest.TestCase):
def test_rejections_before_cuda(self):
cfg = config("periodic")
context = object() # never activated: every case must fail first
for kw in (dict(), dict(blocks=(None, (8, 4))), dict(blocks=((4, 4), None)),
dict(blocks=((4, 4), (8, 4)), objects=[circle()]),
dict(blocks=((4, 4), (5, 4)))):
for kw in (dict(), dict(fine_block=(8, 4)), dict(base_block=(4, 4)),
dict(fine_block=(8, 4), base_block=(4, 4), fine_objects=[circle()]),
dict(fine_block=(5, 4), base_block=(4, 4))):
with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(cfg, context=context, **levels(((4, 4, 8, 12), (14, 4, 18, 12)), **kw))
FixedPatchStepper(cfg, ((4, 4, 8, 12), (14, 4, 18, 12)), context=context, **kw)
with self.assertRaisesRegex(ValueError, "margin"):
FixedPatchStepper(cfg, context=context,
**levels(((1, 4, 8, 12), (10, 4, 14, 12)), blocks=((4, 4), (8, 4))))
FixedPatchStepper(cfg, ((1, 4, 8, 12), (10, 4, 14, 12)), context=context,
fine_block=(8, 4), base_block=(4, 4))
from CelerisLab.body.jets import Jet, Parallel, Tube
cfg32 = LBMConfig(nx=32, ny=16, viscosity=.1)
span = Jet(Tube((5., 8.), 0., 22., .6), Parallel(0.), amplitude=0.)
with self.assertRaisesRegex(ValueError, "one jet"): # same obj_id cannot bypass it
FixedPatchStepper(cfg32, context=context,
**levels(((2, 2, 14, 14), (18, 2, 30, 14)), blocks=((4, 4), (4, 4)),
objects=[body(8., 8.), body(24., 8.)], jets=[span]))
FixedPatchStepper(cfg32, ((2, 2, 14, 14), (18, 2, 30, 14)), context=context,
fine_block=(4, 4), base_block=(4, 4),
fine_objects=[body(8., 8.), body(24., 8.)], jets=[span])
class MultipatchGPU(unittest.TestCase):
@@ -73,7 +67,7 @@ class MultipatchGPU(unittest.TestCase):
def run_steps(self, context, cfg, bounds, coarse, fine, **kw):
out = []
with FixedPatchStepper(cfg, context=context, **levels(bounds, **kw)) as backend:
with FixedPatchStepper(cfg, bounds, context=context, **kw) as backend:
backend.initialize(coarse, fine)
for step in self.STEPS:
backend.step(step - backend.step_count)
@@ -88,7 +82,7 @@ class MultipatchGPU(unittest.TestCase):
p = FixedPatch2D.from_config(cfg, BOUNDS)
s = FixedPatchSet2D.from_config(cfg, (BOUNDS,))
coarse, fine = state(NY, NX, 1), state(*p.fine_shape, 2)
kw = dict(coarse_boundary=mode, blocks=((3, 4), (8, 4)))
kw = dict(coarse_boundary=mode, fine_block=(8, 4), base_block=(3, 4))
old = self.run_steps(context, cfg, BOUNDS, coarse, fine, **kw)
new = self.run_steps(context, cfg, (BOUNDS,), coarse, level_fine(s, [fine]), **kw)
for step, a, b in zip(self.STEPS, old, new):
@@ -105,7 +99,7 @@ class MultipatchGPU(unittest.TestCase):
one = FixedPatchSet2D.from_config(cfg16, (S1[0],))
two = FixedPatchSet2D.from_config(cfg32, S1)
coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4)
kw = dict(blocks=((4, 4), (4, 4)))
kw = dict(fine_block=(4, 4), base_block=(4, 4))
with CudaContext(0) as context:
ref = self.run_steps(context, cfg16, (S1[0],), coarse, level_fine(one, [fine]), **kw)
tiled = np.concatenate([coarse, coarse], axis=2)
@@ -124,7 +118,7 @@ class MultipatchGPU(unittest.TestCase):
s = FixedPatchSet2D.from_config(cfg, FACE)
coarse = state(16, 24, 5)
fine = level_fine(s, [state(*p.fine_shape, 6 + i) for i, p in enumerate(s.patches)])
kw = dict(blocks=((4, 4), (4, 4)))
kw = dict(fine_block=(4, 4), base_block=(4, 4))
leaf = s.fine_kind == ACTIVE
support = s.fine_layout((4, 4)).owned
outer = support & (s.fine_kind == OUTER_GHOST)
@@ -170,7 +164,7 @@ class MultipatchBodiesGPU(unittest.TestCase):
def run_steps(self, context, cfg, bounds, coarse, fine, switch=None, **kw):
out = []
with FixedPatchStepper(cfg, context=context, **levels(bounds, **kw)) as backend:
with FixedPatchStepper(cfg, bounds, context=context, **kw) as backend:
backend.initialize(coarse, fine)
for step in self.STEPS:
if switch is not None and step == 8:
@@ -188,7 +182,8 @@ class MultipatchBodiesGPU(unittest.TestCase):
p = FixedPatch2D.from_config(cfg, BOUNDS)
s = FixedPatchSet2D.from_config(cfg, (BOUNDS,))
coarse, fine = state(NY, NX, 1), state(*p.fine_shape, 2)
kw = dict(coarse_boundary=mode, blocks=((3, 4), (8, 4)), objects=[circle()])
kw = dict(coarse_boundary=mode, fine_block=(8, 4), base_block=(3, 4),
fine_objects=[circle()])
old = self.run_steps(context, cfg, BOUNDS, coarse, fine, **kw)
new = self.run_steps(context, cfg, (BOUNDS,), coarse, level_fine(s, [fine]), **kw)
for step, a, b in zip(self.STEPS, old, new):
@@ -207,17 +202,17 @@ class MultipatchBodiesGPU(unittest.TestCase):
two = FixedPatchSet2D.from_config(cfg32, self.PATCHES)
coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4)
jet = lambda x: Jet(Tube((x, 8.), 0., 6., .6), Parallel(0.), amplitude=.002)
kw = dict(blocks=((4, 4), (4, 4)))
kw = dict(fine_block=(4, 4), base_block=(4, 4))
with CudaContext(0) as context:
for jets in ((), (jet(5.),)):
ref = self.run_steps(context, cfg16, self.PATCHES[:1], coarse, level_fine(one, [fine]),
switch=[(0, -.001)] if jets else None,
objects=[body(8., 8.)], jets=jets, **kw)
fine_objects=[body(8., 8.)], jets=jets, **kw)
both = jets and (jet(5.), jet(21.))
got = self.run_steps(context, cfg32, self.PATCHES, np.concatenate([coarse, coarse], 2),
level_fine(two, [fine, fine]),
switch=[(0, -.001), (1, -.001)] if jets else None,
objects=[body(8., 8., 5), body(24., 8., 5)],
fine_objects=[body(8., 8., 5), body(24., 8., 5)],
jets=both or (), **kw)
for step, a, b in zip(self.STEPS, ref, got):
label = f"jets={len(jets)} step {step}"
@@ -27,16 +27,7 @@ from test_refinement_blocked_base import raw
CFG = LBMConfig(nx=16, ny=16, viscosity=.1)
L1, L2 = (4, 4, 12, 12), (8, 8, 16, 16)
B = (4, 4)
def levels(l1=L1, l2=L2, blocks=(B, B, B), objects=((), (), ()), jets=((), (), ())):
"""Level-list keywords for the scalar L1 patch with one scalar L2 child."""
return dict(refinements=[l1, l2], level_blocks=list(blocks),
level_objects=list(objects), level_jets=list(jets))
KW = levels()
KW = dict(fine_block=(4, 4), base_block=(4, 4), child=L2, child_block=(4, 4))
def child_body(spec=None):
@@ -52,38 +43,35 @@ def child_jet(speed=.002):
def mocked_child():
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
context = Mock(activate=nullcontext, sm_arch="sm_70")
module = Mock(get_function=Mock(side_effect=lambda name: Mock(name=name)))
module.get_global.return_value = (Mock(), 20)
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
patch.object(cuda, "memcpy_htod"):
return FixedPatchStepper(CFG, context=context,
**levels(objects=((), (), (child_body(),)), jets=((), (), (child_jet(),))))
return FixedPatchStepper(CFG, L1, context=context, child_objects=[child_body()],
child_jets=[child_jet()], **KW)
class ThreeLevelCPU(unittest.TestCase):
def test_rejections_before_cuda(self):
context = object() # never activated: every case must fail first
bad = (levels(blocks=(B, B, None)), levels(blocks=(B, None, B)),
levels(objects=((), (circle(),), ())), levels(l2=(4, 8, 16, 16)),
levels(blocks=(B, B, (5, 4))))
bad = (dict(KW, child_block=None), dict(KW, fine_block=None),
dict(KW, fine_objects=[circle()]), dict(KW, child=(4, 8, 16, 16)),
dict(KW, child_block=(5, 4)))
for kw in bad:
with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(CFG, context=context, **kw)
# L2 bodies/Jets without an L2 level (one entry too many), and an L2 Jet without a body.
two_level = dict(refinements=[L1], level_blocks=[None, None])
for kw in (dict(two_level, level_objects=[(), (), (child_body(),)]),
dict(two_level, level_jets=[(), (), (child_jet(),)]),
levels(jets=((), (), (child_jet(),)))):
FixedPatchStepper(CFG, L1, context=context, **kw)
for kw in (dict(child_objects=[child_body()]), dict(child_jets=[child_jet()]),
dict(KW, child_jets=[child_jet()])):
with self.assertRaises(ValueError):
FixedPatchStepper(CFG, context=context, **kw)
with self.assertRaises(ValueError): # several L1 patches + scalar child: not yet
FixedPatchStepper(LBMConfig(nx=32, ny=16, viscosity=.1), context=context,
**levels(l1=((4, 4, 12, 12), (20, 4, 28, 12))))
FixedPatchStepper(CFG, L1, context=context, **kw)
with self.assertRaises(ValueError): # several L1 patches + child: not yet
FixedPatchStepper(LBMConfig(nx=32, ny=16, viscosity=.1), ((4, 4, 12, 12), (20, 4, 28, 12)),
context=context, **KW)
def test_four_halo_bfl_steps_and_fresh_middle_feeds(self):
@@ -168,7 +156,7 @@ class ThreeLevelGPU(unittest.TestCase):
def test_equilibrium_is_kept_on_every_level(self):
from CelerisLab.cuda.context import CudaContext
feq = np.asarray(compute_feq_d2q9(1.0, .03, -.02), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
with CudaContext(0) as ctx, FixedPatchStepper(CFG, L1, context=ctx, **KW) as run:
shapes = ((16, 16), run.patch.fine_shape, run.child_patch.fine_shape)
run.initialize(*(np.broadcast_to(feq, (9, *s)).copy() for s in shapes))
run.step(8)
@@ -181,7 +169,7 @@ class ThreeLevelGPU(unittest.TestCase):
def test_nonequilibrium_masks_and_fresh_middle_feeds(self):
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
with CudaContext(0) as ctx, FixedPatchStepper(CFG, L1, context=ctx, **KW) as run:
p1, p2 = run.patch, run.child_patch
run.initialize(state(16, 16, 1), state(*p1.fine_shape, 2), state(*p2.fine_shape, 3))
f = run._levels[1]
@@ -217,9 +205,8 @@ class ThreeLevelBodyGPU(unittest.TestCase):
CFG, velocity=0., wall_normal_velocity=.001, wall_slot_width=4,
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, coarse_boundary=mode,
**levels(blocks=(B, B, block), objects=((), (), (child_body(spec),)),
jets=((), (), tuple(jets)))) as run:
cfg, L1, context=ctx, coarse_boundary=mode, child_objects=[child_body(spec)],
child_jets=jets, **dict(KW, child_block=block)) as run:
run.initialize(*(state(*level.shape[1:], i + 1) for i, level in enumerate(run._levels)))
snapshots = []
for stop in (0, 1, 2, 4, 8):
@@ -78,10 +78,10 @@ class ThreeLevelMultipatchGPU(unittest.TestCase):
def run_case(self, ctx, cfg=CFG, parents=PARENTS, children=CHILDREN,
mode='periodic', blocks=((4, 4), (4, 4)), objects=(), drive=(), switch=False,
scalar_child=None):
child = children if scalar_child is None else scalar_child
with FixedPatchStepper(config(cfg, mode), context=ctx, coarse_boundary=mode,
refinements=[parents, child], level_blocks=[(4, 4), blocks[0], blocks[1]],
level_objects=[(), (), objects], level_jets=[(), (), drive]) as run:
kwargs = dict(child_rectangles_in_parent=children) if scalar_child is None else dict(child=scalar_child)
with FixedPatchStepper(config(cfg, mode), parents, context=ctx, coarse_boundary=mode,
base_block=(4, 4), fine_block=blocks[0], child_block=blocks[1],
child_objects=objects, child_jets=drive, **kwargs) as run:
frame = frames(run)
run.initialize(*(physical_state(level.shape[1:], *f)
for level, f in zip(run._levels, frame)))
@@ -26,8 +26,9 @@ FP64_ATOL = 3e-6
def make(ctx):
return FixedPatchStepper(CFG, context=ctx, refinements=[PARENTS, A],
level_blocks=[(4, 4)] * 3)
return FixedPatchStepper(CFG, PARENTS, context=ctx, base_block=(4, 4),
fine_block=(4, 4), child_block=(4, 4),
child_rectangles_in_parent=A)
def initial(run, uniform):
@@ -136,8 +136,8 @@ class ShiftedJetsGPU(unittest.TestCase):
Parallel(0.),amplitude=.013)
for rho in (.99,1.01):
with self.subTest(rho=rho),CudaContext(0) as ctx:
with FixedPatchStepper(cfg,context=ctx,refinements=[(8,6,24,18)],level_blocks=[(4,4)]*2,
level_objects=[(),(obj,)],level_jets=[(),(whole,)],observe_raw_exchange=True) as run:
with FixedPatchStepper(cfg,(8,6,24,18),context=ctx,base_block=(4,4),fine_block=(4,4),
fine_objects=(obj,),jets=(whole,),observe_raw_exchange=True) as run:
run._initialize_raw(initial(run,rho,u=.013,v=0.))
expected=run._download_raw();ledger=Ledger(self,run,1)
self.assertTrue(run._level_boundaries[1].tables['aperture'].all())
@@ -153,8 +153,7 @@ class ShiftedJetsGPU(unittest.TestCase):
obj=circle((16.,12.),2.15);j=drive((16.,12.),2.15)
for rho in (.99,1.01):
with self.subTest(rho=rho),CudaContext(0) as ctx,ExitStack() as owned:
settings=dict(context=ctx,refinements=[(8,6,24,18)],level_blocks=[(4,4)]*2,
level_objects=[(),(obj,)],level_jets=[(),(j,)])
settings=dict(bounds=(8,6,24,18),context=ctx,base_block=(4,4),fine_block=(4,4),fine_objects=(obj,),jets=(j,))
measured=owned.enter_context(FixedPatchStepper(cfg,observe_raw_exchange=True,**settings))
plain=owned.enter_context(FixedPatchStepper(cfg,**settings))
measured._initialize_raw(initial(measured,rho));plain._initialize_raw(initial(plain,rho))
@@ -187,9 +186,8 @@ class ShiftedJetsGPU(unittest.TestCase):
parents=((4,4,20,28),(24,4,44,28));a=((16,16,32,40),(56,16,72,40));b=(a[0],(60,16,76,40))
obj=circle((12.,14.),.8);j=drive((12.,14.),.8)
with CudaContext(0) as ctx,ExitStack() as owned:
old=owned.enter_context(FixedPatchStepper(cfg,context=ctx,refinements=[parents,a],
level_blocks=[(4,4)]*3,level_objects=[(),(),(obj,)],level_jets=[(),(),(j,)],
observe_raw_exchange=True))
old=owned.enter_context(FixedPatchStepper(cfg,parents,context=ctx,base_block=(4,4),fine_block=(4,4),
child_block=(4,4),child_rectangles_in_parent=a,child_objects=(obj,),child_jets=(j,),observe_raw_exchange=True))
old._initialize_raw(initial(old,.99));ledger=Ledger(self,old,2)
for speed in (.002,-.001):old.set_jet_amplitude(0,speed,level=2);ledger.step()
before=old._download_raw();count=old.step_count
@@ -22,8 +22,9 @@ B=((20,16,36,40),)
def make(ctx):
return FixedPatchStepper(CFG,context=ctx,refinements=[PARENTS,A],
level_blocks=[(4,4)]*3)
return FixedPatchStepper(CFG,PARENTS,context=ctx,base_block=(4,4),
fine_block=(4,4),child_block=(4,4),
child_rectangles_in_parent=A)
def initial(run):
+49 -4
View File
@@ -57,6 +57,8 @@ WINDOW_TSTAR = 10.0 # trailing 5 s, 21 samples
BANDS = dict(cd=(5.57, 5.59), cl=(0.0104, 0.0110), p_diff=(0.1172, 0.1176))
STEADY_RANGE = {k: (hi - lo) / 10 for k, (lo, hi) in BANDS.items()}
STEADY_SLOPE = {k: (hi - lo) / 50 for k, (lo, hi) in BANDS.items()}
# 2: {schema, provenance, cases}; the earlier receipts (schema 1) are a bare list of cases.
RESULT_SCHEMA = 2
DEVIATIONS = ("pressure-NEQ outlet (neq_extrap), not do-nothing", "local Zou-He inlet")
_re100_config = rr.config
@@ -106,6 +108,21 @@ def summarize(rows):
verdict=inside)
def read_result(path):
"""Cases and provenance from a result.json of either schema.
Schema 1 (receipts before RESULT_SCHEMA=2, e.g. evidence 70f6617) is a bare list of
cases without provenance; schema 2 is {schema, provenance, cases}. Readers that
indexed [0] on schema 1 must use read_result(path)["cases"][0].
"""
data = json.loads(Path(path).read_text())
if isinstance(data, list):
return dict(schema=1, provenance=None, cases=data)
if not isinstance(data, dict) or data.get("schema") != RESULT_SCHEMA:
raise ValueError(f"unknown DFG Re20 result schema: {data.get('schema') if isinstance(data, dict) else type(data).__name__}")
return dict(schema=RESULT_SCHEMA, provenance=data["provenance"], cases=data["cases"])
def healthy(row):
return row["max_mach"] <= 0.2 and row["rho_min"] >= 0.5 and row["rho_max"] <= 1.5
@@ -126,8 +143,8 @@ def run_case(a, h, case):
cfg, pair, body, host = rr.host_geometry(a.source, h, a.collision)
context = stack.enter_context(CudaContext(0))
backend = stack.enter_context(FixedPatchStepper(
cfg, context=context, coarse_boundary="channel", refinements=[rr.PLACEMENTS["wide"]],
level_blocks=[rr.BASE_BLOCK, rr.FINE_BLOCK], level_objects=[(), (body,)],
cfg, rr.PLACEMENTS["wide"], context=context, coarse_boundary="channel",
base_block=rr.BASE_BLOCK, fine_block=rr.FINE_BLOCK, fine_objects=(body,),
observe_raw_exchange=True, observe_window_exchange=True))
from CelerisLab.lbm.channel import channel_masks
from CelerisLab.lbm.refinement import ACTIVE
@@ -194,6 +211,27 @@ def plan(collision="SRT"):
label="compared against the official reference bands (non-canonical neq_extrap outlet, zou_he_local inlet); not a full benchmark certification")
def provenance(source):
"""Current checkout identity: HEAD, dirty flag, src tree and this runner's hash."""
import hashlib
import subprocess
from source_provenance import checkout_provenance
tree = subprocess.check_output(["git", "-C", str(source), "rev-parse", "HEAD:src"],
text=True).strip()
record = dict(source_mode="current_checkout", **checkout_provenance(source), src_tree=tree,
runner_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest())
if record["checkout_dirty"]: # HEAD would not describe the code that runs
raise RuntimeError("DFG Re20 runs require a clean checkout")
return record
def reject_output_inside(output, source):
"""Receipts inside the checkout would make it dirty and change the recorded source."""
output = Path(output).resolve()
if output == source or source in output.parents:
raise SystemExit(f"--output must be outside the source checkout {source}")
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--case", choices=["refined", "uniform", "both"], default="refined")
@@ -204,8 +242,10 @@ def main():
parser.add_argument("--max-wall-seconds", type=float)
a = parser.parse_args()
a.source = a.source.resolve()
reject_output_inside(a.output, a.source)
record = dict(plan(a.collision), schema=RESULT_SCHEMA, provenance=provenance(a.source))
a.output.mkdir(parents=True, exist_ok=False)
(a.output / "plan.json").write_text(json.dumps(plan(a.collision), indent=2, allow_nan=False))
(a.output / "plan.json").write_text(json.dumps(record, indent=2, allow_nan=False))
if a.plan:
return
if not a.max_wall_seconds or a.max_wall_seconds <= 0 or not os.environ.get("CUDA_VISIBLE_DEVICES"):
@@ -215,7 +255,12 @@ def main():
a.deadline = time.monotonic() + a.max_wall_seconds
cases = ["refined", "uniform"] if a.case == "both" else [a.case]
results = [run_case(a, h, case) for case in cases]
(a.output / "result.json").write_text(json.dumps(results, indent=2, allow_nan=False))
after = provenance(a.source)
if after != record["provenance"]:
raise RuntimeError("Source checkout changed during the run")
(a.output / "result.json").write_text(json.dumps(dict(schema=RESULT_SCHEMA, provenance=after,
cases=results),
indent=2, allow_nan=False))
if __name__ == "__main__":
+76 -15
View File
@@ -3,6 +3,7 @@
import argparse
import hashlib
import subprocess
import json
import os
from pathlib import Path
@@ -220,6 +221,60 @@ def verify(repo):
return manifest
def verify_historical(repo):
"""The pinned manifest describes the PIN tree (read with git, not the working tree)."""
from source_provenance import verify_manifest
manifest = json.loads((HERE / "data/jet_circle_cv/source_manifest.json").read_text())
if manifest["revision"] != PIN:
raise RuntimeError("Wrong pinned revision")
verify_manifest(repo, PIN, manifest["files"])
return manifest
def current_checkout(repo):
"""Provenance for a run on the current checkout; never labelled as the pinned source."""
files = subprocess.check_output(["git", "-C", str(repo), "ls-files", "src"],
text=True).splitlines()
return dict(source_mode="current_checkout",
source_capture="current checkout, not the frozen pinned qualification",
source_revision=checkout_provenance(repo)["checkout_revision"],
src_tree=subprocess.check_output(["git", "-C", str(repo), "rev-parse",
"HEAD:src"], text=True).strip(),
src_sha256={f: hashlib.sha256((repo / f).read_bytes()).hexdigest()
for f in files})
def source_identity(repo, current):
"""Recorded source of a run; recomputed after the run by finish_receipt."""
if current:
provenance = dict(**checkout_provenance(repo), **current_checkout(repo))
if provenance["checkout_dirty"]:
raise RuntimeError("Current-checkout runs require a clean checkout")
return provenance
source = verify(repo)
return dict(source_experiment=source["experiment"], source_mode="pinned",
**checkout_provenance(repo))
def finish_receipt(path, payload, provenance, recheck):
"""Mark the receipt COMPLETE only if the source is unchanged; otherwise INVALID.
Receipts written during the run carry run_status="INCOMPLETE", so an interrupted
run never leaves a complete-looking receipt.
"""
from source_provenance import final_source_check
fields = final_source_check(provenance, recheck)
unchanged = fields["source_recheck"] == "UNCHANGED"
payload = dict(payload, **fields,
run_status="COMPLETE" if unchanged else "INVALID_SOURCE_CHANGED")
temporary = Path(str(path) + ".tmp")
temporary.write_text(json.dumps(payload, indent=2) + "\n")
temporary.replace(path)
if not unchanged:
raise RuntimeError("Source changed during the run; receipt marked invalid")
return payload
def run_case(c, ctx, output, provenance, continuation=False):
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
@@ -279,11 +334,12 @@ def run_case(c, ctx, output, provenance, continuation=False):
PeriodicBlockStepper(fine_cfg, layout, context=ctx, objects=[fobj]) as uniform,
FixedPatchStepper(
cfg,
c["bounds"],
context=ctx,
refinements=[c["bounds"]],
level_blocks=[(8, 8), (8, 8)],
level_objects=[(), (obj,)],
level_jets=[(), tuple(jets)],
base_block=(8, 8),
fine_block=(8, 8),
fine_objects=[obj],
jets=jets,
observe_raw_exchange=True,
) as amr,
):
@@ -529,16 +585,16 @@ def run_case(c, ctx, output, provenance, continuation=False):
recorder.flush()
payload = dict(
case=c,
source_revision=PIN,
**provenance,
**{"source_revision": PIN, **provenance},
status="PILOT_DIAGNOSTIC_ONLY",
run_status="INCOMPLETE", # final source recheck in main() completes it
windows=rows,
physics_validation_pass=False,
precision="FP32 evolution, FP64 host diagnostics",
)
(output / f"re{c['re_d']}_s{c['scale']}.json").write_text(
json.dumps(payload, indent=2) + "\n"
)
receipt = output / f"re{c['re_d']}_s{c['scale']}.json"
Path(str(receipt) + ".tmp").write_text(json.dumps(payload, indent=2) + "\n")
Path(str(receipt) + ".tmp").replace(receipt)
print(
json.dumps(
dict(
@@ -570,6 +626,9 @@ def main():
help="Fixed measurement campaign: Re100, scale1/2, 3200 reference steps",
)
p.add_argument("--plan", action="store_true")
p.add_argument("--current-checkout", action="store_true",
help="Run on the current clean checkout and record its provenance "
"instead of requiring the frozen pinned source")
a = p.parse_args()
if a.steps <= 0 or a.steps > 3200:
p.error("steps must be 1..3200")
@@ -594,7 +653,8 @@ def main():
)
plan = dict(
case=c,
required_revision=PIN,
required_revision=None if a.current_checkout else PIN,
source_mode="current_checkout" if a.current_checkout else "pinned",
required_free_device_mib=1024,
estimated_peak_device_mib=128 * a.scale**2,
estimated_output_mib=64 if a.continuation else 2,
@@ -610,10 +670,9 @@ def main():
):
p.error("Root must select one verified idle GPU via CUDA_VISIBLE_DEVICES")
repo = a.repo.resolve()
source = verify(repo)
provenance = dict(
source_experiment=source["experiment"], **checkout_provenance(repo)
)
from source_provenance import reject_output_inside
reject_output_inside(a.output, repo)
provenance = source_identity(repo, a.current_checkout)
sys.path.insert(0, str(repo / "src"))
import CelerisLab
@@ -629,7 +688,9 @@ def main():
free, total = cuda.mem_get_info()
if free < max(1024, 2 * plan["estimated_peak_device_mib"]) * 1024**2:
raise RuntimeError("Insufficient free device memory")
run_case(c, ctx, a.output, provenance, continuation=a.continuation)
payload = run_case(c, ctx, a.output, provenance, continuation=a.continuation)
finish_receipt(a.output / f"re{c['re_d']}_s{c['scale']}.json", payload, provenance,
lambda: source_identity(repo, a.current_checkout))
print(
json.dumps(
dict(status="COMPLETE_DIAGNOSTIC_ONLY", physics_validation_pass=False)
+11 -17
View File
@@ -281,21 +281,16 @@ def host_geometry(source, h, collision="SRT", jet_scenario="none", placement="wi
return cfg, pair, body, host
def source_manifest(collision="SRT", observation_mode="last_base_step"):
"""The frozen historical manifest for this collision/observation mode (no checkout read)."""
def verify_source(source, collision="SRT", observation_mode="last_base_step", jet_scenario="none", observe_cv=False):
if collision not in ("SRT", "MRT"):
raise ValueError("Matched collision must be SRT or MRT")
if observation_mode not in ("last_base_step", "window"):
raise ValueError("Unknown refined observation mode")
name = ("source_manifest_window.json" if observation_mode == "window" else
"source_manifest.json" if collision == "SRT" else "source_manifest_mrt.json")
return json.loads((HERE / "data/refined_dfg" / name).read_text())
def verify_source(source, collision="SRT", observation_mode="last_base_step", jet_scenario="none", observe_cv=False):
expected = source_manifest(collision, observation_mode)
if observe_cv and jet_scenario == "none":
raise ValueError("--observe-cv requires an explicit public Jet scenario")
name = ("source_manifest_window.json" if observation_mode == "window" else
"source_manifest.json" if collision == "SRT" else "source_manifest_mrt.json")
expected = json.loads((HERE / "data/refined_dfg" / name).read_text())
if jet_scenario != "none":
if collision != "MRT" or observation_mode != "window":
raise ValueError("Public Jet scenarios require MRT and --exchange-window")
@@ -303,8 +298,7 @@ def verify_source(source, collision="SRT", observation_mode="last_base_step", je
sys.path.insert(0, str(HERE))
from source_provenance import checkout_provenance
# Harness files from the manifest; production files as this checkout lists them.
paths = {name for name in expected["sha256"] if not name.startswith("src/")}
paths = set(expected["sha256"])
if observe_cv:
paths.add("tests/validation/run_jet_circle_cv.py")
paths.update(subprocess.check_output(
@@ -316,8 +310,7 @@ def verify_source(source, collision="SRT", observation_mode="last_base_step", je
for name in sorted(paths)},
)
for name, digest in expected["sha256"].items():
path = source / name
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != digest:
if hashlib.sha256((source / name).read_bytes()).hexdigest() != digest:
raise RuntimeError("Source mismatch: " + name)
return expected
@@ -390,12 +383,13 @@ def run_case(a, h, periodic, case):
backend = stack.enter_context(
FixedPatchStepper(
cfg,
PLACEMENTS[a.placement],
context=context,
coarse_boundary="channel",
refinements=[PLACEMENTS[a.placement]],
level_blocks=[BASE_BLOCK, FINE_BLOCK],
level_objects=[(), (body,)],
level_jets=[(), public_jets((80.5, 81.0), 20.0, a.jet_scenario)],
base_block=BASE_BLOCK,
fine_block=FINE_BLOCK,
fine_objects=(body,),
jets=public_jets((80.5, 81.0), 20.0, a.jet_scenario),
observe_raw_exchange=True,
**({"observe_window_exchange": True} if a.observation_mode == "window" else {}),
)
+96
View File
@@ -13,3 +13,99 @@ def checkout_provenance(repo):
text=True,
)
return dict(checkout_revision=revision, checkout_dirty=bool(dirty))
def final_source_check(recorded, recheck):
"""Re-derive the source identity after a run and compare it with the recorded one.
Returns receipt fields: source_recheck "UNCHANGED"; "CHANGED" with source_after; or
"RECHECK_FAILED" (source_after None) with the error. Callers mark a receipt complete
only for "UNCHANGED"; anything else invalidates the run.
"""
try:
after = recheck()
except Exception as error: # a failed recheck cannot confirm the source: invalid too
return dict(source_recheck="RECHECK_FAILED", source_after=None,
source_recheck_error=f"{type(error).__name__}: {error}")
if after == recorded:
return dict(source_recheck="UNCHANGED")
return dict(source_recheck="CHANGED", source_after=after, source_recheck_error=None)
def reject_output_inside(output, repo):
"""Refuse run outputs inside the checkout before any work: they would dirty it."""
from pathlib import Path
output, repo = Path(output).resolve(), Path(repo).resolve()
if output == repo or repo in output.parents:
raise SystemExit(f"--output must be outside the source checkout {repo}")
class HistoryUnavailable(RuntimeError):
"""The clone cannot show the recorded commit (no git, shallow clone, installed copy)."""
def historical_sha256(repo, commit, paths):
"""SHA-256 of each path as stored in `commit`, read with git, never from the working tree.
Raises HistoryUnavailable when git or the commit object is missing, so callers can
report an unchecked integrity record explicitly instead of passing or failing silently.
"""
import hashlib
git = ["git", "-C", str(repo)]
try:
subprocess.run([*git, "cat-file", "-e", f"{commit}^{{commit}}"], check=True,
capture_output=True)
except (OSError, subprocess.CalledProcessError) as error:
raise HistoryUnavailable(f"commit {commit} is not available in {repo}") from error
digests = {}
for path in paths:
listed = subprocess.run([*git, "ls-tree", "--name-only", commit, "--", path],
capture_output=True, text=True)
if listed.returncode:
raise HistoryUnavailable(f"tree of {commit} is not available in {repo}")
if listed.stdout.strip() != path:
raise ValueError(f"{path} is not part of {commit}")
shown = subprocess.run([*git, "show", f"{commit}:{path}"], capture_output=True)
if shown.returncode: # e.g. a blobless partial clone without network access
raise HistoryUnavailable(f"content of {path} at {commit} is not available in {repo}")
digests[path] = hashlib.sha256(shown.stdout).hexdigest()
return digests
def verify_manifest(repo, commit, digests):
"""Raise RuntimeError naming the first path whose recorded digest differs at `commit`."""
actual = historical_sha256(repo, commit, digests)
for path, digest in digests.items():
if actual[path] != digest:
raise RuntimeError(f"Historical manifest mismatch at {commit}: {path}")
return actual
def historical_tree(repo, commit, prefix="src"):
"""Tracked file names under `prefix` at `commit` (HistoryUnavailable if missing)."""
historical_sha256(repo, commit, [])
return subprocess.check_output(["git", "-C", str(repo), "ls-tree", "-r", "--name-only",
commit, "--", prefix], text=True).splitlines()
def historical_files(repo, commit, paths, dest):
"""Write each path as stored in `commit` under `dest` (HistoryUnavailable if missing)."""
from pathlib import Path
historical_sha256(repo, commit, paths) # HistoryUnavailable / ValueError before writing
for path in paths:
shown = subprocess.run(["git", "-C", str(repo), "show", f"{commit}:{path}"],
capture_output=True)
if shown.returncode:
raise HistoryUnavailable(f"cannot read {commit}:{path} (blob not in this clone)")
target = Path(dest) / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(shown.stdout)
def history_unavailable(testcase, error):
"""Skip with the reason, or fail when CELERIS_REQUIRE_HISTORY=1 (release gating)."""
import os
message = f"historical integrity NOT checked: {error}"
if os.environ.get("CELERIS_REQUIRE_HISTORY") == "1":
raise AssertionError(message)
testcase.skipTest(message)

Some files were not shown because too many files have changed in this diff Show More