Compare commits

..
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 ae53fade13 Contract: name the in-EDGE half-cell projection; repair DFG runner current-checkout Jet path
- No NODE<->EDGE input conversion still leaves the root-edge -> fine-local
  half-cell projection (prepare_fine_boundary, fine_hits_to_edge_frame); say so.
- run_refined_dfg.py (jet_scenario != none, current checkout only): pass
  level=1 to FixedPatchStepper.set_jet_amplitude on reversal; use
  blocked_coarse_tables there. jet_scenario=none keeps the pinned source's
  blocked_base_tables; manifests and results unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 02:45:30 +00:00
ClaudeandClaude Opus 5.5 358bed1962 Naming/contract delta: _root_cfg, _device_boundaries, blocked_coarse_tables; NODE vs EDGE frame
- Coarse/fine name the two members of any pair (Hierarchy docstring); only the
  misleading blocked_base_tables -> blocked_coarse_tables. prepare_fine_boundary
  and blocked_fine_tables keep their pair-relative names.
- Depth-1 static circles use the integer-NODE frame (unchanged behavior); the
  root EDGE frame applies to refined levels only. Contract text corrected.
- _kernel_configs documents why it stays distinct from Hierarchy.configs.
- Drop the stale re-export comment; two tests import table builders from
  refinement_tables. Mechanical caller renames for this delta only.
No numerical code or config derivation changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 02:34:07 +00:00
ClaudeandClaude Opus 5.5 b4eacf7526 FixedPatchStepper: level-indexed API, drop 15 fine/child compatibility views
- Hierarchy documents level/pair/shape/frame conventions; edge_frame(l)
  replaces the stepper's inline origin/spacing loop.
- Public per-level read-only state: layouts, boundaries, jets, leaf.
- set_jet_amplitude/read_raw_exchange/read_exchange_window: level= required.
- remap_child(child_objects=) -> remap_finest(objects=).
- _kernel_configs, _upload_boundary, _observed_boundary/_summarize split
  construction and observation duplication; behavior unchanged.
- fine_level_origin -> fine_level_offset (a (y, x) index, not an origin).
- docs: migration map in docs/development/src_coherence.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:39:48 +00:00
ClaudeandClaude Opus 5.5 0c64f821a4 Blocked steps: one block_collide extension point; drop unused HaloPlan, collide_trt_no_force, MIN_PATCH_GAP
SRT keeps its two explicit arithmetic variants (level: unclamped no-force,
shifted direct-momentum feq; channel: uniform OneStep arithmetic), selected
at compile time by BLOCK_ROOT_BC; text moved verbatim, PTX parity pending.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:23:56 +00:00
Frank14f 11cbf1194f test: bind Simulation usability guard on host doubles 2026-10-28 08:11:14 +08:00
Frank14f a9aa12e65d refactor: remove redundant periodic patch stepper alias 2026-10-28 08:03:29 +08:00
ClaudeandClaude Opus 5.5 7afb5addde lifecycle: Simulation jet update failure fails the instance like FixedPatchStepper
Simulation.set_jet_amplitude validated before upload and committed host
state after it, but after an uncertain device write nothing stopped reuse.
Record the failure in Simulation._failed and refuse every public device
call except close() (FixedPatchStepper already uses status 'failed').
Invalid input stays side-effect free and retryable. Adds host fault tests
for both owners and states the rule in docs/lifecycle.md.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:12:20 +00:00
ClaudeandClaude Opus 5.5 e18f7abe2b refinement: remove the dense refinement route; level_blocks is required (D2)
Deleted: kernels/refinement.cu and step/patch_step.cu (PatchExplode,
PatchFineStep, PatchCoarseStep, the CELERIS_REFINEMENT_AUDIT counters);
in FixedPatchStepper the dense branches of _build_level/_build_pair/_hop,
_apply_fine_boundary, the fine-level CurvedLinkSoA wiring (_fine_curved,
_fine_flags, _curved, _link_tables, its set_jet_amplitude/initialize/close
paths) and the now-vacuous 'requires all-blocked' guards (shifted,
projected, channel, raw exchange, several L1 rectangles, depth >= 3).

level_blocks is required: one tile (by, bx) of positive integers per level,
else ValueError before any device work naming the whole-level tile
((ny, nx) at the root, the fine window shape for one rectangle) as always
valid. No automatic tile choice. Removed route: None in level_blocks
(dense or mixed dense/blocked storage); parity of D/D, D/B and B/B is
recorded at 1573942 and 2469af7.

Kept: the uniform-grid solver (uniform.cu, Simulation, CurvedLinkSoA,
CurvedBoundaryKernel), FixedPatch2D, pinned historical runners.
Docs: compiler entries, BlockExplode comment, lifecycle, supported_settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:09:13 +00:00
ClaudeandClaude Opus 5.5 104f74632f test(D1): exchange, Jet and fine-surface checks run on all-blocked storage
- test_exchange_window and test_raw_exchange_gpu fine(): dense root becomes
  a (12, 8) blocked root (fine(None) stays only for the dense reference test
  that D2 retires);
- jets: G1/G3 compare blocked layouts against the FINE_BLOCKS[0] reference
  (blocked root); the dense run is a separate leg D2 removes; G2 legacy
  actuation vs Jet on the blocked reference; CPU fixtures use a blocked root;
- test_refinement_blocked_fine.raw reads every level through its layout;
- test_fine_surface_gpu: new test_fine_surface_blocked_matches_uniform_four_hops
  (fine ROI after each of four hops vs the uniform 48^2 solver with the same
  tolerances, blowing/suction mass sign from raw exchange, zero actuation ==
  passive bit for bit); the cross-interface donor rejection passes tiles.
  The dense hook/ledger/audit probes in test_fine_surface_four_hops and the
  dense SoA fault tests stay until D2 retires them by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:43:12 +00:00
ClaudeandClaude Opus 5.5 452eaf32de test(D1): refinement GPU physics also runs on whole-level blocked tiles
- actual-TG-rate case: the unshifted leg uses [(16, 16), (24, 24)];
- partial-advance: blocked storage, fine owner storage read through the layout;
- new test_bounded_equivalence_blocked: the same eight-step scalar-reference
  checks (rest + seeded nonequilibrium, synchronization, leaf drift/moments,
  hop-2 validity mask, coalesce sources, 8 corner packets) and launch-failure
  publication guard on blocked tiles, via the shared eight_steps helper.
The dense audit build (CELERIS_REFINEMENT_AUDIT counters) stays in
test_bounded_equivalence_and_ownership until D2 retires it with the dense
kernels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:45:12 +00:00
ClaudeandClaude Opus 5.5 3716b16555 test(D1): read build metadata while the private build directory exists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:44:13 +00:00
ClaudeandClaude Opus 5.5 ce393fa468 test(D1): refinement followup checks run on all-blocked storage
Every FixedPatchStepper in test_refinement_followup passes explicit tiles
(whole-level tiles on the GPU cases, (1, 1) in the pre-CUDA margin
contract). Assertions kept; dense-specific probes rewritten:
- fault fakes use a blocked BlockLayout2D instead of _layouts [None, None];
- slot/reservoir: the fine module sees its tile window incl. halo
  (fx+2, fy+2) with slot fields zeroed; the root's walls are
  block_root_bc 1 (fine 0) instead of dense Y_WALL_BC/TOP_WALL_BC macros;
- raw owner storage is read through each level's layout (to_dense);
- the off-centre allocation bound counts blocked buffers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:44:05 +00:00
ClaudeWorkerandClaude Opus 5.5 2ccd7d576e Merge claude/dense-parity-2: MRT and two-body dense/blocked parity rows
Test-only residual parity gate (2469af7), Astra GPU green, Grok clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:38:39 +00:00
ClaudeandClaude Opus 5.5 2469af70d0 test: dense/blocked parity rows for MRT bodies and two fine bodies
Extends DenseBlockedRouteParity (test-only, on 1573942) with the residual
dense-supported combinations named before any dense-route deletion:
- test_mrt_bodies: MRT on a periodic root with a passive circle, an
  actuated circle (+/-) and a velocity Jet with an amplitude switch
  (MRT slot/reservoir is rejected on every route: on_site_velocity is
  SRT-only);
- test_two_bodies: two circles on a wider patch (4, 4, 28, 20), periodic
  and slot/reservoir, Jet on one + passive other, and two actuated (+/-);
  routes D/D, D/B [None, (8, 8)], B/B [(12, 8), (8, 8)].
Same gate: exact FP32 bits and NaN masks at 0, 1, 2, 8 and after
re-initialize; D/B vs B/B raw-exchange ledgers equal for every body.
Docstring table updated; no tolerance, no src change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:28:11 +00:00
ClaudeandClaude Opus 5.5 850b66862f examples: fixed_patch_circle reports its blocked tiles (fix unbound fine_block)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:25:22 +00:00
ClaudeandClaude Opus 5.5 fd8f1da9f5 examples: fixed-patch circle and jet run on all-blocked storage (M1)
Both public examples move off the dense refinement route, inside the scope
proven by the dense/blocked parity gate (1573942: two-level unshifted SRT,
periodic or slot/reservoir root, one circle/actuation or body-free):
- fixed_patch_jet: LEVEL_BLOCKS = [(12, 8), (8, 8)] (48x48 root, 56x40
  fine window), recorded in its report config;
- fixed_patch_circle: --base-block (default 12 8) and --fine-block
  (default 16 20); the dense 'omit --fine-block' option is gone.
Host test test_example_tiles: each example's tiles (read from the example
source without importing it) build every layout and table up to the first
CUDA call, for the circle in both coarse boundary modes.
No src change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:23:23 +00:00
ClaudeandClaude Opus 5.5 1573942673 test: ledger comparator compares labels exactly and numbers with NaN-equality
same_summary applied np.array_equal(equal_nan=True) to string metadata
('last_base_step'), a TypeError on the first D/B vs B/B ledger compare.
Numbers (bool/int/float/complex) still compare exactly with equal shapes
and NaN-equality; other values (window labels) compare by dtype and value.
New host test: a summary with string metadata equals itself, and changing
the window label, an integer, a nested float, a scalar or a NaN-bearing
array is detected in both directions. No assertion removed, no tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:15:10 +00:00
ClaudeandClaude Opus 5.5 8db7cee9d5 test: dense/blocked route parity gate and route capability matrix
Test-only, on c6d5dc1. DenseBlockedRouteParity runs the same configuration,
initial state, body/Jet schedule and tilings on D/D [None, None], D/B
[None, FINE_BLOCK] and B/B [(12, 8), FINE_BLOCK], periodic and
slot/reservoir, for body-free, passive circle, actuated circle (+/-) and a
velocity Jet with an amplitude switch: every public and owner array must
match in FP32 bits and NaN masks at steps 0, 1, 2, 8 and after
re-initialize, and the D/B and B/B raw-exchange ledgers (last base step and
last substep) must be equal. No tolerance.

test_route_capability_matrix checks before CUDA what the dense route
rejects (raw exchange on a dense body level, shifted, projected, channel,
several L1 rectangles, depth >= 3 dense). The module docstring holds the
supported/unsupported table and the parked cases (MRT covered elsewhere,
shifted and depth >= 3 have no dense route, several bodies later).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:06:38 +00:00
ClaudeandClaude Opus 5.5 c6d5dc1cbc test: raw-exchange uniform reference passes its raw buffer at launch
8e1feff set root.last_raw on the single-level reference, which also makes
FixedPatchStepper._hop accumulate a ledger the reference never allocated.
The adapter now substitutes the raw output pointer in the BlockCurvedBoundary
launch (argument 10, as the deleted adapter did) and leaves last_raw unset;
Jet column and force arms still come through the root level boundary.
Comparison unchanged; no production change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:18:41 +00:00
ClaudeandClaude Opus 5.5 f520af8935 refinement: document that the free-memory preflight is a lower bound only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:10:56 +00:00
ClaudeandClaude Opus 5.5 19db7d362f refinement: free-memory preflight for every level before any device work
Restores the PeriodicBlockStepper safeguard dropped in 8e1feff, for all
FixedPatchStepper levels: both G buffers, halo pairs and boundary columns
of every level are compared with cuda.mem_get_info() before the stream,
any module or any buffer exists, raising MemoryError('level buffers exceed
free device memory'). Transfer tables are not counted (lower bound).

Tests: the single-level MemoryError case is back beside the param-layout
check (no stream, no allocation); host test_single_level covers depth 1
and depth 2 (no stream, build or allocation) and the wait failure after a
good step (count stays 1). Driver-mocking fixtures report ample free memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:10:15 +00:00
ClaudeandClaude Opus 5.5 8c776309c9 docs: cite the S2 commit for the PeriodicBlockStepper removal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:07:07 +00:00
ClaudeandClaude Opus 5.5 8e1feffcad lbm: delete PeriodicBlockStepper; a single blocked level is FixedPatchStepper(refinements=[]) (S2)
The second owner of one blocked level goes. Its capability is the
zero-refinement FixedPatchStepper added in f764d68/985db14 (periodic SRT
level, stationary passive circles, shifted storage, NaN spare, single wait),
which matched it array for array on GPU at 1318fc2 (3 splits x +-circle x
shifted/unshifted) and passes the host lifecycle and fault contracts.

API change for callers:
  PeriodicBlockStepper(cfg, BlockLayout2D.build(ny, nx, by, bx), context=c, objects=o)
  -> FixedPatchStepper(cfg, context=c, refinements=[], level_blocks=[(by, bx)],
                       level_objects=[tuple(o)])
  download() returns a 1-tuple; static-circle tables are
  _level_boundaries[0].tables (was .boundary); no free-memory preflight.

- lbm/block_stepper.py deleted; MAX_GRID_DIM lives in refinement_stepper.
- tests/validation/multigrid/test_block_stepper.py: same frozen GPU gates
  (split identity, FP64 oracle, circle across seams) and pre-CUDA rejection
  cases on the single level; the old-vs-new bitwise test is retired with the
  old class (its result is the 1318fc2 record).
- tests/unit/test_block_step_sync.py folded into test_single_level.py
  (step(n) vs n x step(1) roles and one wait, step(0), mid-batch launch
  failure), which also gains bad-state rejection before upload, download
  failure and activation-failure retry on close.
- test_shifted_storage and test_raw_exchange_gpu use the single level; the
  raw-exchange reference feeds the AMR Jet column and raw ledger through the
  root level boundary instead of patching a private kernel field.
- Pinned historical runners (taylor_green/run_case.py, run_jet_circle_cv.py)
  are unchanged: they only run on trees that still ship PeriodicBlockStepper.
  No alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:02:08 +00:00
ClaudeandClaude Opus 5.5 d58f06f0fa test: single-level upload, launch and final-wait failures fail the instance
Fault injection with the existing host fakes for FixedPatchStepper(
refinements=[]): an initialize upload failure, a BlockHaloCopy,
BlockCurvedBoundary or BlockStep launch failure, and a failing final
stream wait each raise, leave the instance failed (step/download refused)
and keep the completed step count; a failed launch never reaches the wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:55:30 +00:00
ClaudeandClaude Opus 5.5 1318fc205c test: single-level rejections include LES and window-exchange observation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:37:40 +00:00
ClaudeandClaude Opus 5.5 985db14249 refinement: single level matches PeriodicBlockStepper lifecycle; host contract tests
Review follow-ups for S1 (f764d68):
- a single level starts its spare buffer NaN, as PeriodicBlockStepper does,
  so stale halos cannot hide (multilevel initialization unchanged);
- projected explosion is rejected for a single level;
- new tests/unit/test_single_level.py: NaN spare after initialize, per-step
  launch order halo -> BFL (circles only) -> BlockStep with a null kind and
  one wait per step(n), circle solids excluded from the state mask;
- the GPU bitwise comparison also covers shifted storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:36:05 +00:00
ClaudeandClaude Opus 5.5 f764d68c81 refinement: FixedPatchStepper(refinements=[]) owns a single blocked level (S1)
A zero-refinement FixedPatchStepper is the PeriodicBlockStepper capability:
one doubly periodic blocked SRT level (no LES, Jets or exchange
observation) whose level_objects[0] are stationary passive circles. The
circle tables come from the same checks, now blocks.static_circle_tables
(moved from block_stepper, pycuda-free); BFL, halo and BlockStep launches
are the shared helpers, with an all-ACTIVE (null) kind. Base-level bodies
stay rejected for depth >= 2. PeriodicBlockStepper and its callers are
unchanged (deleted only after S1's GPU result).

Tests: host contract rejections before CUDA for the single level; GPU
bitwise comparison against PeriodicBlockStepper for every split, with and
without the circle (the S2 gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:34:52 +00:00
ClaudeandClaude Opus 5.5 a0700ab370 validation: select the frozen DFG manifest without reading the checkout
36ad523 made a manifest file missing from the checkout a 'Source mismatch',
which also broke the tests' manifest-selection helper: it reused
verify_source with hashing neutralised but still read the (renamed) files.
run_refined_dfg.source_manifest(collision, observation_mode) now owns the
frozen-manifest selection; verify_source uses it, then verifies the
checkout. The test helper calls source_manifest directly; source identity
stays owned by test_source_pin. Same errors, manifests and pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:20:21 +00:00
ClaudeandClaude Opus 5.5 36ad523662 validation: tip tooling no longer reads renamed source paths
Fixes the regressions from the entry/compiler renames (2b3c2a7, d318911)
that Astra reproduced at d318911:
- run_refined_dfg.verify_source: a manifest file missing from the checkout
  is a 'Source mismatch' (was FileNotFoundError); the current-checkout
  (Jet scenario) capture hashes the manifest's harness files plus the
  checkout's own 'git ls-files src', not historical src paths.
- time_window_dfg: candidate file list names kernels/blocked.cu.
- test_projected_pairs: the a135252-only harness's runtime_source is the
  historical receipt value (historical_runtime_source) in the receipt
  fixture and the two mocked run_case.main tests; runtime_source itself
  stays tested against the a135252 package (historical_package).
Harness files, pins, manifests and thresholds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:19:09 +00:00
ClaudeandClaude Opus 5.5 d318911221 cuda: compiler_v2 module becomes compiler
The package already exposed it as CelerisLab.cuda.compiler; the module now
has that name and every tip caller, doc and current-tree test imports it
directly (no alias).

Historical-revision tooling is not edited: the Taylor-Green harness
(run_case.py, hash-pinned check_projected_pairs.py, assess receipts) only
runs against a135252, whose package has cuda/compiler_v2.py, and the J
campaign runners and dated docs describe past trees. Its two current-tree
tests now import that package from git (historical_package) instead of the
tip, and the kernel-root test first checks the accepted case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:15:50 +00:00
ClaudeandClaude Opus 5.5 18e8ef45f0 blocks: one definition of a blocked level's config and kernel launches (C1a)
PeriodicBlockStepper and FixedPatchStepper each built the blocked level's
kernel config (block window NX/NY, neutralized wall/slot/inlet fields,
omega/tau check) and each spelled out the raw BlockHaloCopy and
BlockCurvedBoundary argument lists. These now live once in lbm/blocks.py
(level_config, launch_halo_copy, launch_curved_boundary), so a kernel ABI
change has one host site. Both owners keep their own lifecycle; no new
class, no behaviour or default change. PeriodicBlockStepper now checks its
level config before any device work (same errors). Dense refinement levels
keep their own config check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:12:11 +00:00
Frank14f 21fe71d332 test: target build_module in remap construction fault injection 2026-10-26 22:36:15 +08:00
ClaudeandClaude Opus 5.5 2b3c2a7529 kernels: name compilation entries by storage route (uniform.cu, blocked.cu)
kernel_v2.cu -> uniform.cu (whole-domain NX/NY, Simulation) and
blocks.cu -> blocked.cu (one block window, every blocked level), with
InitTubeFlow_v2 -> InitTubeFlow. All live callers, include comments,
CONFIG.md, lifecycle doc and current-tree tests move together; no alias.
refinement.cu keeps its name until the dense-route decision.
Historical-revision tooling that names the old files (LES source pins,
timing/J campaign runners, TG run_case, supported_settings citations at
888c8038, development notes) is unchanged on purpose.
No arithmetic change; the PTX symbol InitTubeFlow_v2 is renamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:07:34 +00:00
ClaudeandClaude Opus 5.5 dbcdb87081 compiler: remove the implicit last-build state (B2)
generate_config/compile_kernel/load_module also recorded the last build
directory and PTX in ContextVars so that bare compile_kernel() and
load_module() calls worked: a second, implicit interface beside the
explicit build_dir/ptx_path one used by every owner. compile_kernel now
requires build_dir= and load_module requires the PTX path.

Build contract tests use explicit paths; they still check per-build
header/output isolation, interleaved generation, path escapes, refusal of a
removed snapshot, and that a failed compile keeps the previous PTX and
leaves no partial output. No kernel, numerical or launch change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:05:58 +00:00
ClaudeandClaude Opus 5.5 0d62c3cfb8 compiler: one build_module/set_params path for all three module owners
Simulation, PeriodicBlockStepper and FixedPatchStepper levels each repeated
generate_config -> compile_kernel -> load_module -> cleanup and the
d_params pack/upload. They now call compiler.build_module(cfg, entry,
arch=..., n_objects=..., block_root_bc=...) and compiler.set_params.
Simulation no longer keeps its build directory alive until close (nothing
read it after loading). Removes the unused compile_kernel_v2 alias.
Host fakes and lifecycle tests follow the new seam; the close-retry case
for build-directory cleanup goes with the removed cleanup step.
No kernel, numerical or launch change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:59:39 +00:00
ClaudeandClaude Opus 5.5 96c5decf69 docs: draft evidence index for release cleanup (R0)
Per-file class (keep conclusion / tested research code / patch snapshot /
generated pin / raw run output) with size and blob id for research/,
tests/validation/multigrid/*_evidence, output/ and BASELINE_JET_AMR.txt
at 0f8fdbc3. Draft for review; nothing removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:53:30 +00:00
ClaudeandClaude Opus 5.5 e4049a6183 docs(lifecycle): separate compile-time macros from runtime module globals
d_params and the block wall/channel constants are per-module runtime
globals, immutable per stepper instance but not visible to the compiler.
Note that dense and blocked slot roots carry the wall slot as macros vs
d_block_wall respectively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:46:19 +00:00
ClaudeandClaude Opus 5.5 733e4d1fe7 docs: device state lifecycle by invalidation class
Describes current module/global, table, control and per-step state
ownership for Simulation, PeriodicBlockStepper and FixedPatchStepper,
and the window vs level-wide frame of single vs multi-rectangle levels.
No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:43:22 +00:00
ClaudeandClaude Opus 5.5 0f8fdbc30e refinement_stepper: compatibility views are kept on purpose (comment only)
The views and the PeriodicPatchStepper alias stay for the existing tests
and docs (about 130 references); the comment no longer says REMOVE AFTER.
No code change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:26:24 +00:00
ClaudeandClaude Opus 5.5 e4538219b2 docs: supported settings cite guards by file and symbol at 888c8038
The file:line citations pointed at c95a7ba and went stale after P1, the
compile-time root modes and the kernel entry/step split. Each guard is now
named by file plus function (or the kernel #error guard) at source
revision 888c8038. No claim, scope, observation or evidence revision
changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:24:37 +00:00
ClaudeandClaude Opus 5.5 888c80381c kernels (K10): refinement.cu becomes an include-only entry; patch kernels under step/
refinement.cu keeps its header, includes, the curved_double.cu entry
block, the #error guard and the optional CELERIS_REFINEMENT_AUDIT
counters, then includes step/patch_step.cu, which holds patch_collide,
PatchExplode, PatchFineStep and PatchCoarseStep moved verbatim (checked
against the previous refinement.cu) in the same order.
test_refinement_mrt reads refinement.cu + step/patch_step.cu; its
assertions are unchanged. The audit-build tests include refinement.cu
from the build root, so step/patch_step.cu still resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:03:28 +00:00
ClaudeandClaude Opus 5.5 bf5088f84c kernels (K9): blocks.cu becomes an include-only entry; kernels under step/
blocks.cu keeps its header, includes and #error guards and then includes
the kernels, as kernel_v2.cu does:
- step/block_transfer.cu: BlockHaloCopy, BlockExplode,
  BlockExplodeProjected, BlockCoalesce
- step/block_boundary.cu: BlockCurvedBoundary, BlockAccumulateExchange
- step/block_step.cu: BlockStep (rejects BLOCK_ROOT_BC == 2)
- step/block_channel.cu: unchanged (mode 2 only)
Kernel text moved verbatim (checked against the previous blocks.cu) and in
the same definition order, so the translation unit is the same sequence of
kernels. The two source-reading tests follow the move:
test_projected_explosion reads step/block_transfer.cu, test_refinement_mrt
reads blocks.cu + step/block_step.cu; their assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:46:49 +00:00
ClaudeandClaude Opus 5.5 af406b44bf Merge K4 helper withdrawal into K5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:32:42 +00:00
ClaudeandClaude Opus 5.5 78c0f1811b kernels (K4): withdraw the address helpers; keep only fill_invalid
Horsea's gates showed block_ddf/block_slot change real PTX in
BlockHaloCopy, BlockExplode and BlockExplodeProjected (6/10 modules;
BlockExplodeProjected 38 -> 40 registers) although the results stayed
exact. The stack's bar is byte-identical PTX for naming-only steps, so
those three kernels return to their K3 expressions, the helpers leave
core/layout.cuh, and test_projected_explosion goes back to its K3 form.
fill_invalid stays (its kernels were byte-identical). K4 vs K3 is now
only the NaN-fill helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:32:40 +00:00
ClaudeandClaude Opus 5.5 0ddc6c3d62 Merge K4 test harness fix into K5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:35:09 +00:00
ClaudeandClaude Opus 5.5 43e226c59a test_projected_explosion: give the host harness the real block address helpers
K4 made BlockExplodeProjected address storage through block_ddf and
block_slot (core/layout.cuh). This host test compiles the kernel text
extracted from blocks.cu, so it now also extracts those two helper
definitions from core/layout.cuh (device qualifiers -> inline) instead of
failing to compile. Assertions and tolerances unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:35:07 +00:00
ClaudeandClaude Opus 5.5 93e692a40f kernels (K5): reuse the existing bounce_back_swap instead of a new copy
operators/helpers.cuh already had bounce_back_swap (the uniform steps use
it). It moves verbatim, #pragma unroll included, into its own
operators/bounce_back_swap.cuh, which helpers.cuh includes; the channel
root includes that header and calls it. bounce_back_swap_d2q9 from the
previous commit is removed. bounce_back.cuh keeps only the half-way rows
helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:32:07 +00:00
ClaudeandClaude Opus 5.5 33837697da kernels (K5): channel root reuses boundary and streaming helpers
- boundary/bounce_back.cuh: apply_wall_bb_d2q9_rows(y, top_row, ...) is
  the existing half-way y-wall correction with an explicit top row;
  apply_wall_bb_d2q9_y_pull keeps its signature and calls it with NY - 2.
  bounce_back_swap_d2q9(f) names the full opposite-pair swap.
- streaming/pull_double_buffer.cuh: load_node(fi, n, f) loads one node's
  stored populations.
- step/block_channel.cu uses all three instead of its inline copies: the
  wall swap, the outlet donor gather, and rows 1 / ny-2 (now one call with
  the physical top row; same y == 1 / y == top_row order and loads).
Same statements, branch order, indices and arithmetic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:31:26 +00:00
ClaudeandClaude Opus 5.5 b47af12c9d kernels (K4): blocked storage address helpers and one invalid-cell fill
- core/layout.cuh: block_ddf(f, block_cell[, cells]) returns the start of a
  block's populations and block_slot(block_cell, cells, q) the offset of one
  population; BlockHaloCopy, BlockExplode and BlockExplodeProjected use them
  instead of repeating (c / cells) * NQ * cells [+ q * cells + c % cells].
  BlockExplode keeps its loop-hoisted parent base; types stay uint64_t /
  unsigned long; the expressions are the same ones, now named.
- core/cell_kind.cuh: fill_invalid(f) writes the NaN marker; BlockStep,
  BlockChannelStep and PatchFineStep call it instead of three copies of the
  loop.
No change to operation order, types, launches or arithmetic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:29:03 +00:00
ClaudeandClaude Opus 5.5 a3df5855a2 kernels (K3): cell kinds in core/, capability guard first, self-contained block collision
- core/cell_kind.cuh holds KIND_ACTIVE/INNER/OUTER (was a constexpr in
  blocks.cu that step/block_channel.cu and the channel_root.cpp shim
  relied on or redefined); blocks.cu, block_channel.cu and the shim now
  include it.
- blocks.cu checks the lattice/model #error guard before the
  BLOCK_ROOT_BC guard and mode includes, so an unsupported build reports
  the real reason first.
- operators/block_collision.cuh includes what it uses (params, macro,
  SRT/MRT collision) instead of relying on blocks.cu's include order.
Preprocessor-only: same kernels, same statements, same arithmetic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:26:41 +00:00
Frank14f 11e96a188b Test historical build fingerprints with their pinned renderer 2026-10-21 18:50:15 +08:00
Frank14f 3a374faace Specialize blocked root boundary mode at compile time 2026-10-21 18:35:27 +08:00
Grok fce24a64e8 test: run history-independent identity checks before the history lookup 2026-10-21 16:49:41 +08:00
Grok d437faa501 test: check historical source manifests against their recorded commits
Manifests and receipts unchanged. Each of the 8 group-A identity tests now
extracts its manifest's recorded commit and verifies hashes there instead of
against the current checkout. Tamper assertions and runner source refusals are
kept; missing recorded history is an explicit failure, not a skip or repin.
2026-10-21 16:47:22 +08:00
Frank14f 26e49451ec Merge reviewed checkpoint additions for release candidate 2026-10-21 15:20:04 +08:00
Frank14f e214480452 Merge reviewed docs additions for release candidate 2026-10-21 15:20:04 +08:00
Frank14f c414f6c7f6 Merge reviewed fixtures additions for release candidate 2026-10-21 15:20:04 +08:00
ClaudeandClaude Opus 5.5 decd419e94 docs: cite only the recorded MRT parity pass (K1 7813334)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:08:31 +00:00
ClaudeandClaude Opus 5.5 1c47cb8570 docs: supported settings review fixes (exact parity test IDs, divisibility guard)
Names the exact SRT/MRT parity tests and their sub-case scope (SRT: one
of 18 sub-cases, unidentified; MRT: 12 sub-cases, default outlet settings,
with the runs that recorded the pass), cites blocks.py:134 for tile
divisibility, labels refinement.py:457 as the fine-boundary re-check, and
says 'measured at 36ffc8b1' for the Taylor-Green observation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:05:34 +00:00
ClaudeandClaude Opus 5.5 ee7074a53d tests: restore the negative checks masked by stale fixtures (WP-B groups B-E)
Test-side only; no production, manifest or pin change. Group A identity
checks (test_source_pin and the jets.py pins) are unchanged and stay
failing as recorded.

B test_refined_dfg: receipt/plan fixtures load the frozen manifest JSON
  that verify_source returns for a matching tree (frozen_manifest), and
  the two manifest-selection assertions call verify_source with only its
  working-tree hash comparison neutralised (selected_manifest), so the
  malformed/collision/observation-mode/checkout receipt checks run again.
  test_source_pin remains the single identity check.
C test_channel_root: int(owner)-1 (uint64-int became float on NumPy 1);
  test_shifted_storage: FP32 NaN fill so the arrays stay FP32. Both reach
  the intended immutable/representation/FP32/density checks.
D test_block_stepper fake gets _ddf_shifting; test_fine_surface_gpu sets
  _boundary_hosts/_level_jets instead of the read-only fine_boundary view;
  test_refinement_followup fake_backend sets _layouts/_ddf_shifting/
  _level_cfgs instead of the read-only fine_layout view (as reviewed in
  32da2d73).
E test_kernel_contracts_reject_before_upload: an inconsistent pair tau is
  now first rejected by the hierarchy chain check; the test asserts that
  rejection, then bypasses _check_chain to prove the per-level omega/tau
  guard still rejects, and asserts no mem_alloc/memcpy_htod in either case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:04:47 +00:00
Codex b97fc79af9 test: stage existing same-scene body checkpoint roundtrip
Copy tests/integration/test_checkpoint_bodies_gpu.py byte-for-byte from 34b30c434f. Base c95a7baae4. No production or dependency changes. GPU execution is deferred to the assigned executor.
2026-10-21 15:04:08 +08:00
ClaudeandClaude Opus 5.5 8e9fc72e19 docs: curved-wall observation states only the recorded matrix outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:59:14 +00:00
ClaudeandClaude Opus 5.5 d2cd1114ed docs: supported settings for the 2D Jet + multilevel candidate
One page listing supported settings, each with the guard enforcing it
(file:line at c95a7ba) and the existing tests exercising it. Measured
observations are labelled by their exact tested case (SRT strict-parity
1/4320 values ~2 ULP with unresolved cause; V3 TG constant explosion
aggregate; curved-wall r1 pilot; V4 NOT_STEADY), not stated as general
bounds. Documentation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:59:02 +00:00
ClaudeandClaude Opus 5.5 c95a7baae4 test_projected_pairs: regenerate pinned build sources from NUMERICAL_REVISION kernels
The pinned BUILD_SOURCES are the a135252 qualification build inputs.
generate_config hashes the whole kernel snapshot, so after K1/K2 the
working-tree kernels (legitimately) no longer reproduce them. The test
now archives src/CelerisLab/lbm/kernels at NUMERICAL_REVISION and points
compiler_v2._KERNEL_DIR there; config headers are still rendered by
today's compiler. BUILD_SOURCES and the equality assertion are unchanged;
each size is a subTest; a missing NUMERICAL_REVISION fails (no skip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:26:45 +00:00
ClaudeandClaude Opus 5.5 f4372a84c0 Merge a454f023 (claude/jet-shapes-once) for integration review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:03:36 +00:00
ClaudeandClaude Opus 5.5 e48498d089 Merge 580fcf65 (grok/p1-docs-wording) for integration review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:03:36 +00:00
ClaudeandClaude Opus 5.5 531739454e Merge 1cbdc479 (claude/step-single-sync) for integration review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:03:36 +00:00
ClaudeandClaude Opus 5.5 b93de2ce24 Merge 1fa498c7 (claude/k2-block-coords) for integration review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:03:36 +00:00
ClaudeandClaude Opus 5.5 a454f02355 Jets: compute jet link shapes once per boundary preparation
surface_link_velocity already runs jet_link_shapes; CurvedLinkSoA.
assign_surface and _prepare_blocked_boundary then ran it again with the
same jets and hits to fill the host jet cache. A private _surface_links
returns (velocity, aperture, shape, jet_id) from the one call; both
callers use it. surface_link_velocity keeps its two-value public
contract (now a thin wrapper). Same values, same validation order, same
cache contents; host geometry preparation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:57:11 +00:00
Grok 580fcf6525 docs(P1): align wording with level-list API only
Drop legacy constructor prose (child=/mutual exclusion), retarget
README and fixed_three_level_multipatch examples to refinements /
level_blocks, and fix stepper error/doc strings that still named
fine_block/base_block or a fixed gap >= 4 (unions use dependency
closure). Wording only; no behavior change.
2026-10-21 11:07:29 +08:00
ClaudeandClaude Opus 5.5 18f018befe README: several regions are refinements[0], not bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:02:38 +00:00
ClaudeandClaude Opus 5.5 ec9b3d721c P1 (12/12): one FixedPatchStepper construction and initialize API
Removes the legacy entry points now that every caller is migrated:
FixedPatchStepper(cfg, *, context, refinements, coarse_boundary,
level_blocks, level_objects, level_jets, observe_*, explosion) and
initialize(*level_g). Deleted: positional bounds, fine_block, base_block,
fine_objects, jets, child, child_block, child_objects, child_jets,
child_rectangles_in_parent, initialize(coarse_g/fine_g/child_g),
_legacy_levels and _initial_levels. Docstring, README and docs describe
the level lists. Compatibility views (patch, fine_boundary, ...) and the
PeriodicPatchStepper alias are unchanged (separate follow-up).

Error -> rule table (legacy keyword check -> level-list rule now firing):
- several patches without fine_block/base_block -> _check_levels
  'several patches require fine_block and base_block' (unchanged rule)
- base_block without fine_block -> 'a blocked level requires blocked
  refined levels below it'
- nested child without all blocks -> 'three or more levels must all be
  blocked' / 'a blocked level requires ...'
- fine_objects/jets with a nested child ('only on L2') -> 'bodies/Jets are
  supported only on the finest level of a nested hierarchy'
- child_objects/child_jets without a nested child -> 'level_objects/
  level_jets needs one entry per level'
- scalar child with several L1 patches ('scalar child requires one parent
  patch') -> hierarchy 'a scalar child requires a scalar parent'
- child vs child_rectangles_in_parent mutually exclusive -> no level-list
  equivalent (cannot be expressed); covered by the new explicit
  RemovedLegacyAPI test: removed keywords and positional bounds raise
  TypeError before any context use, initialize has only *level_g.
The InitialLevels test of the removed adapter is replaced by that test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:02:29 +00:00
ClaudeandClaude Opus 5.5 84b76f49cc P1 (11/n): multipatch GPU/CPU tests use the level-list constructor
One local levels(bounds, blocks, objects, jets, **rest) builds the
two-level lists; run_steps and every rejection case use it. All five
before-CUDA rejections, the margin and one-jet checks keep their inputs.
Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:00:59 +00:00
ClaudeandClaude Opus 5.5 050d12b76c P1 (10/n): blocked-fine GPU/CPU tests use the level-list constructor
Dense root + blocked fine level is level_blocks=[None, block]; bodies go
to level_objects[1]. Both before-CUDA rejections (non-dividing and
malformed fine block) keep their inputs. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:00:15 +00:00
ClaudeandClaude Opus 5.5 ec3e3b928a P1 (9/n): refinement follow-up tests pass refinements=[bounds]
FixedPatchStepper and the PeriodicPatchStepper alias are called with
refinements=[bounds] instead of the positional legacy bounds (same
_legacy_levels result). Assertions, including the before-CUDA and
four-cell/omega/layout rejections, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:59:56 +00:00
ClaudeandClaude Opus 5.5 2be80324ad P1 (8/n): three-level GPU/CPU tests use the level-list constructor
KW becomes levels() (scalar L1 + scalar L2, per-level blocks, objects,
Jets). All rejection cases are kept per level. Two cases change branch:
L2 bodies/Jets with no nested child (legacy 'child_objects/child_jets
require a nested child') are now an entry for a level that does not
exist ('needs one entry per level'); several L1 patches + scalar child
now meets 'a scalar child requires a scalar parent'. Both still reject
before CUDA. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:59:37 +00:00
ClaudeandClaude Opus 5.5 0fcd4ac4cb P1 (7/n): MRT, shifted-jet and blocked-base GPU tests migrated
test_refinement_mrt_gpu, test_shifted_jets_gpu and
test_refinement_blocked_base pass refinements/level_* lists. The three
blocked-base rejection cases keep their inputs per level; base-only
blocks now meet the level-list rule (blocked refined levels below) where
the legacy keyword check fired before. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:58:57 +00:00
ClaudeandClaude Opus 5.5 af0566db98 P1 (6/n): exchange-window, raw-exchange, Jet and pinned-remap GPU tests migrated
test_exchange_window, test_raw_exchange_gpu (one fine(block) helper for
the repeated circle+jet construction), test_refinement_jets,
test_multiple_pinned_remap_gpu and test_pinned_body_remap_gpu pass
refinements/level_* lists. Dense fine levels stay level_blocks=[None,
None|block] as before. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:58:15 +00:00
ClaudeandClaude Opus 5.5 0b1b3d938f P1 (5/n): six GPU validation tests use the level-list constructor
test_refinement_gpu (incl. PeriodicPatchStepper alias calls),
test_channel_root_gpu, test_fine_surface_gpu, test_shifted_remap_gpu,
test_runtime_child_remap_gpu and test_refinement_three_level_multipatch_gpu
pass refinements/level_* lists; scalar and set children map to the second
refinement entry exactly as _legacy_levels did. Assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:48:09 +00:00
ClaudeandClaude Opus 5.5 5167c79378 P1 (4/n): unit tests and examples use the level-list constructor
test_shifted_jets, test_shifted_storage, test_raw_exchange and
test_channel_root pass refinements/level_* lists; examples
fixed_patch_jet and fixed_periodic_patch pass refinements=[BOUNDS]
instead of the positional bounds. Assertions kept; the shifted-storage
case base_block-without-fine_block now expects the level-list rule
'blocked refined levels below' (same constraint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:46:52 +00:00
ClaudeandClaude Opus 5.5 aedddc35b1 P1 (3/n): three-level multipatch unit tests use the level-list constructor
KW (legacy base/fine/child blocks + child_rectangles_in_parent) becomes
levels(): refinements=[PARENTS, CHILDREN], level_blocks, level_objects,
level_jets. Every negative case is kept and expressed per level (child
rectangles, per-level blocks); the L1-body case now expects the level-list
rule's message 'only on the finest level' instead of the legacy keyword
message 'only on L2' (same constraint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:46:06 +00:00
ClaudeandClaude Opus 5.5 1cbdc479cf test_block_step_sync: locate block_stepper without importing it
The module-level import of CelerisLab.lbm.block_stepper loaded
pycuda.driver into the shared test process, unlike the other CPU host
tests (which load mocked copies via AST). find_spec gives the source path
without the import. Test content and assertions unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:39:52 +00:00
ClaudeandClaude Opus 5.5 7821c6ad71 P1 (2/n): runners and example use the level-list constructor
run_refined_dfg, run_dfg_re20, time_window_dfg, time_refined_dfg,
run_jet_circle_cv and examples/fixed_patch_circle pass refinements /
level_blocks / level_objects / level_jets instead of bounds, base_block,
fine_block, fine_objects and jets. One-to-one with _legacy_levels: same
rectangle (FixedPatch2D frame), same blocks, bodies and Jets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:35:04 +00:00
ClaudeandClaude Opus 5.5 392314e1ae P1 (1/n): remap_child builds its candidate through the level-list API
The only in-library caller of the legacy constructor keywords now passes
refinements=[L1 bounds, L2 set], level_blocks=[base, fine, child],
level_objects=[(), (), child_objects], level_jets=[(), (), jets] -- the
exact lists _legacy_levels produced for it. Legacy keywords stay until
every caller is migrated (final P1 commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:34:33 +00:00
ClaudeandClaude Opus 5.5 f3f1e3d588 PeriodicBlockStepper.step: mid-batch launch failure test; async-error docstring note
Review follow-up (non-blocking items): a launch failure on the second of
three steps fails closed without the batch wait, keeps step_count at its
entry value and blocks download; the docstring now states, as for
FixedPatchStepper, that async faults surface at the single wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:31:09 +00:00
ClaudeandClaude Opus 5.5 3648ad70ef PeriodicBlockStepper.step(n): wait on the stream once per call
Same change as the reviewed FixedPatchStepper end-sync (cherry-picked
ad1d82c+512dcc9 above): the n steps are enqueued in order and the host
waits once; step(0) launches nothing; step_count advances after the wait
and keeps its entry value on failure. Launches, buffer roles and bits are
those of n step(1) calls. Mocked-driver host test covers the single
wait, launch/role equality with n step(1), step(0) and failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:25:16 +00:00
ClaudeandClaude Opus 5.5 5399c9c0a6 step(n) docstring: raw-exchange ledger holds the final interval only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:24:36 +00:00
ClaudeandClaude Opus 5.5 54be33e006 FixedPatchStepper.step(n): wait on the stream once per call
Enqueue all n base intervals in order on the one stream and synchronize
once before returning (step(0): no launch, no wait). step_count, the raw
exchange ledger and an open window are published only after that wait;
any failure leaves step_count at its entry value and invalidates both
ledgers. Async device errors are no longer attributed to an interval;
the docstring says to use step(1) loops to localize one. Launch order and
buffers are unchanged. Adds a mocked-driver host test of the contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:24:36 +00:00
ClaudeandClaude Opus 5.5 1fa498c7d7 kernels: shared block-window to global coordinate helper
BlockStep (slot-reservoir wall rows) and BlockChannelStep computed the
global cell of a block-window cell with the same four lines.
block_global_xy (core/layout.cuh, 2D, __forceinline__) now holds them;
both kernels call it with their own domain width. Same integer
operations: no numerical, ABI or scheduling change. The slot-reservoir
kernel layout is unchanged (no split).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:24:27 +00:00
ClaudeandClaude Opus 5.5 781333407e kernels: one shared unforced MRT collision for blocked, channel and patch paths
BlockStep, BlockChannelStep and refinement.cu patch_collide each carried
the same compute_rho_u -> omega clamp -> collide_mrt_no_force sequence.
collide_mrt_unforced (collision_mrt.cuh, __forceinline__) now holds it
once; the three sites call it. Same operations in the same order: no
numerical, ABI or SRT change. The host source contract now requires one
helper definition and exactly one call per file, and forbids the old
inline sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:15:47 +00:00
115 changed files with 3235 additions and 2879 deletions
+38 -36
View File
@@ -112,8 +112,9 @@ 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, (8, 6, 24, 18), context=ctx,
fine_objects=[body], fine_block=(16, 20)) as run:
with CudaContext(0) as ctx, FixedPatchStepper(cfg, context=ctx, refinements=[(8, 6, 24, 18)],
level_blocks=[None, (16, 20)],
level_objects=[(), (body,)]) 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)),
@@ -124,8 +125,8 @@ with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
Scope and limits:
- The examples below use D2Q9, SRT, FP32, double buffer, unshifted, no LES or
forcing. Fixed 2:1 hierarchies can use level lists of arbitrary depth; the
legacy two/three-level forms are shown below.
forcing. Fixed 2:1 hierarchies use level lists of arbitrary depth; two- and
three-level examples below use that same constructor.
- `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)`),
@@ -133,50 +134,51 @@ 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.
- `fine_block=(by, bx)` stores the fine level in fixed-size blocks; `by`/`bx` must
- `level_blocks=[None, (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
`bounds`, with both `fine_block` and `base_block`. Transfers are built once
`refinements[0]`, with both levels blocked (`level_blocks`). 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. `fine_objects`/`jets` work
with tiles. Order of rectangles does not matter. L1 bodies/Jets (`level_objects[1]`/`level_jets[1]`) 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, use the level lists or legacy child forms below.
For nesting, add further `refinements` entries as shown 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, ((4, 4, 12, 12), (16, 4, 24, 12)), context=ctx,
fine_block=(4, 4), base_block=(4, 4)) as run:
cfg, context=ctx, refinements=[((4, 4, 12, 12), (16, 4, 24, 12))],
level_blocks=[(4, 4), (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
```
- 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.
- 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.
```python
from CelerisLab.body.geometry.circle import CircleGeometry
@@ -188,9 +190,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, (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:
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:
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)
@@ -198,16 +200,16 @@ Scope and limits:
run.step(4)
coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN
```
- 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.
- 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.
- 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,
+22
View File
@@ -3,6 +3,13 @@
Changes that can make a previously accepted setup or call behave differently.
Each entry names the commit, the affected entry points and the observable result.
## Redundant periodic stepper alias removed
`PeriodicPatchStepper` is no longer exported from `lbm.refinement_stepper`.
Use `FixedPatchStepper` with the same arguments, including explicit
`level_blocks`. The old name was an alias for that exact class; periodic
boundaries remain the default. Existing imports of the old name must be updated.
## 87c3a31: cut-link ownership is validated for every body
`validate_cut_link_ownership` (`body/preprocess/flag_overlay.py`) now runs for
@@ -33,3 +40,18 @@ or negative amplitude (the aperture test is geometric). Bodies with neither
`SurfaceActuation` nor aperture links still raise
`ValueError("Body has no surface actuation")`. The value is unchanged: the latest
step's wall/aperture exchange, not total nozzle force.
## 7afb5ad: `Simulation` fails closed after a Jet amplitude device-write failure
`Simulation.set_jet_amplitude` previously only documented that a device error
leaves the per-link velocity column undefined; the instance stayed usable.
Now any error after input validation (internal stream creation or the upload)
marks the Simulation failed. Every later device-touching public method,
including the `stream` property (guard added in the follow-up repair commit on
`claude/jet-update-consistency`), raises
`RuntimeError("Simulation failed (...); close() and recreate it")`. The host
amplitude keeps its previous value. `close()` still works and stays retryable;
cached host readers and host staging calls are not blocked. Invalid input
(`ValueError`/`IndexError`) is unchanged: it raises before any change and the
instance stays usable. `FixedPatchStepper.set_jet_amplitude` already failed the
instance (status `failed`) and is unchanged.
+1 -1
View File
@@ -4,7 +4,7 @@
G-to-G explosion reconstruction. The omitted/default `"constant"` selector retains
the same kernel, launch arguments and device allocations. Projected mode adds two
compact tables per adjacent pair; recursion, collision, BFL, restriction, buffer
ownership and synchronized publication are unchanged. `remap_child` preserves it.
ownership and synchronized publication are unchanged. `remap_finest` preserves it.
Runtime eligibility is periodic-root, fully blocked D2Q9/SRT/FP32, body/Jet-free,
without LES; the fixed-patch backend has no forcing interface. Shifted and unshifted
+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) remains SRT-only.
- `PeriodicBlockStepper` (uniform blocked stepper) remained SRT-only; it was removed in 8e1feff and its single-level capability, still SRT-only, is `FixedPatchStepper(refinements=[])`.
## 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", 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.
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.
`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`:
+77
View File
@@ -0,0 +1,77 @@
# src coherence stage (from main 914906e)
Owner direction 2026-10-28: unified style over historical compatibility, inside `src`.
No numerical change is intended; kernel text was moved verbatim (PTX/bit parity is
pending a GPU run).
## Conventions (defined once, in `lbm.hierarchy.Hierarchy`)
- Level `l = 0 .. depth-1` (0 = root). Pair `hierarchy.pairs[l-1]` builds level `l`.
- Array shapes are `(ny, nx)`; rectangles, origins and centers are `(x, y)`.
- Coarse/fine name the two members (parent/child) of ANY pair, not absolute L0/L1:
`fine_shape`, `fine_origin`, `prepare_fine_boundary`, `blocked_fine_tables`,
`blocked_coarse_tables`.
- Refined levels (l >= 1) of FixedPatchStepper place bodies/Jets in the root EDGE frame
(root cell `i` = `[i, i+1)`). Uniform `Simulation` objects AND the static circles of a
single-level (depth 1) FixedPatchStepper use the integer-NODE frame. Nothing converts
between them. This concerns input placement only: within the EDGE frame, refined
levels still project root-edge coordinates to fine-local cell indices with the
half-cell shift (`prepare_fine_boundary`: `(v - o)/dx - .5`; hits back via `fine_hits_to_edge_frame`
`origin + dx*(hit + .5)`), using `edge_frame`/`fine_origin` below.
- `Hierarchy.edge_frame(l)` -> `((ox, oy), h)`: cell `(i, j)` of level `l`'s allocated
array (ghost band and holes included) covers `[o + h*i, o + h*(i+1))`. A scalar
FixedPatch2D window carries the half-coarse-cell ghost shift `bounds - HALO/2` in
`fine_origin`; a set's level-wide grid has origin 0. Level 1 boundaries are built from
the root config (the wall-aware fields live there); deeper ones from the parent frame.
- `FixedPatch2D.fine_level_offset` (was `fine_level_origin`) is an array INDEX offset
`(y, x)`, not an `(x, y)` origin.
## FixedPatchStepper migration map
| removed / changed | replacement |
|---|---|
| `patch`, `child_patch` | `hierarchy.pairs[0]`, `hierarchy.pairs[1]` |
| `base_layout`, `fine_layout`, `child_layout` (stepper properties) | `layouts[0]`, `layouts[1]`, `layouts[2]` (`FixedPatchSet2D.fine_layout(block)` is unchanged) |
| `fine_boundary`, `child_boundary` | `boundaries[1]`, `boundaries[2]` (host geometry; `None` = no bodies/Jets) |
| `_jets` | `jets[1]` |
| `_boundaries[l]` (dict from L1, level keys) | `_device_boundaries[l]` (list from L0, `None` at L0 unless single-level static circles) |
| `_level_boundaries` (private, b4eacf7) | `_device_boundaries` |
| `_fine_tables`, `_actuated`, `_jet_cache` | `_device_boundaries[1].tables` / `.actuated` / `.jet_cache` |
| `_remap_cfg` (private) | `_root_cfg` |
| `refinement_tables.blocked_base_tables` (also via `refinement_stepper`) | `refinement_tables.blocked_coarse_tables` (import from `refinement_tables`; the stepper no longer re-exports) |
| `_fine_leaf`, `_child_leaf` | `leaf[1]`, `leaf[2]` (copies) |
| `_middle_kind` | `hierarchy.kinds[1]` |
| `_boundary_hosts` (private) | `_boundaries` (private list behind `boundaries`) |
| `remap_child(rects, child_objects=...)` | `remap_finest(rects, objects=...)` |
| `set_jet_amplitude(i, a)` (implicit level 1) | `set_jet_amplitude(i, a, level=l)` (required) |
| `read_raw_exchange(b)` / `read_exchange_window(b)` (implicit level 1) | `read_raw_exchange(b, level=l)` / `read_exchange_window(b, level=l)` (required) |
| `_level_name(l)` "base/fine/child" messages | `"level l"` |
| `refinement.MIN_PATCH_GAP` | deleted (no gap policy exists) |
| `params.cuh HaloPlan`, `collide_trt_no_force` | deleted (zero references) |
Kept: `step_count`, constructor keywords (`refinements`, `level_blocks`, `level_objects`,
`level_jets`, `coarse_boundary`, `explosion`), `Simulation.set_jet_amplitude(i, a)` (one level),
`RemapTopology.from_config(..., child_objects=, child_jets=)` (internal remap helper; "child" =
the replaced level relative to its parent).
## Responsibilities
- Construction: `_check_levels` (inputs), `_kernel_configs` (per-level kernel configs),
`Hierarchy.edge_frame` (geometry frames), `_build_level` / `_build_pair` / `_upload_boundary`
(device). Observation reads share `_observed_boundary` + `_summarize`.
- Blocked kernels: `operators/block_collision.cuh block_collide` is the one collision extension
point of `BlockStep` and `BlockChannelStep`. MRT shares one path; SRT keeps its two explicit
arithmetic variants (level: unclamped, no-force, shifted direct-momentum feq; channel: uniform
OneStep arithmetic with omega clamp), selected by `BLOCK_ROOT_BC`.
- `explosion="projected_linear"` stays an optional capability; `"constant"` stays default.
## Not done / open
- Two per-level config derivations stay distinct on purpose: `Hierarchy.configs`
(`_middle_config`, wall fields reset on every refined level, geometry validation) vs.
`FixedPatchStepper._kernel_configs` (reset only on level 1 of a slot_reservoir root;
generates each level's config.h). Folding them needs a config.h/PTX diff gate.
- No single-`Simulation` facade over FixedPatchStepper: the two own different state models
(uniform node frame, body sync, forcing, checkpoint vs. immutable blocked hierarchy, edge
frame, remap-as-new-instance). A facade would only forward calls; no concrete need found.
- Tests, examples and validation callers are migrated separately (pinned runners stay frozen).
+393
View File
@@ -0,0 +1,393 @@
# 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 |
+16 -16
View File
@@ -1,8 +1,8 @@
# Fixed three-level rectangle unions
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
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
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
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.
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.
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 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`:
- 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, [(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)],
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,
)
```
@@ -57,11 +57,11 @@ 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 `child_objects` and `child_jets` use root edge
Stationary whole-circle L2 bodies and Jets (`level_objects[2]`, `level_jets[2]`) 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
`set_jet_amplitude(index, value, level=2)`; L1 bodies/Jets remain unsupported.
`set_jet_amplitude(index, value, level=2)` (`level` is required); L1 bodies/Jets remain unsupported.
## Verification
+26
View File
@@ -0,0 +1,26 @@
# 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 blocked slot/reservoir root reads its slot from the `d_block_wall` constant (the former dense route used `WALL_*` macros; both were bit-identical in the parity gates). | Every owner loads its own module (`compiler.load_module` = `module_from_file`, no cache). A loaded module is never shared between live owners: globals are per module, so sharing would share mutable `d_params`. |
| 2. Geometry, layout, tables | flags, curved-link tables, block layouts and halo pairs, transfer (explode/coalesce) tables, block kinds | Immutable for the life of an instance. Uniform grid: rebuilt by body sync. `FixedPatchStepper`: a geometry change is a new instance (`remap_finest`), committed only after a successful transfer. | No in-place table edits. |
| 3. Controls and dependent boundary columns | Jet amplitude -> per-link `surface_velocity`; body actions (uniform grid) | Between steps, without a table or module rebuild (`set_jet_amplitude`). | Input (index, level, amplitude) is validated before any device write; invalid input changes nothing and the instance stays usable. The host copy commits only after the upload returned. Any error after validation leaves the device column undefined and fails the instance: `FixedPatchStepper` status `failed`, `Simulation._failed`; every later device-touching public method (`Simulation`: `stream`, `set_jet_amplitude`, `initialize`, `sync_bodies`, `run`/`step`, `read_surface_exchange`, `read_body(s)`, `get_macroscopic`, `get_ddf`, `update_runtime_params`, `snapshot`, `restore`, save/load checkpoint) raises; cached host readers and host-side staging (e.g. `get_flags`, `add_body`/`remove_body` before a sync) do not touch the device and stay callable. `close()` still releases resources and stays retryable. |
| 4. Per-step state | population double buffers and their parity, raw-exchange ledgers, observations | Device-only during `step(n)` / `run(n)`; buffer swaps are host-handle swaps. | Valid on the host only after the single wait at the end of the call. |
Storage routes are separate concerns:
- 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`) were removed: `level_blocks` is
required and every refinement level is blocked.
A single-rectangle refinement (`FixedPatch2D`) is stored in its own fine
window frame (`FixedPatchStepper.__init__`,
`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`).
+89
View File
@@ -0,0 +1,89 @@
# 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/blocked.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 | supported (every level is blocked); one representation on all levels | `lbm/refinement_stepper.py` `FixedPatchStepper._check_storage` |
| 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` |
| Refinement storage | blocked on every level: `level_blocks` is required, one tile `(by, bx)` per level dividing that level; a whole-level tile (`(ny, nx)` at the root, the fine window shape for one rectangle) always divides. Dense and mixed dense/blocked storage were removed (parity of D/D, D/B and B/B recorded at 1573942 and 2469af7) | `lbm/refinement_stepper.py` `_check_levels`, `FixedPatchStepper.__init__` |
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_finest`) | periodic three-level L2 sets only, no L0/L1 bodies, identical pinned boundary | `lbm/refinement_stepper.py` `FixedPatchStepper.remap_finest` |
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_finest`),
moving bodies on refined levels, pressure Jets, Jet checkpoints, LES or TRT on refined
levels, partial (non-dividing) tiles.
+4 -4
View File
@@ -33,7 +33,7 @@ origin is retained. Physical lower edges are `origin + spacing*(x,y)` on L0,
`origin + spacing*(parent.fine_origin + (x,y)/4)` on L2.
Factory options `coarse_boundary` and `objects` must declare `"periodic"` and
an empty collection; L2 bodies use `child_objects` as described below.
an empty collection; L2 bodies use `objects` as described below.
LBMConfig's channel settings do not select this operator's boundary mode.
Geometry metadata is factory-created;
manual mutation/forging of metadata is unsupported. Caller certifies the
@@ -77,7 +77,7 @@ PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_synchronized_r
## Explicit runtime continuation
`replacement = current.remap_child(rectangles)` returns a new ready
`replacement = current.remap_finest(rectangles)` returns a new ready
`FixedPatchStepper` at the **same absolute step_count**. It supports only
periodic three-level runs with no L0/L1 bodies and an existing L2
`FixedPatchSet2D`; stationary L2 bodies/Jets follow the support contract below.
@@ -99,7 +99,7 @@ resources; an actual old-device synchronization/read failure marks the old
stepper failed, consistent with its existing failure semantics.
```python
replacement = current.remap_child([(20, 16, 36, 40)])
replacement = current.remap_finest([(20, 16, 36, 40)])
# Explicitly choose the returned continuation only after success.
current.close()
current = replacement
@@ -119,7 +119,7 @@ Per-population conservation is not an advancement target under collision.
### Bounded pinned-body extension
`replacement = current.remap_child(rectangles, child_objects=original_objects)`
`replacement = current.remap_finest(rectangles, objects=original_objects)`
also accepts stationary Circles inside the L2 union. Each body's **physical
boundary, complete BFL support and current per-link velocity must remain
identical**. Rectangles are not pinned: they may be split, merged or resized,
-5
View File
@@ -46,9 +46,4 @@ 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`.
+12 -10
View File
@@ -1,9 +1,9 @@
"""Public API demo: fixed 2:1 patch with a stationary, optionally actuated circle.
From this checkout, after checking the selected physical GPU is idle:
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py --fine-block 16 20
... --speed -0.002 --fine-block 16 20 --mode slot_reservoir
Omit --fine-block for the (equivalent) dense fine storage.
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py
... --speed -0.002 --mode slot_reservoir
Both levels use blocked storage (--base-block, --fine-block tiles).
A short smoke run (default 128 coarse steps): it shows setup, stepping and
fluid-only diagnostics, not a developed flow, accuracy or long-time stability.
@@ -71,8 +71,10 @@ def main():
parser.add_argument("--mode", choices=("periodic", "slot_reservoir"), default="periodic")
parser.add_argument("--speed", type=float, default=.002,
help="normal surface velocity, coarse lattice units (+ blows, - sucks, 0 passive)")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"),
help="blocked fine storage; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--base-block", type=int, nargs=2, metavar=("BY", "BX"), default=(12, 8),
help="root tile; must divide the 24 x 32 root")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"), default=(16, 20),
help="fine tile; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--steps", type=int, default=128)
args = parser.parse_args()
if args.steps < 0:
@@ -86,20 +88,20 @@ def main():
body = SimObject(0, geometry=CircleGeometry(*CENTER, RADIUS), center=CENTER, radius=RADIUS,
surface_actuation=spec)
cfg = config(args.mode)
fine_block = tuple(args.fine_block) if args.fine_block else None
# 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, BOUNDS, context=context, coarse_boundary=args.mode,
fine_objects=[body], fine_block=fine_block) as run:
cfg, context=context, coarse_boundary=args.mode, refinements=[BOUNDS],
level_blocks=[tuple(args.base_block), tuple(args.fine_block)],
level_objects=[(), (body,)]) 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.
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
run.initialize(np.broadcast_to(rest, (9, *p.coarse_shape)),
np.broadcast_to(rest, (9, *p.fine_shape)))
storage = f"blocked {fine_block}" if fine_block else "dense"
print(f"mode={args.mode} fine storage={storage} speed={args.speed:+g} "
storage = f"blocked root {tuple(args.base_block)} fine {tuple(args.fine_block)}"
print(f"mode={args.mode} storage={storage} speed={args.speed:+g} "
f"(fine allocation {p.fine_shape[0]}x{p.fine_shape[1]}, fine origin {p.fine_origin})")
masks = leaf_masks(run, args.mode)
start = leaf_diagnostics(*run.download(), masks)
+6 -3
View File
@@ -31,6 +31,7 @@ from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
BOUNDS, AMPLITUDE, WIDTH = (16, 4, 32, 28), .02, 12
LEVEL_BLOCKS = [(12, 8), (8, 8)] # blocked storage: 48x48 root, 56x40 fine window
TIMES, MAX_MACH, MAX_SECONDS = (0, 300, 1200), .1, 120
@@ -132,7 +133,8 @@ def main():
lattice_model='D2Q9', collision='SRT', streaming='double_buffer',
store_precision='FP32', ddf_shifting=False, les_enabled=False)
report = dict(config=dict(nx=48, ny=48, bounds=BOUNDS, W=WIDTH, A=AMPLITUDE, nu=.1,
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir'),
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir',
level_blocks=LEVEL_BLOCKS),
guards=dict(check_every=50, positive_density=True, finite=True,
max_Mach=MAX_MACH, max_seconds=MAX_SECONDS,
timing='cooperative checks, not an exact interruption deadline'),
@@ -148,8 +150,9 @@ def main():
samples=[], status='running', last_synchronized_coarse_step=0)
start, run = time.monotonic(), None
try:
with CudaContext(0) as context, FixedPatchStepper(cfg, BOUNDS, context=context,
coarse_boundary='slot_reservoir') as run:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context,
coarse_boundary='slot_reservoir', refinements=[BOUNDS],
level_blocks=LEVEL_BLOCKS) as run:
p = run.patch
masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE]
masks[0][[0, -1]] = False
+3 -2
View File
@@ -16,7 +16,7 @@ from CelerisLab.config import load_lbm_config
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf
from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
def shear_equilibrium(shape, origin, dx, period):
@@ -48,7 +48,8 @@ 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, (4, 4, 12, 12), context=context) as run:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as run:
p = run.patch
coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny)
fine = shear_equilibrium(p.fine_shape, p.fine_origin, .5, cfg.ny)
+14 -3
View File
@@ -207,19 +207,30 @@ 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 (None, None) when neither an object carries surface
order. Returns all 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
return None, None, 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
@@ -252,4 +263,4 @@ def surface_link_velocity(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
return surface_velocity, surface_aperture, shape, jet_id
+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_v2.obs_layout`.
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler.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_v2 import obs_layout
from ..cuda.compiler 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_v2.py` for header generation.
macro dicts consumed by `cuda/compiler.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_v2.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `kernel_v2.cu` 属不受支持用法。
仓库中检入的 `config/*.h` 可能是历史版本。`compiler.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `uniform.cu` 属不受支持用法。
```
config_lbm.json
↓ load_lbm_config()
config.py:LBMConfig (读取 + 校验)
↓ to_macros()
cuda/compiler_v2.py (独立构建目录中生成 config/*.h)
cuda/compiler.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_v2 as compiler
from . import compiler
__all__ = ["compiler"]
@@ -1,11 +1,13 @@
# CelerisLab/cuda/compiler_v2.py
# CelerisLab/cuda/compiler.py
"""
Kernel configuration, compilation, and module loading.
Workflow:
Owners call build_module(cfg, entry, arch=...), then set_params(module, omega, ...).
build_module runs, in a private directory it removes afterwards:
1. build_dir = generate_config(lbm_cfg, n_objects) — isolated source snapshot
2. compile_kernel(arch, build_dir=build_dir) — nvcc -ptx kernel_v2.cu
2. compile_kernel(arch, source=entry, build_dir=build_dir) — nvcc -ptx
3. load_module(ptx_path) — PyCUDA module from PTX
Entries: uniform.cu (uniform grid), blocked.cu (every blocked level, single or refined).
"""
from __future__ import annotations
@@ -16,7 +18,6 @@ import shutil
import tempfile
import hashlib
import json
from contextvars import ContextVar
from dataclasses import asdict
from pathlib import Path
from typing import Optional
@@ -33,9 +34,6 @@ _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)
@@ -54,14 +52,16 @@ def _write(path: str, content: str):
def generate_config(cfg: LBMConfig, n_objects: int = 0,
*, build_root: Optional[str] = None) -> str:
*, build_root: Optional[str] = None, block_root_bc: int = 0) -> str:
"""Snapshot kernels and render headers in a fresh private directory.
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
Returns its path; pass it to compile_kernel(build_dir=...). 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")
@@ -71,20 +71,19 @@ 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"))
_render_config(macros, n_objects, os.path.join(build_dir, "config"),
block_root_bc=block_root_bc)
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}
"n_objects": n_objects, "block_root_bc": block_root_bc}
_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,12 +95,9 @@ 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):
def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0):
_write(os.path.join(cdir, "config_grid.h"), f"""{_HEADER}\
// Layer: Global / Grid
#ifndef CELERIS_CONFIG_GRID_H
@@ -165,6 +161,9 @@ def _render_config(m: dict, n_objects: int, cdir: str):
#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,13 +240,9 @@ def _render_config(m: dict, n_objects: int, cdir: str):
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
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()
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."""
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()
@@ -272,23 +267,36 @@ def compile_kernel(arch: str = "sm_70", source: str = "kernel_v2.cu",
finally:
if os.path.exists(tmp):
os.unlink(tmp)
_last_ptx_path.set(str(dst))
return str(dst)
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")
def load_module(ptx_path: str):
"""Load one compiled PTX file as a new PyCUDA module."""
import pycuda.driver as cuda
return cuda.module_from_file(ptx_path)
# ---------------------------------------------------------------------------
# Back-compat helpers (used by test scripts)
# ---------------------------------------------------------------------------
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.
def compile_kernel_v2(arch: str = "sm_70") -> str:
"""Alias for compile_kernel() kept for test-script compatibility."""
return compile_kernel(arch=arch)
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))
+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_v2.generate_config`` must use
Host code (e.g. ``ObjectManager``) and ``compiler.generate_config`` must use
the same ``dim`` and ``n_objects`` so allocation sizes match device macros.
Design::
-286
View File
@@ -1,286 +0,0 @@
# 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
+82 -1
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
from dataclasses import dataclass, replace
from numbers import Integral
from types import SimpleNamespace
@@ -373,3 +373,84 @@ 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)
+2 -4
View File
@@ -94,12 +94,10 @@ class CurvedLinkSoA:
"""
self.surface_velocity = self.aperture = None
self.jets, self.jet_shape, self.jet_id = tuple(jets), None, None
self.surface_velocity, self.aperture = surface_link_velocity(
from ..body.jets import _surface_links
self.surface_velocity, self.aperture, self.jet_shape, self.jet_id = _surface_links(
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.
+32 -3
View File
@@ -21,7 +21,24 @@ from .refinement import (ACTIVE, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D,
@dataclass(frozen=True, eq=False)
class Hierarchy:
"""configs[l]: validated LBMConfig of level l; pairs[l]: tables of (l, l+1);
kinds[l]: composed per-cell kind (child coverage OUTER on every non-finest level)."""
kinds[l]: composed per-cell kind (child coverage OUTER on every non-finest level).
Index conventions (the single place they are defined):
* level l = 0 (root) .. depth-1; pair index p = l-1 builds level l from level p;
* array shapes are (ny, nx); rectangles, origins and centers are (x, y);
* a pair's two members are its COARSE (parent) and FINE (child) level, for every
pair, not absolute L0/L1 (fine_shape, fine_origin, blocked_coarse_tables, ...);
* refined levels (l >= 1) of FixedPatchStepper place bodies and Jets in the root
EDGE frame: root cell i covers [i, i+1), center i+.5. Uniform Simulation objects
and the static circles of a single-level (depth 1) FixedPatchStepper use the
integer-NODE frame (CircleGeometry.build_flag_mask at integer (x, y)). No
NODE<->EDGE input conversion is applied anywhere; inside the EDGE frame, refined
boundaries still project root-edge coordinates to fine-local cell indices with
the half-cell shift ((v - o)/h - .5, see prepare_fine_boundary);
* cell (i, j) of level l's allocated array (ghost band and holes included) covers
[o + h*i, o + h*(i+1)) with (o, h) = edge_frame(l); level 1 is built from the root
config directly, deeper levels from their parent frame (see edge_frame).
"""
configs: tuple
pairs: tuple
kinds: tuple
@@ -30,6 +47,18 @@ class Hierarchy:
def depth(self):
return len(self.kinds)
def edge_frame(self, level):
"""(origin (x, y), spacing) of level ``level``'s array in the root edge frame.
Each pair's fine_origin is in parent cells: zero for a set (level-wide grid),
the shifted window origin for a scalar FixedPatch2D.
"""
origin, spacing = (0.0, 0.0), 1.0
for pair in self.pairs[:level]:
origin = tuple(o + spacing * f for o, f in zip(origin, pair.fine_origin))
spacing /= 2
return origin, spacing
def is_rectangle(value):
value = tuple(value)
@@ -86,14 +115,14 @@ def _check_chain(coarse, fine, pair):
def leaf_partition(h):
"""Base-cell fraction published as ACTIVE over all levels (exact: powers of two).
Window (scalar) levels are offset by their fine_level_origin first.
Window (scalar) levels are offset by their fine_level_offset first.
"""
base = np.zeros(h.kinds[0].shape)
oy = ox = 0
for level, kind in enumerate(h.kinds):
if level:
pair = h.pairs[level - 1]
py, px = (pair.fine_level_origin if isinstance(pair, FixedPatch2D) else (0, 0))
py, px = (pair.fine_level_offset if isinstance(pair, FixedPatch2D) else (0, 0))
oy, ox = 2 * oy + py, 2 * ox + px
y, x = np.nonzero(kind == ACTIVE)
np.add.at(base, ((y + oy) >> level, (x + ox) >> level), 0.25 ** level)
+47
View File
@@ -0,0 +1,47 @@
// 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
#include "operators/block_collision.cuh"
// 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
@@ -1,206 +0,0 @@
// 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"
@@ -0,0 +1,20 @@
// 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,10 +15,13 @@
// D2Q9 y-wall half-way bounce-back for pull double-buffer streaming.
// ---------------------------------------------------------------------------
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
// 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)
{
if (y == 1) {
// Directions sourced from y=0 wall in pull step: +y, +x+y, -x+y.
@@ -26,14 +29,22 @@ __device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
f[5] = load_ddf(fi_in, index_f(k, 6u));
f[8] = load_ddf(fi_in, index_f(k, 7u));
}
else if (y == (unsigned int)(NY - 2)) {
// Directions sourced from y=NY-1 wall in pull step: -y, -x-y, +x-y.
else if (y == top_row) {
// Directions sourced from the 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,6 +8,9 @@
#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
@@ -0,0 +1,16 @@
// 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,6 +53,18 @@ __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)
// ---------------------------------------------------------------------------
@@ -44,16 +44,4 @@ struct RigidBodyControl2D {
int control_mode; // 0=force, 1=velocity, 2=displacement
};
// ============================================================================
// Halo plan (multi-GPU / AMR 预留)
// ============================================================================
struct HaloPlan {
int face_id;
int peer_gpu;
int level;
size_t count;
int* send_idx;
int* recv_idx;
};
#endif // CELERIS_CORE_PARAMS_CUH
@@ -0,0 +1,43 @@
// The one collision extension point of the blocked steps (BlockStep, BlockChannelStep).
// Compile-time selection: MRT shares one path; SRT keeps two explicit arithmetic
// variants, unchanged from their original kernels:
// level (BLOCK_ROOT_BC 0/1): unclamped omega, no forcing term; shifted storage
// builds feq from the retained drho and direct momentum (numerical variant);
// channel (BLOCK_ROOT_BC 2): matches uniform OneStep SRT arithmetic (omega clamp,
// zero Guo forcing term, compute_feq).
#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);
#elif BLOCK_ROOT_BC == 2
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
const float omega = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega));
collide_srt(f, feq, forcing, 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
}
#endif
@@ -0,0 +1,14 @@
// 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,6 +114,16 @@ __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,
@@ -57,25 +57,4 @@ __device__ __forceinline__ void collide_trt(float* __restrict__ f,
}
}
__device__ __forceinline__ void collide_trt_no_force(float* __restrict__ f,
const float* __restrict__ feq,
float omega)
{
const float wp = omega;
const float wm = compute_omega_minus(wp);
f[0] = f[0] - wp * (f[0] - feq[0]);
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
const int ib = i + 1;
const float fi = f[i], fb = f[ib];
const float feqi = feq[i], feqb = feq[ib];
const float delta_s = 0.5f * ((fi - feqi) + (fb - feqb));
const float delta_a = 0.5f * ((fi - feqi) - (fb - feqb));
f[i] = fi - wp * delta_s - wm * delta_a;
f[ib] = fb - wp * delta_s + wm * delta_a;
}
}
#endif // CELERIS_OPERATORS_COLLISION_TRT_CUH
@@ -5,12 +5,7 @@
#ifndef CELERIS_OPERATORS_HELPERS_CUH
#define CELERIS_OPERATORS_HELPERS_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;
}
}
#include "bounce_back_swap.cuh"
__device__ __forceinline__ void collide_dispatch(
float* f, float rho_n,
-130
View File
@@ -1,130 +0,0 @@
// Fixed, unforced internal 2:1 patch. Separate compilation entry;
// default OneStep/channel kernels are deliberately not included.
#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/planar_velocity.cuh"
#include "boundary/curved_boundary.cuh"
extern "C" {
#include "step/curved_double.cu"
}
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_DDF_SHIFTING || USE_LES || STREAMING_MODEL != 0
#error "Refinement requires unshifted FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
// Enabled only in the validation build; no diagnostic arrays in normal builds.
#ifdef CELERIS_REFINEMENT_AUDIT
extern "C" {
__device__ unsigned int collision_counts[TOTAL_CELLS];
__device__ unsigned int explosion_counts[TOTAL_CELLS];
__device__ unsigned int incoming_counts[NQ * TOTAL_CELLS];
}
#endif
__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 kernel_v2.cu. No standalone compilation.
// Included by uniform.cu. No standalone compilation.
//
// Double-buffer mode:
// CurvedBoundaryKernel runs BEFORE OneStep.
@@ -0,0 +1,42 @@
// 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,5 +1,12 @@
#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"
@@ -15,15 +22,13 @@ 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;
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;
unsigned int gx, gy;
block_global_xy(x, y, halo, d_block_channel.nx, gx, gy);
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) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
fill_invalid(f);
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j);
@@ -31,44 +36,19 @@ 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) {
// 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;
}
bounce_back_swap(f); // Wall owns all four corners; ghosts never collide.
} 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];
for (int q = 0; q < NQ; ++q)
donor[q] = load_ddf(in + base, index_f(k - 1, q));
load_node(in + base, k - 1, donor);
apply_pressure_outlet_d2q9(f, donor);
} 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
// 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] = {};
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
const float omega = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega));
collide_srt(f, feq, forcing, omega);
#endif
} else {
apply_wall_bb_d2q9_rows(gy, d_block_channel.ny - 2, f, in + base, k);
}
if (!wall && !outlet) block_collide(f);
}
stream_pull_store(k, f, out + base);
}
@@ -0,0 +1,35 @@
// 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);
}
@@ -0,0 +1,78 @@
// 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 and coarse_cells
// is the parent's block window 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.
// Child summation order ((c0 + c1) + c2) + c3 is part of the numerics; 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 kernel_v2.cu. No standalone compilation.
// Included by uniform.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 kernel_v2.cu. No standalone compilation.
// Included by uniform.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 kernel_v2.cu after all headers. No standalone compilation.
// Included by uniform.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_v2 — double-buffer channel initialization
// InitTubeFlow — double-buffer channel initialization
// ============================================================================
__global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
__global__ void InitTubeFlow(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 kernel_v2.cu. No standalone compilation.
// Included by uniform.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 kernel_v2.cu. No standalone compilation.
// Included by uniform.cu. No standalone compilation.
//
// CURVED BC: supported via PrepareEsoPullCurvedKernel + ApplyEsoPullCurvedKernel
// in aux_kernels.cu, launched by stepper.py before EsoPullStep.
@@ -21,6 +21,14 @@
// 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,
@@ -1,4 +1,4 @@
// CelerisLab – kernel_v2.cu
// CelerisLab – uniform.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_v2, InitEsoPull
// step/init_flow.cu — InitTubeFlow, InitEsoPull
// step/one_step_double.cu — OneStep (pull double-buffer)
// step/one_step_esopull.cu — EsoPullStep (single-buffer)
// step/aux_kernels.cu — CurvedBoundaryKernel, SensorKernel
+3 -3
View File
@@ -115,8 +115,9 @@ class FixedPatch2D:
return self.bounds[0] - HALO / 2, self.bounds[1] - HALO / 2
@property
def fine_level_origin(self) -> tuple[int, int]:
"""(y, x) of this window's first cell on the level-wide 2:1 fine grid."""
def fine_level_offset(self) -> tuple[int, int]:
"""Array index (y, x) of this window's first cell on the level-wide 2:1 fine grid
(an index offset, not an (x, y) origin)."""
return 2 * self.bounds[1] - HALO, 2 * self.bounds[0] - HALO
@property
@@ -151,7 +152,6 @@ class FixedPatch2D:
raise ValueError("levels are not synchronized: fine_steps must equal 2*coarse_steps")
MIN_PATCH_GAP = 4 # Legacy exported constant; union transfers impose no gap policy.
@dataclass(frozen=True, eq=False)
File diff suppressed because it is too large Load Diff
+7 -5
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 jet_link_shapes, surface_link_velocity
from ..body.jets import _surface_links
from .descriptors import D2Q9_EX, D2Q9_EY
from .refinement import ACTIVE, OUTER_GHOST
@@ -36,7 +36,7 @@ def blocked_kind(layout, dense_kind):
kind[owner[owned]] = np.asarray(dense_kind).ravel()[owned]
return kind
def blocked_base_tables(patch, layout, order, wall_aware=False, *, composed_kind=None):
def blocked_coarse_tables(patch, layout, order, wall_aware=False, *, composed_kind=None):
"""Parent donor cells and receiver-local arrival slots, in transfer row order.
Stage arrivals only after child reads and parent halo gather, immediately
@@ -142,11 +142,13 @@ 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"] = surface_link_velocity(
tables["surface_velocity"], tables["aperture"], shape, jet_id = _surface_links(
boundary.objects, cfg, boundary.flags, fluid_idx, dirs, body_id, rx, ry,
jets=jets, hits=boundary.curved_hits, _shifted_blocked=_shifted_blocked)
return _BlockedBoundary(boundary, tables, jets, actuated,
jet_link_shapes(jets, boundary.curved_hits) if jets else None)
if jets:
jet_cache = (shape, jet_id)
return _BlockedBoundary(boundary, tables, jets, actuated, jet_cache)
+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_v2")
self.init_fn = module.get_function("InitTubeFlow")
self.curved_fn = module.get_function("CurvedBoundaryKernel")
self.prepare_esopull_curved_fn = module.get_function("PrepareEsoPullCurvedKernel")
+35 -21
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_v2 as compiler
from .cuda import compiler
from .lbm.field import LBMField
from .lbm.stepper import LBMStepper
from .body.objects import SimObject
@@ -46,8 +46,8 @@ 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
self._failed = None # reason after an uncertain device write; only close() remains
# Load configs
self.lbm_cfg = load_lbm_config(lbm_config_path)
@@ -59,9 +59,7 @@ class Simulation:
try:
with self.ctx.activate():
arch = self._resolve_compile_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._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch)
self._compiled_n_objects = 0
self._allocation_signature = self.lbm_cfg.allocation_signature()
self._compiled_viscosity = self.lbm_cfg.viscosity
@@ -92,6 +90,7 @@ class Simulation:
@context_bound
def stream(self) -> cuda.Stream:
"""Internal CUDA stream for async DTOH coordination."""
self._require_usable()
if self._stream is None:
self._stream = cuda.Stream()
return self._stream
@@ -175,15 +174,27 @@ class Simulation:
def set_jet_amplitude(self, index: int, amplitude: float) -> None:
"""Change one jet's signed amplitude; re-uploads per-link velocity only.
Invalid input changes nothing. A device error leaves the host amplitude
unchanged but the device column undefined: treat it like any other CUDA
exception here and close/recreate the Simulation before stepping again.
Invalid input raises before any change. Any error after validation
(stream creation or upload) leaves the host amplitude unchanged but the
device column undefined, so the Simulation is marked failed: every later
device-touching public method (incl. ``stream``) raises; close() still
works. Close and recreate it.
"""
self._require_usable()
jet = self.bodies.jets[index].with_amplitude(amplitude)
if self._initialized:
self.field.curved.set_jet_amplitude(index, amplitude, self.stream)
try:
self.field.curved.set_jet_amplitude(index, amplitude, self.stream)
except BaseException as exc:
self._failed = f"jet amplitude upload failed: {exc!r}"
raise
self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude
def _require_usable(self):
failed = getattr(self, "_failed", None)
if failed is not None:
raise RuntimeError(f"Simulation failed ({failed}); close() and recreate it")
def remove_body(self, id: int) -> None:
"""Remove a body from the simulation.
@@ -341,6 +352,7 @@ class Simulation:
Unlike read_body this is not accumulated/time-averaged. It resets after
geometry sync or checkpoint load; advance before interpreting it.
"""
self._require_usable()
obj = self.bodies.get(id)
curved = self.field.curved
has_aperture = (curved.aperture is not None
@@ -368,6 +380,7 @@ class Simulation:
BodyTelemetry with fields ``force``, ``torque``, ``sensor``.
``sensor`` is a dim-length array of zeros for non-sensor bodies.
"""
self._require_usable()
if self.bodies.obs_pinned is None:
raise RuntimeError("No obs buffer. Call run() first.")
if stream is None:
@@ -387,6 +400,7 @@ class Simulation:
This is the most efficient readback for DRL control loops
(single contiguous D2H copy, no Python per-body overhead).
"""
self._require_usable()
if self.bodies.obs_pinned is None:
raise RuntimeError("No obs buffer. Call run() first.")
if stream is None:
@@ -412,15 +426,8 @@ class Simulation:
self.lbm_cfg.validate_runtime()
self._assert_allocation_contract()
arch = self._resolve_compile_arch()
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._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch,
n_objects=expected_n_objects)
self._compiled_n_objects = int(expected_n_objects)
# Reconnect field and stepper to new module
@@ -438,7 +445,6 @@ 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.
@@ -457,6 +463,7 @@ class Simulation:
@context_bound
def initialize(self):
"""Initialize flow field and sync objects to GPU."""
self._require_usable()
self._assert_allocation_contract()
# Validate the complete geometry before flags, populations or modules
# change. Compact-list construction publishes no outputs on failure.
@@ -489,6 +496,7 @@ class Simulation:
In EsoPull mode, the backing layout is decoded to physical DDF on
GPU for the patched cells only, patched on host, then encoded back.
"""
self._require_usable()
if not self._initialized:
raise RuntimeError("Call initialize() before sync_bodies()")
@@ -584,6 +592,7 @@ class Simulation:
sensor) on GPU and reset the step accumulator before stepping.
checkpoint_interval: Nonnegative integer; if >0, save every N steps.
"""
self._require_usable()
if not self._initialized:
raise RuntimeError("Call initialize() first")
steps = operator.index(steps)
@@ -648,10 +657,12 @@ class Simulation:
@context_bound
def get_macroscopic(self) -> Dict[str, np.ndarray]:
"""Download DDF and return rho, ux, uy [, uz]."""
self._require_usable()
return self.field.get_macroscopic(step_id=self.stepper.step_count)
@context_bound
def get_ddf(self) -> np.ndarray:
self._require_usable()
self.field.download_ddf(step_id=self.stepper.step_count)
return self.field.ddf.copy()
@@ -665,23 +676,27 @@ class Simulation:
Accepted: omega, fx, fy, fz, n_objects.
"""
self._require_usable()
self.field.update_params(**kwargs)
# -- Snapshots -----------------------------------------------------------
@context_bound
def snapshot(self):
"""Snapshot the flow field; restore keeps the current clock and controls."""
self._require_usable()
self.field.snapshot(step_id=self.stepper.step_count)
@context_bound
def restore(self):
"""Restore the saved flow field at the current streaming parity."""
self._require_usable()
self.field.restore(step_id=self.stepper.step_count)
# -- Checkpoint / Restore (HDF5) ----------------------------------------
@context_bound
def save_checkpoint(self, path: str = None):
"""Save full simulation state to an HDF5 file."""
self._require_usable()
from .common.checkpoint import save_checkpoint
if self.bodies.jets:
raise NotImplementedError("Jets are not checkpointed yet")
@@ -691,6 +706,7 @@ class Simulation:
@context_bound
def load_checkpoint(self, path: str):
"""Restore simulation state from an HDF5 checkpoint."""
self._require_usable()
from .common.checkpoint import load_checkpoint
if not self._initialized:
self.initialize()
@@ -715,8 +731,6 @@ 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):
+53
View File
@@ -0,0 +1,53 @@
"""Direct Simulation Jet control upload; boundary-law accuracy is tested elsewhere."""
import numpy as np
import pycuda.driver as cuda
from CelerisLab.body.jets import Jet, Radial, Sector
from CelerisLab.lbm.descriptors import FLUID
from ._cases import make_sim
def test_simulation_jet_amplitude_upload_and_continued_steps():
# Same grid/circle/sector as the existing uniform surface-actuation fixture.
center = (16., 12.)
with make_sim(nx=32, ny=24, velocity=0) as sim:
sim.add_body("circle", center=center, radius=4.3)
index = sim.add_jet(Jet(Sector(center, .7, .85, 4.4),
Radial(center, inward=False), "quartic", .002))
sim.initialize()
curved = sim.field.curved
shape, jet_id = curved.jet_shape.copy(), curved.jet_id.copy()
flags = sim.get_flags()
driven = jet_id == index
assert driven.any() and (~driven).any()
velocity_pointer = int(curved.surface_velocity_gpu)
previous = None
for interval, amplitude in enumerate((.002, -.001)):
if interval:
count = sim.stepper.step_count
sim.set_jet_amplitude(index, amplitude)
assert sim.stepper.step_count == count # update does not step
expected = np.zeros_like(curved.surface_velocity)
expected[driven] = (amplitude * shape[driven]).astype(np.float32)
actual = np.empty_like(expected)
with sim.ctx.activate():
cuda.memcpy_dtoh(actual, curved.surface_velocity_gpu)
np.testing.assert_array_equal(actual, expected) # actual device column
np.testing.assert_array_equal(curved.surface_velocity, expected)
assert np.any(actual[driven] != 0)
if previous is not None:
assert not np.array_equal(actual[driven], previous[driven])
previous = actual.copy()
assert sim.bodies.jets[index].amplitude == amplitude
assert curved.jets[index].amplitude == amplitude
assert int(curved.surface_velocity_gpu) == velocity_pointer
np.testing.assert_array_equal(curved.jet_shape, shape)
np.testing.assert_array_equal(curved.jet_id, jet_id)
np.testing.assert_array_equal(sim.get_flags(), flags)
sim.run(2)
assert sim.stepper.step_count == 2 * (interval + 1)
assert sim._failed is None
macro = sim.get_macroscopic()
fluid = (flags.reshape(24, 32) & FLUID) != 0
assert all(np.isfinite(value[fluid]).all() for value in macro.values())
assert np.all(macro["rho"][fluid] > 0)
@@ -6,14 +6,14 @@ import pycuda.driver as cuda
import pytest
import CelerisLab
from CelerisLab.cuda import compiler_v2
from CelerisLab.cuda import compiler
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_v2.kernel_path("kernel_v2.cu")).resolve() == root / "lbm/kernels/kernel_v2.cu"
assert Path(compiler.kernel_path("uniform.cu")).resolve() == root / "lbm/kernels/uniform.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._build_dir != second._build_dir
assert first._module is not second._module
assert cuda.Context.get_current() == before
first.initialize()
second.initialize()
+2 -1
View File
@@ -16,6 +16,7 @@
#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
@@ -40,7 +41,7 @@ inline float __fdiv_rn(float a,float b){return a/b;}
#include "boundary/inlet_outlet.cuh"
#include "step/one_step_double.cu"
#else
constexpr unsigned char KIND_OUTER=2;
#include "core/cell_kind.cuh"
#include "step/block_channel.cu"
#endif
int main(){
+46
View File
@@ -0,0 +1,46 @@
"""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
+29 -5
View File
@@ -83,6 +83,32 @@ 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])
@@ -100,8 +126,7 @@ class BuildContracts(unittest.TestCase):
def work(nx, count):
build = compiler.generate_config(LBMConfig(nx=nx), count, build_root=root)
barrier.wait()
# Compatibility form is context-local even without explicit path.
output = compiler.compile_kernel()
output = compiler.compile_kernel(build_dir=build)
data = json.loads(Path(output).read_text())
self.assertEqual(data, {"nx": nx, "objects": count})
metadata = json.loads(Path(output + ".json").read_text())
@@ -130,7 +155,7 @@ class BuildContracts(unittest.TestCase):
compiler.cleanup_build_dir(a)
compiler.cleanup_build_dir(b)
with self.assertRaises(RuntimeError):
compiler.compile_kernel()
compiler.compile_kernel(build_dir=a) # removed snapshot is never compiled
def test_failed_compile_cannot_implicitly_load_previous_artifact(self):
with tempfile.TemporaryDirectory() as root:
@@ -143,8 +168,7 @@ class BuildContracts(unittest.TestCase):
with self.assertRaises(subprocess.CalledProcessError):
compiler.compile_kernel(build_dir=build)
self.assertEqual(Path(previous).read_bytes(), old_bytes)
with self.assertRaisesRegex(RuntimeError, "No compiled PTX"):
compiler.load_module()
self.assertEqual(sorted(Path(build).glob("uniform-*.ptx")), []) # no partial output
compiler.cleanup_build_dir(build)
+8 -7
View File
@@ -7,7 +7,7 @@ from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.channel import channel_masks, validate_channel_config, validate_channel_transfers
from CelerisLab.lbm.refinement import FixedPatch2D
from CelerisLab.lbm.refinement_tables import blocked_base_tables
from CelerisLab.lbm.refinement_tables import blocked_coarse_tables
from .test_numerical_operators import compile_run
@@ -31,7 +31,7 @@ def transfer(bounds, block=(4, 6)):
cfg = LBMConfig(nx=24, ny=20, viscosity=.1)
pair = FixedPatch2D.from_config(cfg, bounds)
layout = BlockLayout2D.build(20, 24, *block)
targets = blocked_base_tables(pair, layout, np.argsort(pair.coalesce_coarse))[1]
targets = blocked_coarse_tables(pair, layout, np.argsort(pair.coalesce_coarse))[1]
return pair, layout, targets
@@ -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(layout.owner[5, -1]-1, 2),
for target in (layout.slot(int(layout.owner[5, -1]) - 1, 2), # integer under NumPy 1 and 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.load_module.return_value.get_global.side_effect = lambda name: (name, 12 if name == "d_block_channel" else 20)
host.compiler.build_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, (6, 6, 18, 14), context=context,
coarse_boundary="channel", base_block=(4, 6), fine_block=(4, 4))
backend = host.FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
refinements=[(6, 6, 18, 14)], level_blocks=[(4, 6), (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,6 +96,7 @@ 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.load_module.return_value.get_function("BlockChannelStep")
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]
finally:
backend.close()
-54
View File
@@ -75,59 +75,5 @@ 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()
+70
View File
@@ -0,0 +1,70 @@
"""The public examples run all-blocked: their level tiles must reach the device unchanged.
Host only: a context whose activation raises proves construction (layouts, tables,
boundary checks) succeeded up to the first CUDA call.
"""
import ast
from dataclasses import replace
from pathlib import Path
import unittest
from unittest.mock import Mock
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
def literal(name, constant):
"""Module-level literal from an example, read without importing it (no pycuda)."""
tree = ast.parse((EXAMPLES / name).read_text())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
names = target.elts if isinstance(target, ast.Tuple) else [target]
for i, t in enumerate(names):
if isinstance(t, ast.Name) and t.id == constant:
value = node.value.elts[i] if isinstance(target, ast.Tuple) else node.value
return ast.literal_eval(value)
raise KeyError(constant)
def forbidden():
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
class ExampleTiles(unittest.TestCase):
def test_fixed_patch_jet_tiles(self):
bounds, blocks = literal("fixed_patch_jet.py", "BOUNDS"), literal("fixed_patch_jet.py", "LEVEL_BLOCKS")
width, amplitude = literal("fixed_patch_jet.py", "WIDTH"), literal("fixed_patch_jet.py", "AMPLITUDE")
cfg = LBMConfig(nx=48, ny=48, viscosity=.1, velocity=0., wall_slot_width=width,
wall_normal_velocity=amplitude, y_wall_bc="on_site_velocity",
top_wall_bc="reservoir")
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary="slot_reservoir",
refinements=[bounds], level_blocks=[tuple(b) for b in blocks])
def test_fixed_periodic_patch_tiles(self):
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1, velocity=1e-3), context=forbidden(),
refinements=[(4, 4, 12, 12)], level_blocks=[(16, 16), (24, 24)])
def test_fixed_patch_circle_default_tiles(self):
bounds = literal("fixed_patch_circle.py", "BOUNDS")
nx, ny = literal("fixed_patch_circle.py", "NX"), literal("fixed_patch_circle.py", "NY")
center, radius = literal("fixed_patch_circle.py", "CENTER"), literal("fixed_patch_circle.py", "RADIUS")
body = SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
periodic = LBMConfig(nx=nx, ny=ny, viscosity=.1)
slot = replace(periodic, velocity=0, wall_normal_velocity=.001, wall_slot_width=8,
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
for mode, cfg in (("periodic", periodic), ("slot_reservoir", slot)):
with self.subTest(mode=mode), self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary=mode,
refinements=[bounds], level_blocks=[(12, 8), (16, 20)],
level_objects=[(), (body,)])
if __name__ == "__main__":
unittest.main()
+6 -25
View File
@@ -8,6 +8,7 @@ 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,
@@ -19,6 +20,7 @@ from run_jet_circle_cv import (
cv_from_macros,
aperture_probe,
measured_aperture_flux,
PIN,
verify,
)
@@ -166,34 +168,13 @@ class Host(unittest.TestCase):
self.assertEqual(a["steps"] * 4, b["steps"])
def test_source_pin(self):
"""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)
from tempfile import TemporaryDirectory
from historical_tree import historical_tree
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)
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"])
with TemporaryDirectory() as directory:
verify(historical_tree(PIN, directory))
if __name__ == "__main__":
+149
View File
@@ -0,0 +1,149 @@
"""Jet amplitude update failure contract (host doubles only, no CUDA numerics).
Both owners validate before any device write, commit host state only after the
upload returns, and refuse every later call except close() after an uncertain
device write.
"""
from types import SimpleNamespace
import numpy as np
import pytest
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module
from CelerisLab.body.jets import Jet, Radial, Sector
from tests.unit.test_pinned_body_remap import ready
from tests.unit.test_simulation_lifecycle import make_simulation
BAD = (float("nan"), float("inf"), 1.)
class Curved:
"""Records uploads; ``fail`` raises once the write was attempted."""
def __init__(self):
self.writes, self.fail = [], False
def set_jet_amplitude(self, index, amplitude, stream):
self.writes.append((index, amplitude))
if self.fail:
raise RuntimeError("upload failed")
def simulation(monkeypatch):
sim = make_simulation(monkeypatch)
jet = Jet(Sector((6., 5.), 0., .5, 4.), Radial((6., 5.)), "uniform", 1e-3)
sim._failed, sim._initialized = None, True
sim.bodies, sim.field = SimpleNamespace(jets=[jet]), SimpleNamespace(curved=Curved())
return sim, jet
def test_simulation_invalid_input_writes_nothing_and_stays_usable(monkeypatch):
sim, jet = simulation(monkeypatch)
for amplitude in BAD:
with pytest.raises(ValueError):
sim.set_jet_amplitude(0, amplitude)
with pytest.raises(IndexError):
sim.set_jet_amplitude(1, 2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0] is jet and sim._failed is None
sim.set_jet_amplitude(0, 2e-3) # retry after a rejected input
assert sim.field.curved.writes == [(0, 2e-3)] and sim.bodies.jets[0].amplitude == 2e-3
sim.close()
def test_simulation_upload_failure_keeps_host_and_blocks_reuse(monkeypatch):
sim, jet = simulation(monkeypatch)
sim.field.curved.fail = True
with pytest.raises(RuntimeError, match="upload failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
sim.field.curved.fail = False
for call in (lambda: sim.set_jet_amplitude(0, 2e-3), lambda: sim.run(1), sim.step,
sim.sync_bodies, sim.initialize, sim.get_ddf, sim.get_macroscopic,
lambda: sim.read_surface_exchange(0), lambda: sim.read_body(0),
sim.read_bodies, sim.snapshot, sim.restore, sim.save_checkpoint,
lambda: sim.load_checkpoint("x.h5"), lambda: sim.update_runtime_params(omega=1.)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert sim.field.curved.writes == [(0, 2e-3)]
sim.close() # cleanup still works
assert sim._closed and sim.ctx.is_closed
def test_simulation_stream_creation_failure_fails_closed(monkeypatch):
sim, jet = simulation(monkeypatch)
created = []
def stream():
created.append(1)
raise RuntimeError("stream creation failed")
monkeypatch.setattr(module.cuda, "Stream", stream, raising=False)
sim._stream = None
with pytest.raises(RuntimeError, match="stream creation failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
assert sim.field.curved.writes == [] and created == [1]
for call in (lambda: sim.stream, lambda: sim.set_jet_amplitude(0, 2e-3)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert created == [1] # the public stream property does not retry creation
sim.ctx.detach_failure = True
with pytest.raises(RuntimeError, match="detach failed"):
sim.close()
assert not sim._closed
sim.close() # close stays retryable after the failure
assert sim._closed and sim.ctx.is_closed
def test_simulation_before_initialize_commits_host_only(monkeypatch):
sim, _ = simulation(monkeypatch)
sim._initialized = False
sim.set_jet_amplitude(0, -2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0].amplitude == -2e-3
sim.close()
def stepper_state(run):
boundary = run._device_boundaries[2]
return boundary.tables["surface_velocity"].copy(), boundary.jets, run._level_jets[2]
def test_stepper_invalid_input_writes_nothing_and_stays_ready():
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
module.cuda.memcpy_htod.reset_mock()
for args, kw, error in [((0, a), {"level": 2}, ValueError) for a in BAD] + [
((1, .01), {"level": 2}, IndexError), ((0, .01), {"level": 3}, ValueError)]:
with pytest.raises(error):
run.set_jet_amplitude(*args, **kw)
module.cuda.memcpy_htod.assert_not_called()
np.testing.assert_array_equal(run._device_boundaries[2].tables["surface_velocity"], velocity)
assert (run._device_boundaries[2].jets, run._level_jets[2]) == (jets, level_jets)
assert run._status == "ready"
run.set_jet_amplitude(0, .017, level=2)
assert run._level_jets[2][0].amplitude == .017
finally:
run.close()
@pytest.mark.parametrize("where", ["upload", "synchronize"])
def test_stepper_device_failure_keeps_host_and_blocks_reuse(where):
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
if where == "upload":
module.cuda.memcpy_htod.side_effect = RuntimeError("upload failed")
else:
run._stream.synchronize.side_effect = RuntimeError("upload failed")
with pytest.raises(RuntimeError, match="upload failed"):
run.set_jet_amplitude(0, .017, level=2)
np.testing.assert_array_equal(run._device_boundaries[2].tables["surface_velocity"], velocity)
assert (run._device_boundaries[2].jets, run._level_jets[2]) == (jets, level_jets)
assert run._status == "failed"
module.cuda.memcpy_htod.side_effect = None
run._stream.synchronize.side_effect = None
for call in (lambda: run.set_jet_amplitude(0, .017, level=2), run.step, run.download):
with pytest.raises(RuntimeError, match="failed"):
call()
finally:
run.close()
assert run._status == "closed"
+2
View File
@@ -194,6 +194,7 @@ def test_public_surface_exchange_for_jet_preserves_aperture_at_zero_and_reverse(
field, bodies = jet_case(device, [jet])
sim = SimpleNamespace(bodies=bodies, field=field, _initialized=True,
stream=SimpleNamespace(synchronize=device.synchronize))
sim._require_usable = Simulation._require_usable.__get__(sim)
cl = field.curved
assert bodies.get(0).surface_actuation is None
aperture, velocity = cl.aperture, cl.surface_velocity.copy()
@@ -218,6 +219,7 @@ def test_public_surface_exchange_keeps_legacy_and_passive_guards(device):
bodies.sync_to_gpu(field)
sim = SimpleNamespace(bodies=bodies, field=field,
stream=SimpleNamespace(synchronize=device.synchronize))
sim._require_usable = Simulation._require_usable.__get__(sim)
for value in Simulation.read_surface_exchange(sim, 0).values():
np.testing.assert_array_equal(value, 0.)
field.curved.free_gpu_columns()
+36 -47
View File
@@ -13,15 +13,12 @@ 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]
A135252 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
sys.path.insert(0, str(ROOT / "tests/unit"))
from historical_tree import historical_tree, tracked
def a135252_pins():
return json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
A135 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
class GuardTests(unittest.TestCase):
@@ -48,35 +45,24 @@ class GuardTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "velocity/profile"):
decode_macros(invalid, w, cx, cy)
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):
def test_current_corrected_pins(self):
with TemporaryDirectory() as directory:
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")
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")
def test_corrected_and_tamper_guard(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory:
root = Path(directory)
self.materialize(root)
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)
with patch(
"run_uniform_les_flow.subprocess.check_output",
return_value="isolated-fixture\n",
@@ -107,25 +93,28 @@ class GuardTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, "corrected") # Omission still uses the historical profile.
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_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_named_baseline_accepts_only_reversed_header(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
with TemporaryDirectory() as directory:
root = Path(directory)
self.materialize(root)
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)
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/"kernel_v2.cu"),"-o",str(tmp_path/"kernel.ptx")],
str(copy/"uniform.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_v2 as compiler
from CelerisLab.cuda import 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('kernel_v2.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/kernel_v2.cu'
assert Path(compiler.kernel_path('uniform.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/uniform.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/blocks.cu").read_text()
"src/CelerisLab/lbm/kernels/step/block_transfer.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):
+3 -5
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.load_module.side_effect = load
host.compiler.build_module.side_effect = lambda *a, **k: load(None)
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)
@@ -146,7 +146,6 @@ class ProjectedPairDispatch(unittest.TestCase):
for cfg, changes in [(CFG, {'explosion':'unknown'}),
(CFG, {'coarse_boundary':'slot_reservoir'}),
(CFG, {'coarse_boundary':'channel'}),
(CFG, {'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}),
(CFG, {'level_objects':[(),(object(),)]}),
(CFG, {'level_jets':[(),(object(),)]}),
(replace(CFG,collision='MRT'), {}),
@@ -155,9 +154,8 @@ class ProjectedPairDispatch(unittest.TestCase):
host.FixedPatchStepper(cfg, **dict(options, **changes))
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
# The legacy constant path still allows MRT and dense storage.
for options in ({'cfg':replace(CFG,collision='MRT')},
{'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}):
# The constant path still allows MRT.
for options in ({'cfg':replace(CFG,collision='MRT')},):
run, _, _, _, _ = backend(**options)
run.close()
+3 -11
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.load_module.return_value = module
host.compiler.build_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')
@@ -85,7 +85,7 @@ class RawExchange(unittest.TestCase):
def test_four_level_schedule_publication_and_fixed_storage(self):
run, host, calls, arrays = backend()
b = run._level_boundaries[3]
b = run._device_boundaries[3]
rows = len(b.tables['cell'])
self.assertEqual(b.last_raw.size, rows * 16)
self.assertEqual(b.interval_integral.size, rows * 32)
@@ -128,7 +128,7 @@ class RawExchange(unittest.TestCase):
def test_disabled_has_nulls_and_no_observation_allocation_or_launch(self):
run, host, calls, _ = backend(False)
b = run._level_boundaries[3]
b = run._device_boundaries[3]
self.assertIs(b.last_raw, b.interval_integral)
self.assertIs(b.interval_integral, None)
run.step()
@@ -153,14 +153,6 @@ class RawExchange(unittest.TestCase):
run._stream.synchronize.side_effect = None
run.close()
def test_dense_observation_rejected_before_device_access(self):
host = host_module()
ctx = Mock()
with self.assertRaisesRegex(ValueError, 'blocked body'):
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):
for level in (1, 2, 3, 7):
self._check_weights_split_and_window(level)
+7 -21
View File
@@ -5,6 +5,7 @@ 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
@@ -62,31 +63,16 @@ def test_periodic_synthetic():
assert not p.assess(rows()[:100])["periodic"]
def test_manifest_matches_its_pinned_commit():
"""Historical integrity only: the manifest describes the h.PIN tree (read with git)."""
def test_production_unmodified(tmp_path):
from historical_tree import historical_tree
m = json.loads(
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text()
)
assert m["commit"] == h.PIN
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
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
def test_reference():
+45 -31
View File
@@ -7,38 +7,37 @@ 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 = historical_manifest("SRT")
historical = json.loads((r.HERE / "data/refined_dfg/source_manifest.json").read_text())
assert historical["commit"].startswith("cd3a657")
mrt_history = historical_manifest("MRT")
mrt_history = json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text())
assert mrt_history["commit"].startswith("3ea131e")
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)
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
def test_matched_units_and_planes():
@@ -158,17 +157,32 @@ 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, historical_manifest("MRT", "window"))
r.verify_pilot(None, frozen_manifest("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."""
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()))
# 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))
selection = dict(case_selection=case_selection, uniform_diameter=uniform_diameter, placement=placement)
(path / "plan.json").write_text(
json.dumps(
@@ -366,7 +380,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 historical_manifest("MRT", "window")["production_origin_commit"].startswith("d930")
assert selected_manifest("MRT", "window")["production_origin_commit"].startswith("d930")
def test_window_plan_preserves_physics():
@@ -376,7 +390,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 historical_manifest(collision, "window")["observation_mode"] == "window"
assert selected_manifest(collision, "window")["observation_mode"] == "window"
with unittest.TestCase().assertRaises(ValueError):
r.verify_source(r.DEFAULT_SOURCE, "MRT", "unknown")
+14 -8
View File
@@ -22,23 +22,29 @@ 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 / "blocks.cu").read_text()
blocks = (KERNELS / "blocked.cu").read_text() + (KERNELS / "step/block_step.cu").read_text()
channel = (KERNELS / "step/block_channel.cu").read_text()
refinement = (KERNELS / "refinement.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)
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, blocks)
self.assertIn(text, block_collision)
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):
for source in (blocks,):
self.assertIn("(COLLISION_MODEL != 0 && COLLISION_MODEL != 2)", source)
self.assertIn("USE_LES", source)
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
for source in (blocks, channel, refinement):
self.assertEqual(source.count("collide_mrt_no_force("), 1)
# 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):
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")
if __name__ == "__main__": unittest.main()
@@ -22,12 +22,13 @@ 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','block_stepper','curved_links'))]
'cuda','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(),MAX_GRID_DIM=65535,
module.__dict__.update(cuda=Mock(),compiler=Mock(),
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
@@ -140,7 +141,7 @@ class Gate(unittest.TestCase):
def test_sparse_parent_tables_preserve_inherited_kinds(self):
child,kind=r.nest_patch_set(CFG,self.parent,CHILDREN)
layout=self.parent.fine_layout((4,4));order=np.argsort(child.coalesce_coarse)
donors,targets,bkind=host.blocked_base_tables(child,layout,order,composed_kind=kind)
donors,targets,bkind=host.blocked_coarse_tables(child,layout,order,composed_kind=kind)
self.assertTrue((donors < layout.hole).all())
self.assertTrue((targets < np.prod(layout.blocked_shape)).all())
np.testing.assert_array_equal(bkind[layout.owner[layout.owned]],kind[layout.owned])
@@ -157,10 +158,10 @@ class Gate(unittest.TestCase):
hole=int(np.flatnonzero(~layout.owned.ravel())[0])
bad=child.ghost_coarse.copy();bad[0]=hole
with self.assertRaisesRegex(ValueError,'owned uncovered'):
host.blocked_base_tables(replace(child,ghost_coarse=bad),layout,order,composed_kind=kind)
host.blocked_coarse_tables(replace(child,ghost_coarse=bad),layout,order,composed_kind=kind)
bad=child.coalesce_coarse.copy();bad[0]=hole
with self.assertRaisesRegex(ValueError,'owned uncovered'):
host.blocked_base_tables(replace(child,coalesce_coarse=bad),layout,order,composed_kind=kind)
host.blocked_coarse_tables(replace(child,coalesce_coarse=bad),layout,order,composed_kind=kind)
def bodies_and_jets(speed=.002):
@@ -173,8 +174,10 @@ def bodies_and_jets(speed=.002):
return bodies,jets
KW=dict(base_block=(4,4),fine_block=(4,4),child_block=(4,4),
child_rectangles_in_parent=CHILDREN)
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))
def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_upload=False):
@@ -191,9 +194,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.load_module.side_effect=load
module.compiler.build_module.side_effect=lambda *a,**k: load(None)
if fail_compile:
module.compiler.compile_kernel.side_effect=['first','second',RuntimeError('L2 compile failed')]
module.compiler.build_module.side_effect=[load(None),load(None),RuntimeError('L2 compile failed')]
if fail_upload:
def upload(*args):
if len(allocations)>20:
@@ -201,9 +204,8 @@ 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,PARENTS,context=context,
child_objects=objects,child_jets=jets,
**dict(KW,child_rectangles_in_parent=children))
run=module.FixedPatchStepper(CFG,context=context,
**levels(children=children,objects=((),(),objects),jets=((),(),jets)))
except RuntimeError:
for allocation in allocations:
allocation.free.assert_called_once_with()
@@ -214,28 +216,29 @@ 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()
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))]
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))]
for changes in cases:
with self.subTest(changes=changes),self.assertRaises((ValueError,TypeError)):
host.FixedPatchStepper(CFG,PARENTS,context=context,**dict(KW,**changes))
host.FixedPatchStepper(CFG,context=context,**levels(**changes))
with self.assertRaisesRegex(ValueError,'margin'):
host.FixedPatchStepper(CFG,[(1,4,20,28),(23,4,44,28)],context=context,**KW)
host.FixedPatchStepper(CFG,context=context,**levels(parents=[(1,4,20,28),(23,4,44,28)]))
objects,jets=bodies_and_jets()
with self.assertRaisesRegex(ValueError,'only on L2'):
host.FixedPatchStepper(CFG,PARENTS,context=context,fine_objects=objects,**KW)
with self.assertRaisesRegex(ValueError,'only on the finest level'):
host.FixedPatchStepper(CFG,context=context,**levels(objects=((),objects,())))
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,PARENTS,context=context,child_objects=objects,
child_jets=[spanning],**KW)
host.FixedPatchStepper(CFG,context=context,
**levels(objects=((),(),objects),jets=((),(),[spanning])))
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
@@ -313,21 +316,23 @@ class ConstructorAndSchedule(unittest.TestCase):
class InitialLevels(unittest.TestCase):
"""initialize() entry adapter: positional per-level arrays or the legacy names, never both."""
class RemovedLegacyAPI(unittest.TestCase):
"""The pre-level-list constructor/initialize forms are gone and fail loudly."""
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)
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
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, 'compile_kernel', side_effect=RuntimeError('candidate compile'))
operation = patch.object(module.compiler, 'build_module', side_effect=RuntimeError('candidate compile'))
else:
initialize = type(run).initialize
def fail(candidate, *arrays):
+10 -11
View File
@@ -54,21 +54,20 @@ 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,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)
module.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
level_blocks=[(4,4)]*3,level_objects=[(),(),objects],level_jets=[(),(),jets])
for blocks in ((None,None,None),((4,4),None,(4,4))):
with self.assertRaises(ValueError):
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.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
level_blocks=list(blocks),level_objects=[(),(),(body(),)],
level_jets=[(),(),(jet(),)])
module.cuda.mem_alloc.assert_not_called()
module.compiler.compile_kernel.assert_not_called()
module.compiler.build_module.assert_not_called()
def test_switch_upload_only_then_raw_remap_keeps_live_velocity(self):
run,module,allocations,storage,objects=ready();clone=None
try:
boundary=run._level_boundaries[2]
boundary=run._device_boundaries[2]
before=run._download_raw()
original=boundary.tables['surface_velocity'].copy()
module.cuda.memcpy_htod.reset_mock()
@@ -80,7 +79,7 @@ class ShiftedJets(unittest.TestCase):
for a,b in zip(before.values,run._download_raw().values):self.assertEqual(a.tobytes(),b.tobytes())
clone=run.remap_child(B,child_objects=objects)
self.assertEqual(clone._level_jets[2][0].amplitude,-jet().amplitude)
np.testing.assert_array_equal(clone._level_boundaries[2].tables['surface_velocity'],
np.testing.assert_array_equal(clone._device_boundaries[2].tables['surface_velocity'],
boundary.tables['surface_velocity'])
self.assertEqual(clone.step_count,7)
saved=run._download_raw()
@@ -93,12 +92,12 @@ class ShiftedJets(unittest.TestCase):
def test_invalid_amplitude_does_not_upload_or_commit(self):
run,module,_,_,_=ready()
try:
before=run._level_boundaries[2].tables['surface_velocity'].copy()
before=run._device_boundaries[2].tables['surface_velocity'].copy()
module.cuda.memcpy_htod.reset_mock()
for amplitude in (float('nan'),float('inf'),1.):
with self.assertRaises(ValueError):run.set_jet_amplitude(0,amplitude,level=2)
module.cuda.memcpy_htod.assert_not_called()
np.testing.assert_array_equal(before,run._level_boundaries[2].tables['surface_velocity'])
np.testing.assert_array_equal(before,run._device_boundaries[2].tables['surface_velocity'])
finally:run.close()
+16 -25
View File
@@ -1,7 +1,4 @@
"""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
@@ -78,13 +75,15 @@ 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.nan).repeat(9,axis=0) for m in old.leaf_masks)
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
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.nan).repeat(9,axis=0) for m in old.leaf_masks)
rest = tuple(np.where(m[None],np.float32(0),np.float32(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')
@@ -126,33 +125,24 @@ class ShiftedStorage(unittest.TestCase):
with self.assertRaisesRegex(ValueError,'mixed'): run._download_raw()
finally: run.close()
def test_dense_or_mixed_shifted_rejected_before_compilation(self):
def test_missing_tiles_rejected_before_compilation(self):
module=backend.host_module()
cfg=replace(backend.CFG,ddf_shifting=True)
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()
for blocks in ([None,None],[(4,4),None],[None,(4,4)]):
with self.assertRaisesRegex(ValueError,'must be a tile'):
module.FixedPatchStepper(cfg,context=None,refinements=[(4,4,20,28)],
level_blocks=blocks)
module.compiler.build_module.assert_not_called()
def test_uniform_block_codec_and_raw_snapshot(self):
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)
module = backend.host_module() # single blocked level: FixedPatchStepper(refinements=[])
cuda, compiler = module.cuda, module.compiler
cuda.mem_alloc.side_effect=lambda size: Mock()
gpu=Mock();gpu.get_global.return_value=(Mock(),20)
compiler.load_module.return_value=gpu
compiler.build_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.PeriodicBlockStepper(cfg,layout,context=context)
run=module.FixedPatchStepper(cfg,context=context,refinements=[],level_blocks=[(4,4)])
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)])
@@ -163,7 +153,8 @@ 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())
np.testing.assert_array_equal(run.download(),physical)
(down,)=run.download()
np.testing.assert_array_equal(down,physical)
finally: run.close()
def test_passive_shifted_body_raw_remap_preserves_support(self):
+2 -9
View File
@@ -36,12 +36,10 @@ 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
@@ -57,7 +55,7 @@ def test_close_inside_activation_preserves_resources(monkeypatch):
assert sim._closed and sim.ctx.is_closed
@pytest.mark.parametrize("failure", ["synchronize", "detach", "directory"])
@pytest.mark.parametrize("failure", ["synchronize", "detach"])
def test_close_failure_is_retryable(monkeypatch, failure):
sim = make_simulation(monkeypatch)
if failure == "detach":
@@ -68,17 +66,12 @@ def test_close_failure_is_retryable(monkeypatch, failure):
calls.append(args)
if len(calls) == 1:
raise RuntimeError(failure + " failed")
if failure == "synchronize":
monkeypatch.setattr(module.cuda.Context, "synchronize", fail_once)
else:
monkeypatch.setattr(module.compiler, "cleanup_build_dir", fail_once)
monkeypatch.setattr(module.cuda.Context, "synchronize", 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
@@ -0,0 +1,218 @@
"""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._device_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()
+1
View File
@@ -295,6 +295,7 @@ def test_passive_overlap_failure_keeps_live_state(device, entry, second_x):
_initialized=entry == "runtime_edit",
_assert_allocation_contract=lambda: None,
stream=SimpleNamespace(synchronize=device.synchronize))
sim._require_usable = Simulation._require_usable.__get__(sim)
with pytest.raises(ValueError, match="active fluid receivers|unique cut-link writers"):
if entry == "sync":
bodies.sync_to_gpu(field)
+170
View File
@@ -0,0 +1,170 @@
"""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()
+17 -50
View File
@@ -8,19 +8,19 @@ import sys
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests/validation"))
import pytest
from source_provenance import (HistoryUnavailable, checkout_provenance, historical_sha256,
historical_tree, history_unavailable, verify_manifest)
import unittest
from source_provenance import checkout_provenance
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_match_their_recorded_source_and_preserve_origin():
"""Historical evidence integrity: each manifest describes the tree of its own commit."""
manifests = []
def test_manifests_cover_current_source_and_preserve_origin(tmp_path):
assert re100_helpers.PIN == run_jet_circle_cv.PIN == ORIGIN
manifests = {}
for group, hashes_key, revision_key in (
("dfg_re100", "production_sha256", "commit"),
("jet_circle_cv", "files", "revision"),
@@ -30,7 +30,6 @@ def test_manifests_match_their_recorded_source_and_preserve_origin():
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 (
@@ -38,15 +37,16 @@ def test_manifests_match_their_recorded_source_and_preserve_origin():
== "d79e3f6f0a424f5bcc3a1a6d610313e49daf97a2"
)
assert "not inherited from d79" in manifest["experiment"]
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
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
def test_runtime_checkout_identity_is_separate_from_source_origin(tmp_path):
@@ -78,36 +78,3 @@ 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()
@@ -30,7 +30,7 @@ original_module = gate.compile_module
cache = {}
def retain_compile(*args, **kwargs):
result = original_compile(*args, **kwargs)
source = kwargs.get('source', 'kernel_v2.cu')
source = kwargs.get('source', 'uniform.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'))
+50 -117
View File
@@ -1,4 +1,4 @@
"""Periodic body-free block backend: CPU contract doubles plus one short GPU case.
"""Single blocked level, FixedPatchStepper(refinements=[]): CPU contracts plus short GPU cases.
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,9 +11,8 @@ 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 MagicMock, Mock, patch
from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
@@ -21,10 +20,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_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.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.lbm.descriptors import D2Q9_EX, D2Q9_EY
ROOT = Path(__file__).resolve().parents[3]
@@ -112,72 +111,78 @@ 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 and lifecycle."""
"""No device: driver doubles prove rejection before allocation."""
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):
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())
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)
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):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(), objects=objects)
single(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):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(),
objects=[circle(center, radius)])
with patch.object(implementation, "_static_circles") as prepare:
single(context=self.forbidden_context(), objects=[circle(center, radius)])
with patch.object(implementation, "static_circle_tables") as prepare:
with self.assertRaisesRegex(AssertionError, "CUDA reached"): # body-free path
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(), objects=iter(()))
single(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"):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context(),
objects=[circle()])
single(context=self.forbidden_context(), objects=[circle()])
with patch.object(implementation, "MAX_GRID_DIM", 3):
with self.assertRaisesRegex(ValueError, "launch limits"):
PeriodicBlockStepper(config(), layout, context=self.forbidden_context())
single(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.assertRaises(MemoryError):
PeriodicBlockStepper(config(), layout, context=context)
with self.assertRaisesRegex(MemoryError, "exceed free device memory"):
single(context=context, objects=[circle()])
stream.assert_not_called()
alloc.assert_not_called()
module = Mock()
module.get_global.return_value = (Mock(), 16)
with patch.object(cuda, "mem_get_info", return_value=(1 << 30, 1 << 30)), \
patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), 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"):
PeriodicBlockStepper(config(), layout, context=context)
self.assertEqual(compile_kernel.call_args.kwargs["source"], "blocks.cu")
single(context=context)
self.assertEqual(compile_kernel.call_args.kwargs["source"], "blocked.cu")
alloc.assert_not_called()
upload.assert_not_called()
def test_static_circle_tables(self):
layout = BlockLayout2D.build(NY, NX, 12, 16)
tables = implementation._static_circles(layout, (circle(),))
tables = static_circle_tables(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))
@@ -185,84 +190,11 @@ 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
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):
class SingleLevelGPU(unittest.TestCase):
def setUp(self):
self.assertEqual(Path(implementation.__file__).resolve(),
ROOT / "src/CelerisLab/lbm/block_stepper.py")
ROOT / "src/CelerisLab/lbm/refinement_stepper.py")
def tearDown(self):
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
@@ -273,13 +205,13 @@ class PeriodicBlockGPU(unittest.TestCase):
g0 = initial_state()
results = []
with CudaContext(0) as context:
for by, bx in SPLITS:
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context) as backend:
for split in SPLITS:
with single(context=context, block=split) as backend:
backend.initialize(g0)
backend.step(STEPS)
self.assertEqual(backend.step_count, STEPS)
results.append(backend.download())
(g,) = backend.download()
results.append(g)
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)}")
@@ -306,16 +238,17 @@ class PeriodicBlockGPU(unittest.TestCase):
g0 = initial_state()
results = []
with CudaContext(0) as context:
for by, bx in SPLITS:
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context, objects=[circle()]) as backend:
for split in SPLITS:
with single(context=context, block=split, objects=[circle()]) as backend:
tables = backend._device_boundaries[0].tables
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(backend.boundary[key], np.array(values, dtype))
np.testing.assert_array_equal(tables[key], np.array(values, dtype))
backend.initialize(g0)
backend.step(STEPS)
results.append(backend.download())
(g,) = backend.download()
results.append(g)
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_v2 as compiler
from CelerisLab.cuda import 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)
build = compiler.generate_config(cfg, block_root_bc=2 if source == "blocked.cu" else 0)
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, "kernel_v2.cu" if layout is None else "blocks.cu")
module = compile_module(local, context, "uniform.cu" if layout is None else "blocked.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,9 +106,10 @@ 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, (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:
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:
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"), BOUNDS, context=m["context"], base_block=(3, 4),
fine_block=(16, 20), fine_objects=[circle()],
return FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), (16, 20)], level_objects=[(), (circle(),)],
observe_raw_exchange=raw, observe_window_exchange=window)
@@ -59,7 +59,7 @@ class ExchangeWindowCPU(unittest.TestCase):
stack.enter_context(patch.object(cuda, "memcpy_dtoh",
side_effect=lambda out, src: out.fill(0)))
backend = build(m)
b = backend._level_boundaries[1]
b = backend._device_boundaries[1]
self.assertIsNotNone(b.window_integral)
with self.assertRaises(RuntimeError):
backend.start_exchange_window() # not initialized
@@ -96,7 +96,7 @@ class ExchangeWindowCPU(unittest.TestCase):
with mocked_backend() as m, ExitStack() as stack:
stack.enter_context(patch.object(cuda, "memset_d8_async"))
backend = build(m, window=False)
self.assertIsNone(backend._level_boundaries[1].window_integral)
self.assertIsNone(backend._device_boundaries[1].window_integral)
backend.initialize(*[np.full(l.shape, 1 / 9, np.float32) for l in backend._levels])
backend.step(1)
self.assertEqual(len(accumulations(m)), 2)
@@ -109,17 +109,18 @@ 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, fine_block=(16, 20), fine_objects=[rc()], jets=[jet()],
kw = dict(context=ctx, refinements=[RB], level_blocks=[(12, 8), (16, 20)],
level_objects=[(), (rc(),)], level_jets=[(), (jet(),)],
observe_raw_exchange=True)
with FixedPatchStepper(CFG, RB, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(CFG, RB, **kw) as plain:
with FixedPatchStepper(CFG, observe_window_exchange=True, **kw) as windowed, \
FixedPatchStepper(CFG, **kw) as plain:
arrays = [state(level.shape) for level in windowed._levels]
windowed.initialize(*arrays)
plain.initialize(*arrays)
windowed.step(2)
plain.step(2)
windowed.start_exchange_window()
b = windowed._level_boundaries[1]
b = windowed._device_boundaries[1]
count = len(b.tables["cell"])
factors = np.array([.25, .25, .125, .25]) # h*dt, h*h*dt, h*h for level-1 h=.5
scale = np.zeros(4)
@@ -200,18 +201,18 @@ 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, 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:
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:
arrays = [state(level.shape) for level in windowed._levels]
for run in (windowed, split, plain):
run.initialize(*arrays)
run.step(2)
if run is not plain:
run.start_exchange_window()
b = windowed._level_boundaries[1]
b = windowed._device_boundaries[1]
count = len(b.tables["cell"])
record = dict(scale=np.zeros(4), substeps=0)
audit_substeps(windowed, b, count, record)
@@ -227,7 +228,7 @@ class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
same_bits(self, a, e)
rows = [np.empty((count, 4), np.float64) for _ in range(2)]
cuda.memcpy_dtoh(rows[0], b.window_integral)
cuda.memcpy_dtoh(rows[1], split._level_boundaries[1].window_integral)
cuda.memcpy_dtoh(rows[1], split._device_boundaries[1].window_integral)
np.testing.assert_array_equal(rows[0].view(np.uint64), rows[1].view(np.uint64))
out = windowed.read_exchange_window(0)
@@ -1,13 +1,17 @@
"""Two coarse/four fine steps: isolated fine circle, no flux-accuracy claim.
"""Two coarse/four fine steps: isolated fine circle on blocked storage, no flux-accuracy claim.
Run only through the bounded GPU runner after a fresh resource check. The test
hooks read actual kernel inputs/outputs; they never change the stepping order.
Run only through the bounded GPU runner after a fresh resource check. The fine
step hook reads the actual output buffer; it never changes the stepping order.
Retired with the dense refinement route (accepted losses, see the D2 commit):
the dense-kernel per-hop flux ledger (fine/coarse transfer + boundary budget),
the CurvedLinkSoA reflection oracle and the CELERIS_REFINEMENT_AUDIT collision
counts. The blocked test keeps the four-hop comparison with the uniform solver,
the raw-exchange mass sign and passive == zero actuation; raw exchange covers
the boundary part only, not full population flux closure.
"""
import importlib.util
from contextlib import nullcontext
from pathlib import Path
from threading import RLock
from types import SimpleNamespace
from unittest.mock import Mock, patch
import numpy as np
@@ -18,7 +22,6 @@ 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_v2 as compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm import refinement_stepper as implementation
from CelerisLab.lbm.descriptors import FLUID, SOLID
@@ -27,10 +30,8 @@ from CelerisLab.lbm.descriptors import FLUID, SOLID
ROOT = Path(__file__).resolve().parents[3]
CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1])
CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1])
OPP = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7])
W = np.array([4/9]+[1/9]*4+[1/36]*4)
MOMENTS = np.stack((np.ones(9), CX, CY))
GEOMETRY = ("fluid_idx", "dir", "q", "body_id", "rx", "ry", "rz", "fallback_class")
BOUNDS = (4, 4, 20, 20)
@@ -53,196 +54,6 @@ def object_spec(speed):
radius=2.15, surface_actuation=surface(speed))
def audit_compiler(original):
def compile_private(*args, **kwargs):
entry = Path(kwargs["build_dir"])/"fine_surface_audit.cu"
entry.write_text('#define CELERIS_REFINEMENT_AUDIT\n#include "refinement.cu"\n')
kwargs["source"] = entry.name
return original(*args, **kwargs)
return compile_private
def reflection_oracle(before, cl, speed, *, verify_projection=True):
"""FP64 BFL from frozen spatial populations, independent arc projection."""
flat = before.astype(float).reshape(9, -1)
radius = np.hypot(cl.rx.astype(float), cl.ry.astype(float))
delta = (np.arctan2(cl.ry.astype(float), cl.rx.astype(float))-.7+np.pi) % (2*np.pi)-np.pi
amplitude = 0 if speed is None else speed
strength = amplitude*np.maximum(0, 1-(delta/.85)**2)**2
velocity = strength[:, None]*np.column_stack((cl.rx, cl.ry))/radius[:, None]
if verify_projection and cl.surface_velocity is not None:
np.testing.assert_allclose(cl.surface_velocity, velocity, atol=3e-9, rtol=0)
incoming = []
for i, (k, d, q, fallback) in enumerate(zip(cl.fluid_idx, cl.dir, cl.q, cl.fallback_class)):
k, d, q = int(k), int(d), float(q)
rho = flat[:, k].sum()
dot = CX[d]*velocity[i, 0]+CY[d]*velocity[i, 1]
if fallback:
value = flat[d, k]-6*W[d]*rho*dot
elif q < .5:
value = 2*q*flat[d, k]+(1-2*q)*flat[d, k-CX[d]-40*CY[d]]-6*W[d]*rho*dot
else:
value = flat[d, k]/(2*q)+(1-1/(2*q))*flat[OPP[d], k]-3*W[d]*rho*dot/q
incoming.append(value)
return np.array(incoming)
def fine_flux(before, staged, active, solid):
"""Population ledger across the fine leaf boundary: solid versus halo.
Internal fluid-to-fluid streaming cancels pairwise; solid and halo packets
are counted in their actual direction, before any collision.
"""
boundary, transfer = np.zeros(9), np.zeros(9)
magnitude = 0.0
for d, (cx, cy) in enumerate(zip(CX, CY)):
incoming = active & ~np.roll(active, (cy, cx), (0, 1))
outgoing = active & ~np.roll(active, (-cy, -cx), (0, 1))
from_solid = np.roll(solid, (cy, cx), (0, 1))
into_solid = np.roll(solid, (-cy, -cx), (0, 1))
received = np.roll(staged[d], (cy, cx), (0, 1)).astype(float)
sent = before[d].astype(float)
for vector, inc, out in ((boundary, incoming & from_solid, outgoing & into_solid),
(transfer, incoming & ~from_solid, outgoing & ~into_solid)):
assert np.isfinite(received[inc]).all() and np.isfinite(sent[out]).all()
vector[d] = received[inc].sum()-sent[out].sum()
magnitude += np.abs(received[inc]).sum()+np.abs(sent[out]).sum()
return boundary, transfer, magnitude
def coarse_flux(before, fine, active):
"""Derive every replacement from physical midpoints, not production tables."""
x0, y0, x1, y1 = BOUNDS
incoming = np.stack([np.roll(before[d], (CY[d], CX[d]), (0, 1)) for d in range(9)]).astype(float)
magnitude = 0.0
for y, x in np.argwhere(active):
for d in range(1, 9):
if x0 <= x+.5-CX[d]/2 <= x1 and y0 <= y+.5-CY[d]/2 <= y1:
a, b = 4+2*(x-x0), 4+2*(y-y0)
children = fine[d, b:b+2, a:a+2].astype(float)
assert children.shape == (2, 2) and np.isfinite(children).all()
incoming[d, y, x] = children.sum()/4
magnitude += np.abs(children).sum()/4
assert np.isfinite(incoming[:, active]).all()
# This retains the eight grazing-corner replacements of uncovered donors.
flux = (incoming[:, active]-before[:, active].astype(float)).sum(axis=1)
magnitude += np.abs(incoming[:, active]).sum()+np.abs(before[:, active]).sum(dtype=float)
return flux, magnitude
def run_patch(speed, *, audit=True):
cfg = LBMConfig(nx=24, ny=24, viscosity=.05, velocity=0, rho=1)
coarse_active = np.ones((24, 24), bool)
coarse_active[4:20, 4:20] = False
source = object_spec(speed)
state = vars(source.state).copy()
with CudaContext(0) as context:
build = (patch.object(compiler, "compile_kernel", audit_compiler(compiler.compile_kernel))
if audit else nullcontext())
with build:
backend = implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source])
with backend, context.activate():
c, f = backend._levels
cl = backend._fine_curved
geometry = {name: getattr(cl, name).copy() for name in GEOMETRY}
flags = backend.fine_boundary.flags.reshape(40, 40)
solid = (flags & SOLID) != 0
active = np.zeros((40, 40), bool)
active[4:-4, 4:-4] = True
active &= (flags & FLUID) != 0
active &= ~solid
local = backend.fine_boundary.objects[0]
assert local.center == (20., 20.) and local.radius == 4.3
assert source.center == (12.25, 12.25) and source.radius == 2.15
yy, xx = np.mgrid[:40, :40]
roi = active & ((xx-20)**2+(yy-20)**2 <= 9**2)
backend.initialize(rest((24, 24)), rest((40, 40)))
if audit:
for level in backend._levels:
for name in ("collision_counts", "explosion_counts", "incoming_counts"):
pointer, size = level.module.get_global(name)
cuda.memset_d8(pointer, 0, size)
initial = backend.download()
initial_pop = initial[0][:, coarse_active].sum(axis=1, dtype=float)
initial_pop += .25*initial[1][:, active].sum(axis=1, dtype=float)
boundary, fine_transfer, coarse_transfer = np.zeros(9), np.zeros(9), np.zeros(9)
budget_sum, reflection_error, drive_mass = 0., 0., 0.
traces = []
original_curved, original_fine, original_coarse = backend._curved, f.kernel, c.kernel
def curved(*args, **kwargs):
nonlocal boundary, fine_transfer, budget_sum, reflection_error, drive_mass
backend._stream.synchronize()
before = read(args[0], f.shape)
expected = reflection_oracle(before, cl, speed)
original_curved(*args, **kwargs)
backend._stream.synchronize()
staged = read(args[0], f.shape)
d, k = cl.dir.astype(int), cl.fluid_idx.astype(int)
values = staged.reshape(9, -1)[OPP[d], k+CX[d]+40*CY[d]]
passive = reflection_oracle(before, cl, None, verify_projection=False)
drive_mass += .25*float(np.sum(values-passive))
error = float(np.max(np.abs(values-expected)))
reflection_error = max(reflection_error, error)
assert error <= 1e-7
np.testing.assert_array_equal(staged[:, active], before[:, active])
b, transfer, magnitude = fine_flux(before, staged, active, solid)
boundary += .25*b
fine_transfer += .25*transfer
budget_sum += .25*magnitude
def fine(*args, **kwargs):
nonlocal budget_sum
backend._stream.synchronize()
staged = read(args[0], f.shape)
original_fine(*args, **kwargs)
backend._stream.synchronize()
advanced = read(args[1], f.shape)
assert np.isfinite(advanced[:, active]).all()
assert np.isnan(advanced[:, solid]).all()
# Include actual collision operands in the FP32 rounding budget.
budget_sum += .25*np.abs(staged[:, active]).sum(dtype=float)
traces.append(advanced[:, roi].copy())
def coarse(*args, **kwargs):
nonlocal coarse_transfer, budget_sum
backend._stream.synchronize()
before, fine_values = read(args[0], c.shape), read(args[2], f.shape)
transfer, magnitude = coarse_flux(before, fine_values, coarse_active)
coarse_transfer += transfer
budget_sum += magnitude
return original_coarse(*args, **kwargs)
with patch.object(backend, "_curved", curved), patch.object(f, "kernel", fine), \
patch.object(c, "kernel", coarse):
backend.step(2)
actual = backend.download()
population = actual[0][:, coarse_active].sum(axis=1, dtype=float)
population += .25*actual[1][:, active].sum(axis=1, dtype=float)
ledger = boundary+fine_transfer+coarse_transfer
error = MOMENTS@(population-initial_pop-ledger)
budget = 8*np.finfo(np.float32).eps*budget_sum
assert np.isfinite(budget_sum) and budget_sum > 0
assert np.max(np.abs(error)) <= budget
if speed:
assert speed*drive_mass > 0
assert len(traces) == 4 and backend.step_count == 2
if audit:
for level, mask, count in ((c, coarse_active, 2), (f, active, 4)):
pointer, size = level.module.get_global("collision_counts")
counts = read(pointer, mask.shape, np.uint32)
assert size == counts.nbytes
np.testing.assert_array_equal(counts, count*mask)
for name, values in geometry.items():
np.testing.assert_array_equal(getattr(cl, name), values)
assert vars(source.state) == state and source.geometry.center == source.center
print(f"speed={speed} audit={audit} BFL={reflection_error:.9g} ledger={error.tolist()} "
f"budget={budget:.9g} boundary={list(MOMENTS@boundary)} "
f"transfer={list(MOMENTS@(fine_transfer+coarse_transfer))} "
f"drive_mass={drive_mass:+.9g}; counts={'PASS' if audit else 'normal build'}", flush=True)
return traces, roi, actual
def uniform_reference(speed, roi):
# Reuse the existing explicit config helper without importing unrelated tests.
spec = importlib.util.spec_from_file_location("fine_surface_cases", ROOT/"tests/integration/_cases.py")
@@ -260,61 +71,59 @@ def uniform_reference(speed, roi):
return traces
def run_blocked_patch(speed):
"""All-blocked run (whole-level tiles) of the same case; fine ROI after each fine hop."""
cfg = LBMConfig(nx=24, ny=24, viscosity=.05, velocity=0, rho=1)
source = object_spec(speed)
with CudaContext(0) as context:
with implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
level_blocks=[(24, 24), (40, 40)],
level_objects=[(), (source,)],
observe_raw_exchange=True) as backend:
c, f = backend._levels
flags = backend.fine_boundary.flags.reshape(40, 40)
active = np.zeros((40, 40), bool)
active[4:-4, 4:-4] = True
active &= ((flags & FLUID) != 0) & ((flags & SOLID) == 0)
yy, xx = np.mgrid[:40, :40]
roi = active & ((xx-20)**2+(yy-20)**2 <= 9**2)
backend.initialize(rest((24, 24)), rest((40, 40)))
traces, original = [], f.kernel
def fine(*args, **kwargs):
original(*args, **kwargs)
backend._stream.synchronize()
advanced = f.layout.to_dense(read(args[1], f.layout.blocked_shape))
assert np.isfinite(advanced[:, active]).all()
assert np.isnan(advanced[:, (flags & SOLID) != 0]).all()
traces.append(advanced[:, roi].copy())
with patch.object(f, "kernel", fine):
backend.step(2)
assert len(traces) == 4 and backend.step_count == 2
mass = backend.read_raw_exchange(0)["total"]["mass_into_fluid"]
return traces, roi, backend.download(), float(mass)
@pytest.mark.parametrize("speed", [0., .002, -.002])
def test_fine_surface_four_hops(speed):
assert Path(CelerisLab.__file__).resolve() == ROOT/"src/CelerisLab/__init__.py"
assert Path(compiler._KERNEL_DIR).resolve() == ROOT/"src/CelerisLab/lbm/kernels"
assert Path(implementation.__file__).resolve() == ROOT/"src/CelerisLab/lbm/refinement_stepper.py"
actual, roi, final = run_patch(speed)
def test_fine_surface_blocked_matches_uniform_four_hops(speed):
"""Blocked storage: fine ROI after each of four hops against the uniform 48^2 solver
(same tolerances as the dense test); blowing/suction drives mass with its sign;
zero actuation equals the passive circle bit for bit."""
actual, roi, final, mass = run_blocked_patch(speed)
reference = uniform_reference(speed, roi)
for got, wanted in zip(actual, reference):
np.testing.assert_allclose(got, wanted, atol=3e-6, rtol=0)
np.testing.assert_allclose(MOMENTS@got.astype(float), MOMENTS@wanted.astype(float),
atol=2e-5, rtol=0)
if speed == 0:
passive, passive_roi, passive_final = run_patch(None, audit=False)
if speed:
assert speed*mass > 0
else:
passive, passive_roi, passive_final, _ = run_blocked_patch(None)
np.testing.assert_array_equal(passive_roi, roi)
for got, wanted in zip(actual+list(final), passive+list(passive_final)):
np.testing.assert_array_equal(got, wanted)
@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.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)
backend._curved = Mock()
with pytest.raises(RuntimeError, match="Surface boundary device buffers are missing"):
backend._apply_fine_boundary(object())
backend._curved.assert_not_called()
def test_close_frees_fine_boundary_after_stream_failure_once():
backend = implementation.FixedPatchStepper.__new__(implementation.FixedPatchStepper)
backend.ctx = SimpleNamespace(activate=nullcontext)
backend._lock = RLock()
backend._status = "ready"
stream, soa, first, second = Mock(), Mock(), Mock(), Mock()
stream.synchronize.side_effect = RuntimeError("stream synchronization failed")
backend._stream = stream
backend._fine_curved = soa
backend._fine_flags = first
backend._curved = Mock()
backend._levels = [object(), object()]
backend._allocations = [first, second]
with pytest.raises(RuntimeError, match="stream synchronization failed"):
backend.close()
assert backend._status == "closed"
assert backend._levels == [] and backend._allocations == []
assert backend._fine_curved is None and backend._fine_flags is None
assert backend._curved is None and backend._stream is None
backend.close()
stream.synchronize.assert_called_once_with()
soa.free.assert_called_once_with()
first.free.assert_called_once_with()
second.free.assert_called_once_with()
print(f"blocked speed={speed} four hops vs uniform PASS mass_into_fluid={mass:+.9g}", flush=True)
def test_cross_interface_donor_rejects_before_cuda():
@@ -325,6 +134,8 @@ 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, BOUNDS, context=context, fine_objects=[source])
implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
level_blocks=[(24, 24), (40, 40)],
level_objects=[(), (source,)])
context.activate.assert_not_called()
allocate.assert_not_called()
@@ -48,9 +48,8 @@ def make(context):
for (x, y), amplitude in zip(centers, (.003, -.002))
)
run = FixedPatchStepper(
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,
CFG, context=context, refinements=[PARENTS, START], level_blocks=[(4, 4)] * 3,
level_objects=[(), (), objects], level_jets=[(), (), jets],
)
return run, objects
@@ -25,9 +25,8 @@ 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,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,))
run = FixedPatchStepper(CFG,context=ctx,refinements=[PARENTS,A],level_blocks=[(4,4)]*3,
level_objects=[(),(),(body,)],level_jets=[(),(),(jet,)])
return run,(body,)
@@ -90,18 +90,22 @@ def census(run, index, buffer):
return totals(dense, leaf), external, independent
def fine(block):
"""Two-level circle+jet construction on BOUNDS, blocked with a (12, 8) root tile."""
return dict(refinements=[BOUNDS], level_blocks=[(12, 8), 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, 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:
with FixedPatchStepper(CFG, context=ctx, **fine(block), observe_raw_exchange=True) as measured, \
FixedPatchStepper(CFG, context=ctx, **fine(block)) as plain:
arrays = [state(l.shape) for l in measured._levels]
measured.initialize(*arrays)
plain.initialize(*arrays)
b = measured._level_boundaries[1]
b = measured._device_boundaries[1]
count = len(b.tables['cell'])
kernel = measured._levels[1].kernel
samples = []
@@ -142,27 +146,6 @@ class RawExchangeGPU(unittest.TestCase):
np.testing.assert_array_equal(measured.read_raw_exchange(0)['total']['impulse'],
obs['total']['impulse'])
def test_dense_fine_storage_reference(self):
"""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, 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)
for speed in (0., .002, 0.):
dense.set_jet_amplitude(0, speed)
blocked.set_jet_amplitude(0, speed)
dense.step(); blocked.step()
for a, e in zip(blocked.download(), dense.download()):
np.testing.assert_array_equal(a, e)
count = dense._fine_curved.count
expected = download(dense._fine_curved.link_exchange_gpu, (count, 3))
actual = download(blocked._level_boundaries[1].last_raw, (count, 4))
np.testing.assert_array_equal(actual[:, :3], expected)
def test_depth_four_interval_storage_and_sampling(self):
cfg = LBMConfig(nx=48, ny=48, viscosity=.02)
obj = SimObject(0, geometry=CircleGeometry(16., 16., .5), center=(16., 16.), radius=.5)
@@ -175,7 +158,7 @@ class RawExchangeGPU(unittest.TestCase):
FixedPatchStepper(cfg, context=ctx, **settings) as plain:
arrays = [state(l.shape, ux=0.) for l in run._levels]
run.initialize(*arrays); plain.initialize(*arrays)
b = run._level_boundaries[3]
b = run._device_boundaries[3]
count = len(b.tables['cell'])
kernel = run._levels[3].kernel
samples, independent_samples = [], []
@@ -221,35 +204,35 @@ 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 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:
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:
amr.initialize(*(state(l.shape) for l in amr._levels))
uniform.initialize(state((9, 48, 64)))
b = amr._level_boundaries[1]
b = amr._device_boundaries[1]
count = len(b.tables['cell'])
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.
rx = uniform._upload(b.tables['rx'])
ry = uniform._upload(b.tables['ry'])
# 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._device_boundaries[0]
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 = uniform._curved
original = root.kernel
samples = []
def measured_boundary(*args, **kw):
original(*args[:7], velocity, rx, ry, raw, **kw)
original(*args[:10], raw, **kw) # BlockCurvedBoundary raw-exchange output
uniform._stream.synchronize()
samples.append(download(raw, (count, 4)))
uniform._curved = measured_boundary
root.kernel = measured_boundary
for speed in (0., .002, -.001, 0.):
amr.set_jet_amplitude(0, speed)
cuda.memcpy_htod(velocity, b.tables['surface_velocity'])
@@ -2,6 +2,12 @@
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 refinement storage was removed: every level is blocked and level_blocks
is required (TileContractCPU). The retired dense/blocked route parity gates
(BlockedBaseGPU exact parity, DenseBlockedRouteParity incl. MRT and two
bodies, the route capability matrix) passed and are recorded at 1573942 and
2469af7.
"""
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import replace
@@ -13,12 +19,13 @@ from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda import 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 CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.lbm.refinement_tables import blocked_coarse_tables
from test_refinement_blocked_fine import NX, NY, BOUNDS, config, state, circle, actuation
BASE_BLOCKS = ((24, 32), (12, 8), (3, 4), (1, 1))
@@ -73,6 +80,7 @@ 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))))
@@ -83,11 +91,10 @@ 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 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)
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)
context.activate.assert_not_called()
def test_all_arrival_rows_donors_and_kind_masks(self):
@@ -102,7 +109,7 @@ class BlockedBaseCPU(unittest.TestCase):
leaves[[0, -1]] = False
for by, bx in BASE_BLOCKS:
layout = BlockLayout2D.build(NY, NX, by, bx, periodic=(True, mode == "periodic"))
donors, targets, kind = blocked_base_tables(p, layout, order, mode != "periodic")
donors, targets, kind = blocked_coarse_tables(p, layout, order, mode != "periodic")
ly, lx = layout.local_shape
L = ly * lx
expected, source_cells = [], []
@@ -132,7 +139,7 @@ class BlockedBaseCPU(unittest.TestCase):
p = FixedPatch2D.from_config(config("periodic"), BOUNDS)
order = np.argsort(p.coalesce_coarse)
layout = BlockLayout2D.build(NY, NX, 3, 4)
donors, targets, _ = blocked_base_tables(p, layout, order)
donors, targets, _ = blocked_coarse_tables(p, layout, order)
dense = np.arange(9 * NY * NX, dtype=np.float32).reshape(9, NY, NX)
initial = layout.to_blocked(dense)
arrivals = np.arange(len(targets), dtype=np.float32) + 10000
@@ -172,7 +179,7 @@ class BlockedBaseCPU(unittest.TestCase):
# With one parent block, all eight grazing sources are owner cores.
# Each is read for four fine ghost children: late explosion corrupts32.
single = BlockLayout2D.build(NY, NX, NY, NX)
donors, targets, _ = blocked_base_tables(p, single, order)
donors, targets, _ = blocked_coarse_tables(p, single, order)
initial = single.to_blocked(dense)
staged = initial.copy()
staged.ravel()[targets.astype(np.intp)] = arrivals
@@ -193,9 +200,10 @@ 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"), BOUNDS, context=m["context"],
coarse_boundary="slot_reservoir", fine_block=FINE_BLOCK,
base_block=(3, 4), fine_objects=[circle(actuation(.002))])
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)),)])
try:
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
coarse, fine = backend._levels
@@ -223,6 +231,8 @@ 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)
@@ -236,16 +246,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"), BOUNDS, context=m["context"],
fine_block=FINE_BLOCK, base_block=(3, 4))
FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), FINE_BLOCK])
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"), BOUNDS, context=m["context"],
fine_block=FINE_BLOCK, base_block=(3, 4))
backend = FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
level_blocks=[(3, 4), FINE_BLOCK])
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
@@ -263,61 +273,33 @@ class BlockedBaseCPU(unittest.TestCase):
allocation.free.assert_called_once_with()
class BlockedBaseGPU(unittest.TestCase):
def test_bodyfree_exact_parity_and_raw_invalid_slots(self):
self.compare([(None, ((24, 32), (3, 4), (1, 1)), 8)])
class TileContractCPU(unittest.TestCase):
"""level_blocks is required: one positive (by, bx) tile per level, checked before CUDA."""
def test_actuated_circle_exact_parity_and_raw_invalid_slots(self):
self.compare([(.002, ((12, 8),), 8), (-.002, ((12, 8),), 8)])
def test_non_power_two_slot_width_exact_parity(self):
self.compare([(None, ((3, 4),), 7), (None, ((3, 4),), 10)], ("slot_reservoir",))
def compare(self, cases, modes=("periodic", "slot_reservoir")):
from CelerisLab.cuda.context import CudaContext
expected_source = Path(__file__).resolve().parents[3] / "src/CelerisLab/lbm/refinement_stepper.py"
self.assertEqual(Path(refinement_stepper.__file__).resolve(), expected_source)
with CudaContext(0) as context:
for mode in modes:
for speed, layouts, width in cases:
cfg = config(mode)
if mode == "slot_reservoir":
cfg = replace(cfg, wall_slot_width=width)
p = FixedPatch2D.from_config(cfg, BOUNDS)
arrays = state(NY, NX, 1), state(*p.fine_shape, 2)
reference = None
for base_block in (None, *layouts):
label = f"{mode} width={width} speed={speed} base={base_block}"
snapshots = []
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:
# Blocked staging dirties invalid input slots; its output
# must poison them. Dense parent does not stage there.
coarse = backend._levels[0]
spare = coarse.layout.to_blocked(np.full(coarse.shape, 42, np.float32))
cuda.memcpy_htod(coarse.spare, spare)
backend.step(step - backend.step_count)
public, owner = backend.download(), raw(backend)
self.assertEqual(backend.step_count, step)
self.assertTrue(np.isnan(owner[0][:, 6:18, 8:24]).all(), label)
if mode != "periodic":
self.assertTrue(np.isnan(owner[0][:, (0, -1), :]).all(), label)
snapshots.append(public + owner)
backend.initialize(*arrays)
self.assertEqual(backend.step_count, 0)
for actual, expected in zip(backend.download() + raw(backend), snapshots[0]):
exact(self, actual, expected, label + " reset")
if reference is None:
reference = snapshots
else:
for step, actual, expected in zip((0, 1, 2, 8), snapshots, reference):
for index, (a, b) in enumerate(zip(actual, expected)):
exact(self, a, b, f"{label} step={step} array={index}")
print(f"PASS {label}: full finite bits/NaN masks at 0,1,2,8 + reset", flush=True)
def test_level_blocks_required_and_validated(self):
context = Mock(activate=Mock(side_effect=AssertionError("CUDA reached")))
cfg = config("periodic")
for label, kw in (("omitted", {}),
("None list", dict(level_blocks=None)),
("too few levels", dict(level_blocks=[(12, 8)])),
("too many levels", dict(level_blocks=[(12, 8), FINE_BLOCK, FINE_BLOCK])),
("None root", dict(level_blocks=[None, FINE_BLOCK])),
("None fine", dict(level_blocks=[(12, 8), None])),
("wrong arity", dict(level_blocks=[(12,), FINE_BLOCK])),
("non-integer", dict(level_blocks=[(12, 8.0), FINE_BLOCK])),
("bool", dict(level_blocks=[(True, 8), FINE_BLOCK])),
("non-positive", dict(level_blocks=[(0, 8), FINE_BLOCK])),
("non-tuple", dict(level_blocks=[12, FINE_BLOCK])),
("not dividing", dict(level_blocks=[(7, 8), FINE_BLOCK]))):
with self.subTest(label), self.assertRaises(ValueError):
FixedPatchStepper(cfg, context=context, refinements=[BOUNDS], **kw)
with self.assertRaisesRegex(ValueError, "whole-level tile"):
FixedPatchStepper(cfg, context=context, refinements=[BOUNDS])
context.activate.assert_not_called()
# The documented whole-level tiles are always valid (construction reaches CUDA).
whole = [(NY, NX), FixedPatch2D.from_config(cfg, BOUNDS).fine_shape]
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=context, refinements=[BOUNDS], level_blocks=whole)
if __name__ == "__main__":
@@ -1,9 +1,8 @@
"""Opt-in blocked fine storage for FixedPatchStepper: CPU tables plus GPU gate.
"""Blocked fine storage for FixedPatchStepper: CPU tables, staging and launch contracts.
Frozen GPU gate (same stream, 8 coarse steps, nonequilibrium start), in both
periodic and slot_reservoir modes: dense fine storage vs 1x1, 2x2 and 4x2
fine blocks give bit-identical masked downloads AND bit-identical raw coarse
and unpacked owner-core fine arrays, including patch-ghost values/NaN masks.
The former GPU gate (dense fine storage vs 1x1, 2x2 and 4x2 fine blocks,
bit-identical) was retired with the dense route; its result and the
dense/blocked route parity are recorded at 1573942 and 2469af7.
"""
from contextlib import nullcontext
import unittest
@@ -15,14 +14,15 @@ 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_v2 as compiler
from CelerisLab.cuda import 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
from CelerisLab.lbm.descriptors import SOLID
from CelerisLab.lbm.refinement import (ACTIVE, INNER_GHOST, OUTER_GHOST, FixedPatch2D,
prepare_fine_boundary)
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, blocked_fine_tables
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.lbm.refinement_tables import blocked_fine_tables
NX, NY, BOUNDS, STEPS = 32, 24, (8, 6, 24, 18), 8
FINE_BLOCKS = ((32, 40), (16, 20), (16, 10)) # fine window 32x40: 1x1, 2x2, 4x2
@@ -88,15 +88,13 @@ def seam_cells(layout, mapped):
def raw(backend):
"""Unmasked device arrays; blocked fine storage unpacked from owner cores."""
coarse, fine = backend._levels
out = [np.empty(coarse.shape, np.float32)]
out.append(np.empty(fine.shape if backend.fine_layout is None
else backend.fine_layout.blocked_shape, np.float32))
cuda.memcpy_dtoh(out[0], coarse.current)
cuda.memcpy_dtoh(out[1], fine.current)
if backend.fine_layout is not None:
out[1] = backend.fine_layout.to_dense(out[1])
"""Unmasked device arrays of every level; blocked storage unpacked from owner cores."""
out = []
for level in backend._levels:
array = np.empty(level.shape if level.layout is None else level.layout.blocked_shape,
np.float32)
cuda.memcpy_dtoh(array, level.current)
out.append(array if level.layout is None else level.layout.to_dense(array))
return out
@@ -105,9 +103,10 @@ 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 ({"fine_block": (7, 7)}, {"fine_block": (16,)}):
for kw in ({"level_blocks": [None, (7, 7)]}, {"level_blocks": [None, (16,)]}):
with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(config("periodic"), BOUNDS, context=self.forbidden_context(), **kw)
FixedPatchStepper(config("periodic"), context=self.forbidden_context(),
refinements=[BOUNDS], **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).
@@ -121,12 +120,12 @@ class BlockedFineCPU(unittest.TestCase):
raise AssertionError("zero-byte device allocation")
sizes.append(size)
return Mock()
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), 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"), BOUNDS, context=context,
fine_block=FINE_BLOCKS[0])
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=[(12, 8), 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
@@ -171,14 +170,15 @@ 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(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), 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"), BOUNDS, context=context,
fine_objects=objects, fine_block=FINE_BLOCKS[1])
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=[(12, 8), FINE_BLOCKS[1]],
level_objects=[(), tuple(objects)])
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
return backend, calls
@@ -187,8 +187,8 @@ class BlockedFineCPU(unittest.TestCase):
backend, calls = self.mocked_backend([circle(spec)])
c, f = backend._levels
coarse, cur, spare = c.current, f.current, f.spare
flags, kind = backend._fine_flags, backend._levels[1].kind
velocity = backend._link_tables.get("surface_velocity")
flags, kind = backend._device_boundaries[1].flags, backend._levels[1].kind
velocity = backend._device_boundaries[1].device.get("surface_velocity")
self.assertEqual(velocity is None, spec is None)
backend.step(1)
backend.close()
@@ -196,8 +196,9 @@ class BlockedFineCPU(unittest.TestCase):
self.assertEqual(order, [("BlockExplode", coarse),
("BlockHaloCopy", cur), ("BlockCurvedBoundary", cur), ("BlockStep", cur),
("BlockHaloCopy", spare), ("BlockCurvedBoundary", spare),
("BlockStep", spare), ("PatchCoarseStep", coarse)])
steps = [a for name, a in calls if name == "BlockStep"]
("BlockStep", spare), ("BlockHaloCopy", coarse),
("BlockCoalesce", cur), ("BlockStep", coarse)])
steps = [a for name, a in calls if name == "BlockStep"][:2] # the two fine hops
self.assertEqual([a[1] for a in steps], [spare, cur])
for a in steps:
self.assertIs(a[3], flags) # blocked flags, not null
@@ -213,7 +214,7 @@ class BlockedFineCPU(unittest.TestCase):
for speed in (0.0, .002): # any spec, even zero amplitude
backend, calls = self.mocked_backend([circle(actuation(speed))])
self.assertIsNotNone(backend._fine_tables["surface_velocity"])
del backend._link_tables["surface_velocity"]
del backend._device_boundaries[1].device["surface_velocity"]
with self.assertRaisesRegex(RuntimeError, "Surface velocity device buffer is missing"):
backend.step(1)
names = [name for name, _ in calls]
@@ -279,91 +280,5 @@ class BlockedFineCPU(unittest.TestCase):
self.assertEqual(set(np.unique(kind).tolist()) - {ACTIVE, INNER_GHOST, OUTER_GHOST}, set())
class BlockedFineGPU(unittest.TestCase):
def test_dense_and_blocked_fine_storage_are_bit_identical(self):
from CelerisLab.cuda.context import CudaContext
for mode in ("periodic", "slot_reservoir"):
cfg = config(mode)
patch = FixedPatch2D.from_config(cfg, BOUNDS)
g_coarse, g_fine = state(NY, NX, 1), state(*patch.fine_shape, 2)
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
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)
results.append((backend.download(), raw(backend)))
(dense_public, dense_raw), blocked = results[0], results[1:]
kind = patch.fine_kind
self.assertTrue(np.isnan(dense_raw[1][:, kind == OUTER_GHOST]).all(), mode)
# INNER populations may be NaN after hop 2 (pulled from poisoned OUTER);
# their exact pattern is pinned by the dense-vs-blocked comparison below.
self.assertTrue(np.isfinite(dense_raw[1][:, kind == ACTIVE]).all(), mode)
for fine_block, (public, raw_arrays) in zip(FINE_BLOCKS, blocked):
msg = f"{mode} fine_block={fine_block}"
for a, b in zip(public + tuple(raw_arrays), dense_public + tuple(dense_raw)):
np.testing.assert_array_equal(a, b, err_msg=msg) # values and NaN masks
print(f"{mode}: dense vs {len(blocked)} fine-block layouts bit-identical "
f"after {STEPS} coarse steps", flush=True)
def test_passive_circle_dense_and_blocked_fine_storage_are_bit_identical(self):
from CelerisLab.cuda.context import CudaContext
for mode in ("periodic", "slot_reservoir"):
cfg = config(mode)
patch, boundary = fine_circle(mode)
solid = (boundary.flags.reshape(patch.fine_shape) & SOLID) != 0
g_coarse, g_fine = state(NY, NX, 1), state(*patch.fine_shape, 2)
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
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)))
(dense_public, dense_raw), blocked = results[0], results[1:]
fine = dense_raw[1]
self.assertTrue(np.isfinite(fine[:, (patch.fine_kind == ACTIVE) & ~solid]).all(), mode)
self.assertTrue(np.isnan(fine[:, solid]).all(), mode)
self.assertTrue(np.isnan(fine[:, patch.fine_kind == OUTER_GHOST]).all(), mode)
for fine_block, (public, raw_arrays) in zip(FINE_BLOCKS, blocked):
msg = f"circle {mode} fine_block={fine_block}"
for a, b in zip(public + tuple(raw_arrays), dense_public + tuple(dense_raw)):
np.testing.assert_array_equal(a, b, err_msg=msg) # values and NaN masks
print(f"circle {mode}: dense vs {len(blocked)} fine-block layouts bit-identical "
f"after {STEPS} coarse steps", flush=True)
def test_actuated_circle_dense_and_blocked_fine_storage_are_bit_identical(self):
from CelerisLab.cuda.context import CudaContext
for mode in ("periodic", "slot_reservoir"):
for speed in (.002, -.002):
cfg = config(mode)
patch, boundary = fine_circle(mode, actuation(speed))
solid = (boundary.flags.reshape(patch.fine_shape) & SOLID) != 0
g_coarse, g_fine = state(NY, NX, 1), state(*patch.fine_shape, 2)
results = []
with CudaContext(0) as context:
for fine_block in (None, *FINE_BLOCKS):
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)))
(dense_public, dense_raw), blocked = results[0], results[1:]
fine = dense_raw[1]
self.assertTrue(np.isfinite(fine[:, (patch.fine_kind == ACTIVE) & ~solid]).all())
self.assertTrue(np.isnan(fine[:, solid]).all())
self.assertTrue(np.isnan(fine[:, patch.fine_kind == OUTER_GHOST]).all())
for fine_block, (public, raw_arrays) in zip(FINE_BLOCKS, blocked):
msg = f"actuated {speed} {mode} fine_block={fine_block}"
for a, b in zip(public + tuple(raw_arrays), dense_public + tuple(dense_raw)):
np.testing.assert_array_equal(a, b, err_msg=msg) # values and NaN masks
print(f"actuated {speed:+.3f} {mode}: dense vs {len(blocked)} fine-block layouts "
f"bit-identical after {STEPS} coarse steps", flush=True)
if __name__ == "__main__":
unittest.main()
@@ -17,9 +17,10 @@ import pycuda.driver as cuda
import CelerisLab
from CelerisLab.lbm import refinement_stepper as implementation
from CelerisLab.lbm.blocks import BlockLayout2D
from test_refinement_gpu import (ROOT, POP_ATOL, MOMENT_ATOL, ref, postcollision,
totals, LBMConfig, CudaContext, compiler,
PeriodicPatchStepper)
FixedPatchStepper)
def scalar_interval(coarse, fine, bounds, slot=None):
@@ -83,6 +84,21 @@ def scalar_interval(coarse, fine, bounds, slot=None):
return result, current
def whole_tiles(cfg, bounds):
"""One whole-level tile per level: (ny, nx) at the root, the fine window below."""
return [(cfg.ny, cfg.nx), implementation.FixedPatch2D.from_config(cfg, bounds).fine_shape]
def owner_arrays(backend):
"""Raw owner storage of every level as dense (9, ny, nx), before public masking."""
arrays = []
for level in backend._levels:
blocked = np.empty(level.layout.blocked_shape, np.float32)
cuda.memcpy_dtoh(blocked, level.current)
arrays.append(level.layout.to_dense(blocked))
return arrays
class FaultGuardsCPU(unittest.TestCase):
def test_boundary_mode_and_margin_contract(self):
cfg = LBMConfig(nx=32, ny=24, viscosity=.1, velocity=0, wall_normal_velocity=.001,
@@ -94,7 +110,8 @@ class FaultGuardsCPU(unittest.TestCase):
("slot_reservoir", (12, 4, 20, 21))):
with self.subTest(mode=mode, bounds=bounds):
with self.assertRaises(ValueError):
implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode)
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[bounds], level_blocks=[(1, 1)] * 2)
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.
@@ -102,16 +119,20 @@ 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, bounds, context=context, coarse_boundary=mode)
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
refinements=[bounds], level_blocks=[(1, 1)] * 2)
def fake_backend(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend.fine_layout = None # dense fine storage
backend = FixedPatchStepper.__new__(FixedPatchStepper)
layout = BlockLayout2D.build(1, 1, 1, 1, periodic=(False, False))
backend._layouts = [layout, layout] # blocked storage on both levels
backend._ddf_shifting = False # unshifted on every level, as _check_storage expects
backend._level_cfgs = [LBMConfig(nx=16, ny=16, viscosity=.1)] * 2 # ddf_shifting off
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "ready"
backend._stream = Mock()
backend._levels = [Mock(shape=(9, 1, 1), layout=None), Mock(shape=(9, 1, 1), layout=None)]
backend._levels = [Mock(shape=(9, 1, 1), layout=layout), Mock(shape=(9, 1, 1), layout=layout)]
backend._allocations = [Mock(), Mock()]
return backend
@@ -154,20 +175,35 @@ 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, (4, 4, 12, 12), context=context)
FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)])
p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
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)
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"):
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
with patch.object(hierarchy, "_check_chain"), \
self.assertRaisesRegex(RuntimeError, "omega/tau"):
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
alloc.assert_not_called()
upload.assert_not_called()
module = Mock()
module.get_global.return_value = (Mock(), 16)
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "memcpy_htod") as upload:
with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
PeriodicPatchStepper(cfg, p.bounds, context=context)
FixedPatchStepper(cfg, context=context, refinements=[p.bounds],
level_blocks=[(16, 16), (24, 24)])
upload.assert_not_called()
@@ -235,23 +271,27 @@ class NormalBuildGPU(unittest.TestCase):
parts = line.split()
if len(parts) == 3 and parts[0] == "#define":
definitions[parts[1]] = parts[2]
generated.append((metadata["config"], definitions))
generated.append((metadata["config"], definitions, metadata))
return build
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, bounds, context=context,
coarse_boundary="slot_reservoir")
backend = implementation.FixedPatchStepper(cfg, context=context, refinements=[bounds],
coarse_boundary="slot_reservoir",
level_blocks=whole_tiles(cfg, bounds))
with backend:
self.assertEqual(len(generated), 2)
fine_config, fine_defines = generated[1]
self.assertEqual((fine_config["nx"], fine_config["ny"]), (fx, fy))
fine_config, fine_defines, _ = generated[1]
# Blocked kernels see one tile window including the 1-cell halo.
self.assertEqual((fine_config["nx"], fine_config["ny"]), (fx+2, fy+2))
for name in ("Y_WALL_BC", "TOP_WALL_BC", "WALL_SLOT_WIDTH",
"WALL_NORMAL_VELOCITY", "TOP_WALL_NORMAL_VELOCITY"):
self.assertEqual(float(fine_defines[name].removesuffix("f")), 0, name)
self.assertEqual(int(generated[0][1]["Y_WALL_BC"]), 2)
self.assertEqual(int(generated[0][1]["TOP_WALL_BC"]), 1)
# The root's physical slot/reservoir walls come from BLOCK_ROOT_BC=1
# (module constant d_block_wall); the fine level has none.
root_meta, fine_meta = generated[0][2], generated[1][2] # read before cleanup
self.assertEqual((root_meta["block_root_bc"], fine_meta["block_root_bc"]), (1, 0))
# Independently derive all target links, including the eight
# whose coarse sources are uncovered, from physical midpoints.
targets, corners = set(), set()
@@ -271,9 +311,7 @@ class NormalBuildGPU(unittest.TestCase):
expected = scalar_interval(*expected, bounds, slot=(amplitude, width))
backend.step()
self.assertEqual(backend.step_count, step)
raw = [np.empty(a.shape, np.float32) for a in initial]
for level, array in zip(backend._levels, raw):
cuda.memcpy_dtoh(array, level.current)
raw = owner_arrays(backend)
# Read raw storage before masking: active NaNs cannot be
# hidden by the public download's non-leaf cleanup.
self.assertTrue(np.isfinite(raw[0][:, coarse_leaf]).all())
@@ -310,8 +348,9 @@ class NormalBuildGPU(unittest.TestCase):
state = ref.State(*(a * (1 + 1e-3*rng.standard_normal(a.shape))
for a in (initial.coarse_f, initial.fine_f)))
max_pop = max_drift = max_moment = 0.0
with CudaContext(0) as context, PeriodicPatchStepper(
LBMConfig(nx=16, ny=16, viscosity=.1), (4, 4, 12, 12), context=context) as backend:
with CudaContext(0) as context, FixedPatchStepper(
LBMConfig(nx=16, ny=16, viscosity=.1), context=context,
refinements=[(4, 4, 12, 12)], level_blocks=[(16, 16), (24, 24)]) as backend:
backend.initialize(*postcollision(state, spec, maps))
before = totals(backend.download(), maps)
for step in range(1, 9):
@@ -342,17 +381,17 @@ 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, bounds, context=context) as backend:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context, refinements=[bounds],
level_blocks=[(13, 17), (14, 16)]) as backend:
self.assertEqual(backend.patch.fine_shape, (14, 16))
p = backend.patch
allocated = 2 * 9 * (17*13+16*14) * 4 + sum(a.nbytes for a in
(p.ghost_fine, p.ghost_coarse, p.coalesce_coarse, p.coalesce_fine))
allocated = 2 * 4 * sum(int(np.prod(level.layout.blocked_shape)) for level in backend._levels) \
+ sum(a.nbytes for a in (p.ghost_fine, p.ghost_coarse, p.coalesce_coarse, p.coalesce_fine))
self.assertLess(allocated, 128*1024)
backend.initialize(*initial)
backend.step()
coarse, fine = backend.download()
raw_fine = np.empty((9, 14, 16), np.float32)
cuda.memcpy_dtoh(raw_fine, backend._levels[1].current)
raw_fine = owner_arrays(backend)[1]
max_error = 0.0
for got, want in zip((coarse, raw_fine), expected):
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
+71 -118
View File
@@ -20,9 +20,9 @@ import pycuda.driver as cuda
import CelerisLab
from CelerisLab.config import LBMConfig
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda import compiler
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, PeriodicPatchStepper
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
ROOT = Path(__file__).resolve().parents[3]
POP_ATOL = 3e-6
@@ -52,12 +52,11 @@ def totals(arrays, maps):
return np.array([populations.sum(), populations @ ref.CX, populations @ ref.CY])
def global_array(module, name, shape):
out = np.empty(shape, dtype=np.uint32)
ptr, size = module.get_global(name)
assert size == out.nbytes
cuda.memcpy_dtoh(out, ptr)
return out
def level_storage(level):
"""Raw device storage of one blocked level as dense (9, ny, nx)."""
blocked = np.empty(level.layout.blocked_shape, np.float32)
cuda.memcpy_dtoh(blocked, level.current)
return level.layout.to_dense(blocked)
class CoupledGPU(unittest.TestCase):
@@ -82,15 +81,15 @@ class CoupledGPU(unittest.TestCase):
expected = postcollision(next_state, spec, maps)
arrivals = (next_state.coarse_f, next_state.fine_f)
cases = (("dense_unshifted", False, {}),
cases = (("blocked_unshifted", False, dict(level_blocks=[(16, 16), (24, 24)])),
("blocked_shifted", True,
dict(base_block=(4, 4), fine_block=(6, 6))))
dict(level_blocks=[(4, 4), (6, 6)])))
with CudaContext(0) as context:
for label, shifted, storage in cases:
with self.subTest(storage=label):
cfg = LBMConfig(nx=16, ny=16, viscosity=.0192,
ddf_shifting=shifted)
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context,
with FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
**storage) as backend:
self.assertAlmostEqual(backend.patch.tau_coarse, spec.tau_coarse)
self.assertAlmostEqual(backend.patch.tau_fine, spec.tau_fine)
@@ -125,7 +124,7 @@ class CoupledGPU(unittest.TestCase):
"wrong-phase controls PASS", flush=True)
def test_cleanup_attempts_every_allocation(self):
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
backend = FixedPatchStepper.__new__(FixedPatchStepper)
backend.ctx = Mock(activate=nullcontext)
backend._lock = RLock()
backend._status = "failed"
@@ -147,8 +146,9 @@ class CoupledGPU(unittest.TestCase):
spec = ref.make_spec(collision="srt")
maps = ref.build_maps(spec)
with CudaContext(0) as context:
with PeriodicPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1),
(4, 4, 12, 12), context=context) as backend:
with FixedPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1),
context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as backend:
backend.initialize(*postcollision(ref.make_state(spec), spec, maps))
coarse, fine = backend._levels
@@ -164,122 +164,75 @@ class CoupledGPU(unittest.TestCase):
for operation in (backend.download, backend.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
raw = np.empty(fine.shape, np.float32)
cuda.memcpy_dtoh(raw, fine.current)
raw = level_storage(fine)
self.assertTrue(np.isfinite(raw.ravel()[backend.patch.coalesce_fine]).all())
self.assertFalse(context.is_closed)
print("partial failure: two fine hops completed, no coarse step published; cleanup PASS", flush=True)
def test_bounded_equivalence_and_ownership(self):
self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py")
self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels")
def eight_steps(self, backend, spec, maps):
"""Rest and seeded nonequilibrium, 8 steps against the scalar reference."""
p = backend.patch
for case in ("rest", "nonequilibrium_seed1"):
with self.subTest(case=case):
state = ref.make_state(spec, velocity=(0, 0) if case == "rest" else (.03, -.02))
if case != "rest":
rng = np.random.default_rng(1)
state = ref.State(*(a * (1 + 1e-3 * rng.standard_normal(a.shape))
for a in (state.coarse_f, state.fine_f)))
initial = postcollision(state, spec, maps)
backend.initialize(*initial)
before = totals(backend.download(), maps)
max_error = max_drift = max_moment_error = max_corner = 0.0
for step in range(1, 9):
state, diagnostic = ref.advance_coarse_once(spec, maps, state)
backend.step()
actual = backend.download()
expected = postcollision(state, spec, maps)
self.assertEqual(backend.step_count, step)
p.check_synchronization(step, state.tick, phase="postcollision_g")
for got, want in zip(actual, expected):
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
error = float(np.nanmax(np.abs(got - want)))
self.assertLessEqual(error, POP_ATOL)
max_error = max(max_error, error)
drift = float(np.max(np.abs(totals(actual, maps) - before)))
moment_error = float(np.max(np.abs(totals(actual, maps) - totals(expected, maps))))
self.assertLessEqual(drift, MOMENT_ATOL)
self.assertLessEqual(moment_error, MOMENT_ATOL)
max_drift, max_moment_error = max(max_drift, drift), max(max_moment_error, moment_error)
f = backend._levels[1]
raw = level_storage(f)
np.testing.assert_array_equal(np.isfinite(raw), diagnostic["hop2_valid"])
self.assertTrue(np.isfinite(raw.ravel()[p.coalesce_fine]).all())
self.assertEqual(len(diagnostic["corner_packets"]), 8)
for packet in diagnostic["corner_packets"].values():
sx, sy = packet["source_coarse_xy"]
self.assertTrue(maps.coarse_active[sy, sx])
values = [raw[packet["direction"], y, x]
for y, x in packet["children_fine_yx"]]
error = float(np.max(np.abs(np.array(values) - packet["packets"])))
self.assertLessEqual(error, POP_ATOL)
max_corner = max(max_corner, error)
print(f"{case}: steps=8 max_population={max_error:.9g} "
f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment_error:.9g} "
f"max_corner_packet={max_corner:.9g}; PASS", flush=True)
def test_bounded_equivalence_blocked(self):
"""The same eight-step reference and launch-failure checks on whole-level tiles."""
spec = ref.make_spec(collision="srt")
maps = ref.build_maps(spec)
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
compile_original = compiler.compile_kernel
def audit_compile(*args, **kwargs):
# Only this private build's entry differs. Production source unchanged.
entry = Path(kwargs["build_dir"]) / "refinement_audit.cu"
entry.write_text('#define CELERIS_REFINEMENT_AUDIT\n#include "refinement.cu"\n')
kwargs["source"] = entry.name
return compile_original(*args, **kwargs)
with CudaContext(0) as context:
with patch.object(compiler, "compile_kernel", audit_compile):
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
p = backend.patch
device_bytes += sum(a.nbytes for a in (p.ghost_fine, p.ghost_coarse,
p.coalesce_coarse, p.coalesce_fine))
audit_bytes = sum((2 + 9) * np.prod(level.shape[1:]) * 4
for level in backend._levels)
self.assertLess(device_bytes + audit_bytes, 128 * 1024)
print(f"device_arrays={device_bytes} audit_arrays={audit_bytes} bytes", flush=True)
for case in ("rest", "nonequilibrium_seed1"):
with self.subTest(case=case):
state = ref.make_state(spec, velocity=(0, 0) if case == "rest" else (.03, -.02))
if case != "rest":
rng = np.random.default_rng(1)
state = ref.State(*(a * (1 + 1e-3 * rng.standard_normal(a.shape))
for a in (state.coarse_f, state.fine_f)))
initial = postcollision(state, spec, maps)
backend.initialize(*initial)
for level in backend._levels:
for name in ("collision_counts", "explosion_counts", "incoming_counts"):
ptr, size = level.module.get_global(name)
cuda.memset_d8(ptr, 0, size)
before = totals(backend.download(), maps)
max_error = max_drift = max_moment_error = max_corner = 0.0
for step in range(1, 9):
state, diagnostic = ref.advance_coarse_once(spec, maps, state)
backend.step()
actual = backend.download()
expected = postcollision(state, spec, maps)
self.assertEqual(backend.step_count, step)
p.check_synchronization(step, state.tick, phase="postcollision_g")
for got, want in zip(actual, expected):
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
error = float(np.nanmax(np.abs(got - want)))
self.assertLessEqual(error, POP_ATOL)
max_error = max(max_error, error)
drift = float(np.max(np.abs(totals(actual, maps) - before)))
moment_error = float(np.max(np.abs(totals(actual, maps) - totals(expected, maps))))
self.assertLessEqual(drift, MOMENT_ATOL)
self.assertLessEqual(moment_error, MOMENT_ATOL)
max_drift, max_moment_error = max(max_drift, drift), max(max_moment_error, moment_error)
c, f = backend._levels
np.testing.assert_array_equal(global_array(c.module, "collision_counts", (16, 16)),
step * maps.coarse_active)
np.testing.assert_array_equal(global_array(f.module, "collision_counts", (24, 24)),
2 * step * maps.fine_active)
np.testing.assert_array_equal(global_array(f.module, "explosion_counts", (24, 24)),
step * ~maps.fine_active)
owners = np.zeros((9, 16, 16), np.uint32)
owners.ravel()[p.coalesce_coarse] = step
np.testing.assert_array_equal(global_array(c.module, "incoming_counts", owners.shape), owners)
raw = np.empty(f.shape, np.float32)
cuda.memcpy_dtoh(raw, f.current)
np.testing.assert_array_equal(np.isfinite(raw), diagnostic["hop2_valid"])
self.assertTrue(np.isfinite(raw.ravel()[p.coalesce_fine]).all())
self.assertEqual(len(diagnostic["corner_packets"]), 8)
for packet in diagnostic["corner_packets"].values():
sx, sy = packet["source_coarse_xy"]
self.assertTrue(maps.coarse_active[sy, sx])
values = [raw[packet["direction"], y, x]
for y, x in packet["children_fine_yx"]]
error = float(np.max(np.abs(np.array(values) - packet["packets"])))
self.assertLessEqual(error, POP_ATOL)
max_corner = max(max_corner, error)
print(f"{case}: steps=8 max_population={max_error:.9g} "
f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment_error:.9g} "
f"max_corner_packet={max_corner:.9g}; ownership/counts PASS", flush=True)
# Fail before any additional time step; no stale synchronized export.
with FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as backend:
self.eight_steps(backend, spec, maps)
with patch.object(backend._pairs[0], "explode", side_effect=RuntimeError("injected launch failure")):
with self.assertRaisesRegex(RuntimeError, "injected"):
backend.step()
with self.assertRaisesRegex(RuntimeError, "failed"):
backend.download()
with self.assertRaisesRegex(RuntimeError, "failed"):
backend.step()
for operation in (backend.download, backend.step):
with self.assertRaisesRegex(RuntimeError, "failed"):
operation()
self.assertFalse(context.is_closed)
with self.assertRaisesRegex(RuntimeError, "closed"):
backend.download()
# Compile and execute the actual non-audited entry as well: one step.
with PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as plain:
rest = postcollision(ref.make_state(spec), spec, maps)
plain.initialize(*rest)
plain.step()
for got, expected in zip(plain.download(), rest):
np.testing.assert_allclose(got, expected, rtol=0, atol=POP_ATOL, equal_nan=True)
print("normal-build rest: steps=1 PASS; source/kernel paths pinned", flush=True)
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
print(f"host_peak_rss={rss:.3f} MiB", flush=True)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -3,11 +3,13 @@
Jets use the same coarse cell-edge frame as fine_objects. The equivalent jet
of Astra's actuated fixture is SurfaceActuation(0.7, 0.85, U).as_jet((16, 12),
2.15). Frozen GPU gates (8 coarse steps, both coarse boundary modes):
G1 jet run: dense vs 1x1/2x2/4x2 fine blocks bit-identical (values + NaN masks);
G2 jet vs legacy actuation (dense): max |dG| over finite fine ACTIVE fluid
G1 jet run: 1x1/2x2/4x2 fine blocks (blocked (12, 8) root) bit-identical to the
FINE_BLOCKS[0] reference (values + NaN masks);
G2 jet vs legacy actuation (blocked reference): max |dG| over finite fine ACTIVE fluid
populations <= 1e-6 (velocity rows differ only by FP32 rounding; reported);
G3 amplitude change after 4 steps (+U -> -U), 4 more steps: dense vs blocked
bit-identical and the run differs from the unchanged run.
G3 amplitude change after 4 steps (+U -> -U), 4 more steps: blocked layouts
bit-identical to the FINE_BLOCKS[0] reference and the run differs from the
unchanged run.
"""
from contextlib import nullcontext
import unittest
@@ -16,7 +18,7 @@ from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.cuda import compiler
from CelerisLab.lbm.descriptors import SOLID
from CelerisLab.lbm.refinement import ACTIVE, FixedPatch2D, prepare_fine_boundary
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
@@ -26,6 +28,11 @@ from test_refinement_blocked_fine import (BOUNDS, FINE_BLOCKS, NX, NY, STEPS, ac
SPEED = .002
def root_and(fine_block):
"""Blocked levels with a (12, 8) root tile."""
return [(12, 8), fine_block]
def jet(speed=SPEED):
return actuation(speed).as_jet((16.0, 12.0), 2.15)
@@ -36,13 +43,14 @@ 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(compiler, "generate_config"), \
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), 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"), BOUNDS, context=context,
fine_objects=objects, fine_block=fine_block, jets=jets)
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
level_blocks=root_and(fine_block), level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)])
return backend, htod
@@ -99,7 +107,7 @@ class JetStepperCPU(unittest.TestCase):
def test_blocked_amplitude_update_rewrites_only_velocity_column(self):
backend, htod = mocked([circle()], [jet()], FINE_BLOCKS[1])
before = backend._fine_tables["surface_velocity"].copy()
device = backend._link_tables["surface_velocity"]
device = backend._device_boundaries[1].device["surface_velocity"]
with patch.object(cuda, "memcpy_htod", htod):
htod.reset_mock()
backend.set_jet_amplitude(0, -SPEED)
@@ -115,7 +123,7 @@ class JetStepperCPU(unittest.TestCase):
backend.close()
def test_copy_failure_marks_failed_and_keeps_host_amplitude(self):
for fine_block in (None, FINE_BLOCKS[1]): # dense SoA and blocked tables
for fine_block in (FINE_BLOCKS[1],): # blocked tables
backend, _ = mocked([circle()], [jet()], fine_block)
status = backend._status
for bad, error in ((1.5, ValueError), (float("nan"), ValueError)):
@@ -125,9 +133,7 @@ class JetStepperCPU(unittest.TestCase):
with self.assertRaises(IndexError):
backend.set_jet_amplitude(1, -SPEED)
self.assertEqual(backend._status, status)
dense = backend._fine_curved
before = (dense.surface_velocity if dense is not None
else backend._fine_tables["surface_velocity"]).copy()
before = backend._fine_tables["surface_velocity"].copy()
boom = Mock(side_effect=RuntimeError("copy failed"))
with patch.object(cuda, "memcpy_htod", boom), patch.object(cuda, "memcpy_htod_async", boom), \
patch.object(cuda, "pagelocked_empty", side_effect=lambda shape, dtype: np.empty(shape, dtype)):
@@ -135,11 +141,7 @@ class JetStepperCPU(unittest.TestCase):
backend.set_jet_amplitude(0, -SPEED)
self.assertEqual(backend._status, "failed", fine_block)
self.assertEqual(backend._jets[0].amplitude, SPEED)
if dense is not None:
self.assertEqual(dense.jets[0].amplitude, SPEED)
np.testing.assert_array_equal(dense.surface_velocity, before)
else:
np.testing.assert_array_equal(backend._fine_tables["surface_velocity"], before)
np.testing.assert_array_equal(backend._fine_tables["surface_velocity"], before)
with self.assertRaisesRegex(RuntimeError, "failed"):
backend.step(1)
with self.assertRaisesRegex(RuntimeError, "failed"):
@@ -149,8 +151,10 @@ 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"), BOUNDS, context=Mock(),
fine_objects=[circle(actuation(SPEED))], jets=[jet()])
FixedPatchStepper(config("periodic"), context=Mock(), refinements=[BOUNDS],
level_blocks=root_and(FINE_BLOCKS[1]),
level_objects=[(), (circle(actuation(SPEED)),)],
level_jets=[(), (jet(),)])
def run(mode, fine_block, *, objects, jets, switch=None):
@@ -158,8 +162,9 @@ 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, BOUNDS, context=context, coarse_boundary=mode, fine_objects=objects,
fine_block=fine_block, jets=jets) as backend:
cfg, context=context, coarse_boundary=mode, refinements=[BOUNDS],
level_blocks=root_and(fine_block), level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)]) as backend:
backend.initialize(state(NY, NX, 1), state(*patch_.fine_shape, 2))
if switch is None:
backend.step(STEPS)
@@ -176,29 +181,29 @@ class JetStepperGPU(unittest.TestCase):
patch_, boundary = fine_circle(mode)
fluid = (patch_.fine_kind == ACTIVE) & (
(boundary.flags.reshape(patch_.fine_shape) & SOLID) == 0)
dense = run(mode, None, objects=[circle()], jets=[jet()])
self.assertTrue(np.isfinite(dense[1][1][:, fluid]).all(), mode)
for fine_block in FINE_BLOCKS:
reference = run(mode, FINE_BLOCKS[0], objects=[circle()], jets=[jet()])
self.assertTrue(np.isfinite(reference[1][1][:, fluid]).all(), mode)
for fine_block in FINE_BLOCKS[1:]:
public, raw_arrays = run(mode, fine_block, objects=[circle()], jets=[jet()])
for a, b in zip(public + tuple(raw_arrays), dense[0] + tuple(dense[1])):
for a, b in zip(public + tuple(raw_arrays), reference[0] + tuple(reference[1])):
np.testing.assert_array_equal(a, b, err_msg=f"G1 {mode} {fine_block}")
legacy = run(mode, None, objects=[circle(actuation(SPEED))], jets=())
diff = np.abs(dense[1][1][:, fluid] - legacy[1][1][:, fluid]).max()
legacy = run(mode, FINE_BLOCKS[0], objects=[circle(actuation(SPEED))], jets=())
diff = np.abs(reference[1][1][:, fluid] - legacy[1][1][:, fluid]).max()
print(f"G2 {mode}: jet vs legacy fine max |dG| = {diff:.3e}", flush=True)
self.assertLessEqual(diff, 1e-6, mode)
def test_g3_amplitude_switch_layouts_identical(self):
for mode in ("periodic", "slot_reservoir"):
dense = run(mode, None, objects=[circle()], jets=[jet()], switch=-SPEED)
steady = run(mode, None, objects=[circle()], jets=[jet()])
self.assertFalse(np.array_equal(dense[1][1], steady[1][1], equal_nan=True), mode)
for fine_block in FINE_BLOCKS:
reference = run(mode, FINE_BLOCKS[0], objects=[circle()], jets=[jet()], switch=-SPEED)
steady = run(mode, FINE_BLOCKS[0], objects=[circle()], jets=[jet()])
self.assertFalse(np.array_equal(reference[1][1], steady[1][1], equal_nan=True), mode)
for fine_block in FINE_BLOCKS[1:]:
public, raw_arrays = run(mode, fine_block, objects=[circle()], jets=[jet()],
switch=-SPEED)
for a, b in zip(public + tuple(raw_arrays), dense[0] + tuple(dense[1])):
for a, b in zip(public + tuple(raw_arrays), reference[0] + tuple(reference[1])):
np.testing.assert_array_equal(a, b, err_msg=f"G3 {mode} {fine_block}")
print(f"G3 {mode}: amplitude switch dense vs {len(FINE_BLOCKS)} layouts identical",
flush=True)
print(f"G3 {mode}: amplitude switch, {len(FINE_BLOCKS) - 1} layouts "
f"identical to {FINE_BLOCKS[0]}", flush=True)
if __name__ == "__main__":
@@ -42,8 +42,9 @@ class RefinementMRTGPU(unittest.TestCase):
collision="MRT", ddf_shifting=shifted)
reference = None
for block in ((32, 48), (8, 8), (4, 1)):
with FixedPatchStepper(cfg, (8, 6, 40, 26), context=context, coarse_boundary=boundary,
base_block=block, fine_block=(8, 8), fine_objects=(body,)) as backend:
with FixedPatchStepper(cfg, context=context, coarse_boundary=boundary,
refinements=[(8, 6, 40, 26)], level_blocks=[block, (8, 8)],
level_objects=[(), (body,)]) as backend:
backend.initialize(*[state(*level.shape[1:]) for level in backend._levels])
backend.step(8)
raw = backend._download_raw()
@@ -53,31 +54,12 @@ class RefinementMRTGPU(unittest.TestCase):
else:
for a, b in zip(reference, raw.values): np.testing.assert_array_equal(a, b)
def test_dense_versus_blocked_mrt_two_level(self):
"""Unshifted MRT: all-dense PatchCoarseStep/PatchFineStep vs mixed/all-blocked storage."""
from test_refinement_blocked_base import BASE_BLOCKS, FINE_BLOCK
from test_refinement_blocked_fine import NX, NY, BOUNDS
import test_refinement_blocked_fine as fixture
cfg = LBMConfig(nx=NX, ny=NY, viscosity=.1, collision="MRT")
with CudaContext(0) as context:
results = {}
layouts = ((None, None), *((base, FINE_BLOCK) for base in (None, *BASE_BLOCKS)))
for base_block, fine_block in layouts:
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)
for layout in layouts[1:]:
for level, (a, b) in enumerate(zip(results[layout], results[None, None])):
exact(self, a, b, f"layout={layout} level={level}")
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(fine_block=(4, 4), base_block=(4, 4), child=(8, 8, 16, 16), child_block=(4, 4))
kw = dict(refinements=[(4, 4, 12, 12), (8, 8, 16, 16)], level_blocks=[(4, 4)] * 3)
with CudaContext(0) as context:
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context, **kw) as backend:
with FixedPatchStepper(cfg, 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)

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