Files
CelerisLab/tests/validation/multigrid/spacetime_gpu.py
T

309 lines
16 KiB
Python

"""Test-only arrival-f GPU owner; CUDA compilation/execution requires admission."""
from dataclasses import replace
import hashlib
from numbers import Integral
from pathlib import Path
import numpy as np
import spacetime_reference as ref
PINS = {"spacetime_reference.py": "1fe13242b9a41062a1227db4a6f9b695165aefab7f4c0b3b2adff0c9023552bf",
"cpu_reference.py": "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e",
"../taylor_green/oracle.py": "83e4d7425d45538278e9fb181af450a36358bd9a5ed6be57ff611dd9ae5eb35b"}
def check_sources():
for name, expected in PINS.items():
if hashlib.sha256((Path(__file__).parent/name).read_bytes()).hexdigest() != expected:
raise RuntimeError(f"reference source changed: {name}")
def validate_config(config):
cfg = replace(config)
cfg.validate()
if (cfg.nx != cfg.ny or not 8 <= cfg.nx <= 64 or cfg.nz != 1
or cfg.collision != "SRT" or not cfg.is_d2q9
or cfg.streaming != "double_buffer" or cfg.store_precision != "FP32"
or cfg.data_type != "FP32" or cfg.ddf_shifting or cfg.les_enabled
or cfg.velocity != 0 or cfg.wall_slot_width or cfg.wall_normal_velocity):
raise ValueError("experiment requires periodic unforced square N8..64, unshifted FP32 D2Q9/SRT")
fine = replace(cfg, nx=2*cfg.nx, ny=2*cfg.ny, viscosity=2*cfg.viscosity)
fine.validate()
return cfg, fine
def pack_plan(plan):
"""Pack reviewed coordinate events; no production transfer builder imports."""
n, nf = plan.n, plan.nf
groups, receivers = plan.groups, plan.receivers
data = dict(samples=np.zeros((len(groups), 10), np.uint32),
origins=np.empty(len(groups), np.uint32), sizes=np.empty(len(groups), np.int32),
weights=np.zeros((len(groups), 4, 10), np.float64),
targets=(receivers[:, 0]*n*n+receivers[:, 1]).astype(np.uint32))
lookup = {tuple(row): i for i, row in enumerate(receivers)}
packets = [[] for _ in receivers]; incoming = [[], []]
for i, g in enumerate(groups):
size = len(g.samples)
if size not in (6, 10):
raise ValueError("unexpected interpolation stencil")
data['samples'][i, :size] = g.q*n*n+g.samples
data['origins'][i] = g.q*n*n+g.source
data['sizes'][i] = size
data['weights'][i, :, :size] = g.weights
for k, event in enumerate(g.events):
if event is None:
packets[lookup[(g.q, g.receiver)]].append((-1, 4*i+k))
else:
m, slot = event
incoming[m].append((g.q*nf*nf+slot, 4*i+k))
for m, outgoing in enumerate(plan.outgoing):
for q, source, target in outgoing:
packets[lookup[(int(q), int(target))]].append((m, int(q*nf*nf+source)))
if any(len(row) != 4 or len(set(row)) != 4 for row in packets):
raise ValueError("each receiver needs four distinct quarter-packets")
if len({packet for row in packets for packet in row}) != 4*len(receivers):
raise ValueError("packet has multiple receiver owners")
data['times'] = np.array([[t for t, _ in row] for row in packets], np.int32)
data['sources'] = np.array([[s for _, s in row] for row in packets], np.uint32)
for m, rows in enumerate(incoming):
data[f'incoming{m}'] = np.array(rows, np.uint32)
if len({slot for slot, _ in rows}) != len(rows):
raise ValueError("duplicate incoming writer")
return {key: np.ascontiguousarray(value) for key, value in data.items()}
def table_hash(data):
digest = hashlib.sha256()
for key, value in sorted(data.items()):
digest.update(f"{key}:{value.dtype}:{value.shape}".encode()+value.tobytes())
return digest.hexdigest()
def rounding_ledger(plan, tables, before, after, g0, predicted, fine_phases, pre, h, registers):
"""Audit actual stored FP32 phases; never call a missing packet 'rounding'."""
maps, n = plan.maps, plan.n
f64 = lambda a: np.asarray(a, dtype=np.float64)
collision = (f64(g0[:, maps.coarse_active])-f64(before.coarse_f[:, maps.coarse_active])).sum(axis=1)
for f, g in fine_phases:
collision += .25*(f64(g[:, maps.fine_active])-f64(f[:, maps.fine_active])).sum(axis=1)
np.testing.assert_array_equal(h, pre.astype(np.float32))
origins = f64(g0).ravel()[tables['origins']]
np.testing.assert_allclose(pre.mean(axis=1), origins, rtol=0, atol=2e-14)
group = np.bincount(tables['origins']//(n*n), weights=f64(h).mean(axis=1)-origins, minlength=9)
packet = np.empty(tables['times'].shape, np.float64)
for when, source in ((-1, h), (0, fine_phases[0][1]), (1, fine_phases[1][1])):
mask = tables['times'] == when
packet[mask] = f64(source).ravel()[tables['sources'][mask]]
p = f64(predicted).ravel()[tables['targets']]
ideal = -p + .25*packet.sum(axis=1)
reg_error = registers-ideal
bound = 16*np.finfo(float).eps*(abs(p)+.25*abs(packet).sum(axis=1))
if np.any(abs(reg_error) > bound):
raise AssertionError("receiver packet register mismatch (not roundoff)")
actual = after.coarse_f.ravel()[tables['targets']]
np.testing.assert_array_equal(actual, (p+registers).astype(np.float32))
q = tables['targets']//(n*n)
store_error = np.bincount(q, weights=f64(actual)-(p+registers), minlength=9)
register_error = np.bincount(q, weights=reg_error, minlength=9)
# directional() must sum converted arrays, not accumulate in FP32.
total = lambda s: ref.directional(ref.cpu.State(f64(s.coarse_f), f64(s.fine_f)), maps)
raw = total(after)-total(before)-collision
unexplained = raw-group-store_error-register_error
limit = float(64*np.finfo(float).eps*total(before).sum())
np.testing.assert_allclose(unexplained, 0, rtol=0, atol=limit)
return dict(raw=raw.tolist(), physical_collision=collision.tolist(),
child_rounding=group.tolist(), receiver_rounding=store_error.tolist(),
register_rounding=register_error.tolist(), unexplained=unexplained.tolist(),
unexplained_bound=limit, minimum_pre=float(pre.min()), minimum_stored=float(h.min()))
class SpaceTimeGPU:
"""Caller-owned context, one stream, explicit synchronized arrival state."""
def __init__(self, config, bounds, *, context, mode='spacetime', identity=False):
check_sources()
self.configs = validate_config(config)
if mode not in ('spacetime', 'frozen') or type(identity) is not bool:
raise ValueError("unsupported interpolation or debug collision mode")
self.bounds = tuple(bounds)
if len(self.bounds) != 4 or any(isinstance(v, bool) or not isinstance(v, Integral) for v in self.bounds):
raise ValueError("rectangle bounds must be four integers")
self.plan = ref.Plan(config.nx, self.bounds)
self.tables = pack_plan(self.plan)
self.ctx, self.mode, self.identity = context, mode, identity
self.allocations, self.modules, self.build_metadata = [], [], []
self.status, self.step_count, self.device_bytes = 'new', 0, 0
import pycuda.driver as cuda
from CelerisLab.cuda import compiler_v2 as compiler
root = Path(__file__).resolve().parents[3]
if Path(compiler._KERNEL_DIR).resolve() != root/'src/CelerisLab/lbm/kernels':
raise RuntimeError("wrong production kernel checkout")
self.cuda = cuda
with context.activate():
self.stream = cuda.Stream()
try:
source = Path(__file__).with_suffix('.cu')
for cfg in self.configs:
build = compiler.generate_config(cfg)
try:
Path(build, source.name).write_bytes(source.read_bytes())
ptx = compiler.compile_kernel(context.sm_arch, source=source.name, build_dir=build)
self.modules.append(compiler.load_module(ptx))
import json
metadata = json.loads(Path(ptx+'.json').read_text())
metadata['test_entry_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
self.build_metadata.append(metadata)
finally:
compiler.cleanup_build_dir(build)
self.device_tables = {key: self.upload(value) for key, value in self.tables.items()}
self.active = self.upload(self.plan.maps.fine_active.astype(np.uint8))
self.coarse = [self.allocate(9*config.nx**2*4) for _ in range(4)]
self.fine = [self.allocate(9*(2*config.nx)**2*4) for _ in range(3)]
self.pre = self.allocate(len(self.plan.groups)*4*8)
self.h = self.allocate(len(self.plan.groups)*4*4)
self.registers = self.allocate(len(self.plan.receivers)*8)
self.counts = self.allocate(6*4)
except BaseException:
try:
self.close()
except Exception:
pass
raise
def allocate(self, size):
pointer = self.cuda.mem_alloc(size)
self.allocations.append(pointer); self.device_bytes += size
return pointer
def upload(self, array):
pointer = self.allocate(array.nbytes)
self.cuda.memcpy_htod(pointer, array)
return pointer
def read(self, pointer, shape, dtype=np.float32):
self.stream.synchronize()
out = np.empty(shape, dtype)
self.cuda.memcpy_dtoh(out, pointer)
return out
def launch(self, level, name, *args, count=None):
cfg = self.configs[level]
nt = cfg.threads_per_block
grid = ((cfg.nx+nt-1)//nt, cfg.ny) if count is None else ((count+nt-1)//nt, 1)
self.modules[level].get_function(name)(*args, block=(nt,1,1), grid=grid, stream=self.stream)
def initialize(self, state):
if (self.status not in ('new', 'ready') or state.phase != 'arrival_f'
or isinstance(state.tick, bool) or not isinstance(state.tick, Integral) or state.tick != 0):
raise ValueError("initial synchronized arrival state with tick=0 required")
arrays = [np.array(a, dtype=np.float32, copy=True, order='C') for a in (state.coarse_f,state.fine_f)]
for a, mask in zip(arrays, (self.plan.maps.coarse_active,self.plan.maps.fine_active)):
if a.shape != (9,*mask.shape) or not np.isfinite(a[:,mask]).all() or (a[:,mask] <= 0).any():
raise ValueError("positive finite leaf populations and exact shapes required")
a[:,~mask] = np.nan
with self.ctx.activate():
try:
self.cuda.memcpy_htod(self.coarse[0], arrays[0])
self.cuda.memcpy_htod(self.fine[0], arrays[1])
except BaseException:
self.status = 'failed'
raise
self.status, self.step_count = 'ready', 0
def download(self):
if self.status != 'ready':
raise RuntimeError(f"GPU owner is {self.status}")
try:
with self.ctx.activate():
arrays = [self.read(self.coarse[0], (9,self.plan.n,self.plan.n)),
self.read(self.fine[0], (9,self.plan.nf,self.plan.nf))]
except BaseException:
self.status = 'failed'
raise
for a, mask in zip(arrays, (self.plan.maps.coarse_active,self.plan.maps.fine_active)):
a[:,~mask] = np.nan
return ref.cpu.State(*arrays, tick=2*self.step_count)
def step(self, *, mutation=0):
"""One audited interval; mutation 1/2/3 drops packets, 4 omits a ghost."""
if self.status != 'ready' or type(mutation) is not int or mutation not in range(5):
raise RuntimeError("ready owner and known test mutation required")
before = self.download()
n, nf, t = self.plan.n, self.plan.nf, self.device_tables
ng, nr = len(self.plan.groups), len(self.plan.receivers)
u32, null = np.uint32, np.uintp(0)
omega = [np.float32(0 if self.identity else cfg.omega) for cfg in self.configs]
with self.ctx.activate():
try:
self.status = 'advancing'
self.cuda.memset_d32_async(self.counts, 0, 6, self.stream)
fc, g0, predicted, gp = self.coarse
self.launch(0, 'CoarseStart', fc,self.fine[0],g0,omega[0],
*(u32(v) for v in self.bounds),self.counts)
self.launch(0, 'Pull', g0,predicted,null,self.counts)
self.launch(0, 'Collide', predicted,gp,null,omega[0],self.counts)
self.launch(0, 'Interpolate', g0,gp,t['samples'],t['origins'],t['sizes'],t['weights'],
self.pre,self.h,u32(ng),u32(self.mode=='frozen'),self.counts,count=ng)
coarse_g = self.read(g0,(9,n,n)); prediction = self.read(predicted,(9,n,n))
fine_phases = []
for m in (0,1):
f, out, g = self.fine
old_f = self.read(f,(9,nf,nf))
self.launch(1, 'Collide', f,g,self.active,omega[1],self.counts)
fine_phases.append((old_f,self.read(g,(9,nf,nf))))
self.launch(0, 'Register', predicted,g,self.h,t['targets'],t['sources'],t['times'],
self.registers,u32(nr),np.int32(m),np.int32(mutation),count=nr)
rows = self.tables[f'incoming{m}']
self.launch(1, 'Inject', g,self.h,t[f'incoming{m}'],u32(len(rows)),u32(mutation==4),count=len(rows))
self.launch(1, 'Pull', g,out,self.active,self.counts)
self.fine[0], self.fine[1] = out, f
self.launch(0, 'ApplyRegisters', predicted,self.registers,t['targets'],u32(nr),self.counts,count=nr)
counts = self.read(self.counts,(6,),np.uint32)
if counts.any():
raise AssertionError(f"boundary/leaf health failed: {counts.tolist()}")
coarse_after = self.read(predicted,(9,n,n))
coarse_after[:,~self.plan.maps.coarse_active] = np.nan
after = ref.cpu.State(coarse_after,self.read(self.fine[0],(9,nf,nf)),tick=2*(self.step_count+1))
ledger = rounding_ledger(self.plan,self.tables,before,after,coarse_g,prediction,fine_phases,
self.read(self.pre,(ng,4),np.float64),self.read(self.h,(ng,4)),
self.read(self.registers,(nr,),np.float64))
self.coarse[0], self.coarse[2] = predicted, fc
self.step_count += 1
self.status = 'ready'
return ledger
except BaseException:
self.status = 'failed'
raise
def close(self):
if self.status == 'closed':
return
with self.ctx.activate():
error = None
try:
self.stream.synchronize()
except Exception as exc:
error = exc
failed = []
for pointer in self.allocations:
try:
pointer.free()
except Exception as exc:
error = error or exc
failed.append(pointer)
self.allocations = failed
self.status = 'cleanup_failed' if failed else 'closed'
if not failed:
self.modules.clear()
if error:
raise error
def __enter__(self):
return self
def __exit__(self, kind, value, traceback):
try:
self.close()
except Exception:
if kind is None:
raise