Compare commits

..
Author SHA1 Message Date
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 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
7 changed files with 309 additions and 3173 deletions
@@ -1,76 +0,0 @@
待 Grok 独立复核:750e8a4 本轮六例物理验证
==========================================
固定候选 `750e8a483ec47dcf472e528edc1cf2936f0e7e83`,工作树 `/home/frank14f/CelerisLab-release-prep-20261020` 干净。六例全部完成、退出码 0、无超时;调度器和六个子进程均已消失,最终 `nvidia-smi` 无计算进程。206 个源码/测试/文档哈希与启动 manifest 一致,各例运行前后 source map 相同,原生结果与外部摘要一致;V2 原生源码捕获及 runner 哈希也一致。V4 采用外部 manifest 绑定,没有冒称 runner 内部具备同样 provenance。没有新实验、阈值变更、源码变更、main 合并或公开发布。
六例状态及进程墙钟秒数:V2 zero refined `periodic` 511.409,zero uniform `periodic` 325.254;fixed refined `driven_diagnostics_complete` 530.173,fixed uniform 同状态 333.016;V4 refined `NOT_STEADY` 315.714,uniform `NOT_STEADY` 114.183。墙钟包含初始化和观测开销,仅用于执行记录,不能作正式性能比较。
**V2 zero:MRT、unshifted,均达到 t*=300。** 每例 3480 条 history;refined 1.2M steps,uniform 2.4M steps。各提取 14 个 t*>250 的周期,末五周期全部原生周期门槛通过,各自与冻结参考比较通过。
| 周期指标 | refined | uniform | 原门槛 |
|---|---:|---:|---:|
| 末五周期范围 | 281.663875–298.232508 | 281.590556–298.153468 | 起点>=250,5 个连续周期 |
| period min / max | 3.311073 / 3.316868 | 3.311314 / 3.314196 | 每周期严格在 (2,5) |
| period ptp/mean | 0.00174873 | 0.000869972 | <=0.005 |
| 末周期距最后力采样 | 1.757492 | 1.836532 | [0,5] |
| 连续周期最大间隙 | 0 | 0 | <1e-8 |
| 最小 Cl peak-to-peak | 2.003137 | 1.998667 | >=0.2 |
| Cd_min / Cd_max 跨周期 range | 8.14323e-5 / 1.39714e-4 | 2.60406e-4 / 8.66420e-5 | 每项<=0.005 |
| Cl_min / Cl_max 跨周期 range | 1.96279e-4 / 1.15290e-4 | 1.59490e-4 / 1.82477e-4 | 每项<=0.005 |
| dp_min / dp_max 跨周期 range | 5.50774e-4 / 5.87194e-4 | 8.12699e-4 / 7.99311e-4 | 每项<=0.005 |
| 周期质量均值 ptp/abs(mean) | 3.61945e-8 | 8.51505e-9 | <=1e-4 |
| 最大周期均值 flux_relative | 6.35676e-6 | 5.22752e-5 | <=0.005 |
末周期实际值:refined period=3.312817588、St=0.301857852、Cd=[3.160863689,3.234990541]、Cl=[-1.019161308,0.984224152]、dp=[2.404928344,2.499337133];uniform period=3.311313790、St=0.301994937、Cd=[3.161468623,3.231458726]、Cl=[-1.016677997,0.982020469]、dp=[2.420541455,2.513318511]。
| 末周期参考误差 | refined | uniform | 原门槛 |
|---|---:|---:|---:|
| Cd_min / Cd_max 相对误差 | 0.00107520 / 0.00235425 | 0.000884026 / 0.00125993 | 每项<=0.02 |
| Cl_min / Cl_max 绝对误差 | 0.00212432 / 0.00234646 | 0.00460763 / 0.00455014 | 每项<=0.03 |
| dp_min / dp_max 相对误差 | 0.00638037 / 0.00306172 | 0.0000703422 / 0.00251519 | 每项<=0.02 |
| St 相对误差 | 0.0000558054 | 0.000509970 | <=0.02 |
| Cd / Cl / dp waveform RMS | 0.00472589 / 0.00216188 / 0.0117979 | 0.00317231 / 0.00337428 / 0.00382206 | 描述指标,不是 gate |
`matched_case_pass=false` 原样保留:四次 V2 调用均选择单 case,runner 只有 `--case both` 的两结果原生比较分支才赋值这一字段。它不等于 zero 的各自周期或参考比较失败,也不能由执行者自行改为 true;本轮没有运行该 paired-case 原生比较。fixed 没有官方 DFG 参考 verdict,完成执行不等于物理验收。
**Fixed Jet 健康与 CV。** 两例达到 t*=300,五个 250–300 诊断窗均完整。原健康规则为 physical leaf 数据及 impulse 有限、rho_min>=0.5、rho_max<=1.5、Mach<=0.2。所有 3480 条 history 均有限,观测数值如下。
| 指标 | refined | uniform |
|---|---:|---:|
| 全序列 rho min / max | 0.994218661 / 1.004163150 | 0.993391573 / 1.004678726 |
| 全序列 max Mach | 0.0422925943 | 0.0421810197 |
| mass t*=0.5 / t*=300 | 0.8931541481 / 0.8931214909 | 0.8936680602 / 0.8936524063 |
| 上述端点质量相对变化 | -3.65640e-5 | -1.75164e-5 |
| t*=290–300 mass ptp/mean | 8.40337e-5 | 6.31000e-5 |
| 290–300 Cd / Cl 均值 | 3.184020005 / -0.580990812 | 3.182612828 / -0.582235721 |
CV 各 501 个端点,间距 t*=0.02,积分区间 290–300(root time 40000)。排除首端点之前的 impulse,只累加后 500 个区间;root reaction impulse refined=(254.721600438,-46.479264945),uniform=(254.609026243,-46.578857642)。两条矩形轮廓 refined 为 (48,44,160,120)/(44,36,180,128),uniform 的 x 坐标均加 0.25。以原式 `J_CV=-ΔP-∫outward_momentum_flux dt`,残差为 `J_CV-J_raw`,系数除以 `duration*0.5*U_root²*D_root=80`。这里是 wall+aperture 原始流体对物体反作用,不是已修正的 nozzle thrust。
| 动量残差系数 (x,y) | refined | uniform |
|---|---|---|
| 内轮廓、stride1 | (2.24958e-4,-1.00611e-4) | (1.78190e-4,-7.24811e-5) |
| 外轮廓、stride1 | (7.88038e-5,-4.35879e-5) | (-8.30841e-5,-1.99246e-5) |
| 外减内 | (-1.46155e-4,5.70231e-5) | (-2.61274e-4,5.25565e-5) |
| stride2 相对 stride1 最大分量差 | 1.18312e-5 | 2.19402e-5 |
| stride4 相对 stride1 最大分量差 | 4.44884e-5 | 6.26044e-5 |
CV 未定义验收门槛,以上仅为离线积分诊断。质量记录缺少 raw `mass_into_fluid` Jet 源账本,无法扣除质量源判断闭合。`ΔM+∫outward_mass_flux dt` 仅是表观质量源:refined 内/外=-0.0172316912/-0.646617293 root mass;uniform=3.519017515/3.575029022。相对初始 CV 库存分别 (-2.37505e-6,-5.74501e-5)/(4.84891e-4,3.17573e-4)。轮廓间质量差异保留供审查,不能据此宣称质量闭合通过。
**V4:SRT,无 Jet,t*=60。** uniform 480k/refined 240k steps,各 120 个样本。末窗 t*=50–60 共 21 点;drift 是原 runner 的 `abs(OLS slope per t*)*10`。
| case / 指标 | 末窗 range | 原 range 上限 | 末窗 drift | 原 drift 上限 |
|---|---:|---:|---:|---:|
| refined Cd | 9.86614e-4 | 0.002 | 4.68834e-6 | 0.0004 |
| refined Cl | 6.95374e-5 | 6e-5 | 3.28188e-6 | 1.2e-5 |
| refined p_diff | 1.45066e-4 | 4e-5 | 9.28999e-6 | 8e-6 |
| uniform Cd | 1.19061e-3 | 0.002 | 1.60374e-4 | 0.0004 |
| uniform Cl | 7.93289e-5 | 6e-5 | 5.92289e-6 | 1.2e-5 |
| uniform p_diff | 1.27157e-4 | 4e-5 | 2.80735e-5 | 8e-6 |
两例均有 Cl range、p_diff range/drift 超限,保持 `steady=false,status=NOT_STEADY,verdict=null`。末窗均值 refined=(Cd 5.588876919,Cl 0.010732806,p_diff 0.117811546),uniform=(5.586439289,0.010680915,0.117402607)。原区间 Cd=[5.57,5.59]、Cl=[0.0104,0.011]、p_diff=[0.1172,0.1176];refined 压差均值在区间外,uniform 均值在区间内,但均不能越过稳态 gate。
120 点全序列(t*=0.5–60)存在初始瞬态和反复起伏:refined Cd/Cl/p_diff 的增量符号变化次数为 82/80/82,uniform 为 79/78/81;末窗仍为 13/13/14 和 12/13/11。末窗有符号 OLS slope per t* refined=(-4.68834e-7,-3.28188e-7,-9.28999e-7),uniform=(-1.60374e-5,+5.92289e-7,-2.80735e-6)。这只是当前采样分辨率下的非单调时序描述,不能确定振荡频率、物理/数值根因,也不是发散或瓶颈结论。完整 JSON 保留全序列及逐 10 t* 窗统计。边界偏差仍为 pressure-NEQ/neq_extrap outlet 与 local Zou-He inlet,不能称为原始 do-nothing DFG 边界。
**复核边界与证据。** 本轮不是发布通过:短套件仍为 767 passed/24 failed/1 skipped;V1 历史 pin 阻塞,V3 四例原 TG dry-run 均被 frozen-source guard 拒绝、尚无 CUDA 计算或 TG 结果;未接纳待修订 `56004f44`,没有修改 dtype/NumPy/数值核心。下一步由 Claude 提交修订和解释、Grok 独立复核,再由集成任务接受明确通过审查的 SHA。
原始证据根 `/home/frank14f/CelerisLab-release-evidence-20261020/physical-750e8a4/followup-queue`。`final-physical-digest.json` 约 99 KB,含全部六例原生小摘要、门槛、CV 抽稀结果、源/输出哈希、进程检查和限制;后处理脚本 `final_physical_digest.py` 不导入或运行模拟器。原始 history/log 只保留在 Horsea,没有上传巨量日志。
File diff suppressed because it is too large Load Diff
@@ -1,94 +0,0 @@
"""Read-only postprocessing of the six existing, authorized simulation outputs."""
import csv
import hashlib
import json
import math
from pathlib import Path
import subprocess
import datetime
import numpy as np
Q = Path('/home/frank14f/CelerisLab-release-evidence-20261020/physical-750e8a4/followup-queue')
R = Path('/home/frank14f/CelerisLab-release-prep-20261020')
SHA = '750e8a483ec47dcf472e528edc1cf2936f0e7e83'
def read(p):
return json.loads(p.read_text())
def v4_cases(data):
"""DFG Re20 result.json: schema 1 is a bare case list, schema 2 is {schema, provenance, cases}."""
if isinstance(data, list): return data
if isinstance(data, dict) and data.get('schema') == 2: return data['cases']
raise ValueError('unknown DFG Re20 result schema')
def sha(p):
return hashlib.sha256(p.read_bytes()).hexdigest()
def cmd(*a):
return subprocess.check_output(a, text=True).strip()
def span(a):
return float(np.ptp(a))
def finite(x):
if isinstance(x, dict): return all(finite(v) for v in x.values())
if isinstance(x, list): return all(finite(v) for v in x)
return not isinstance(x, (int,float)) or math.isfinite(x)
def stats(rows, key):
t=np.array([v['tstar'] for v in rows]); v=np.array([r[key] for r in rows])
d=np.diff(v); nz=np.sign(d[d!=0])
return dict(samples=len(v),first=float(v[0]),last=float(v[-1]),min=float(min(v)),max=float(max(v)),mean=float(np.mean(v)),range=span(v),ols_slope_per_tstar=float(np.polyfit(t-t[0],v,1)[0]),increment_sign_changes=int(np.count_nonzero(nz[1:]!=nz[:-1])),total_variation=float(np.abs(d).sum()))
state=read(Q/'state.json'); manifest=read(Q/'manifest.json'); execution=read(Q/'execution.json')
out=dict(schema=1,utc=datetime.datetime.now(datetime.timezone.utc).isoformat(),sha=SHA,evidence_root=str(Q),postprocessor_sha256=sha(Path(__file__)),scope='Offline read-only digest; no new simulation, gate edit, acceptance promotion, or performance claim',verification={},cases={})
v=out['verification']; v['all_six_completed']=len(state['jobs'])==6 and all(x['status']=='COMPLETED' for x in state['jobs'].values())
v['all_exit_zero_no_timeout']=all(x['exit_code']==0 and not x['timeout'] for x in state['jobs'].values())
v['head']=cmd('git','-C',str(R),'rev-parse','HEAD');v['git_status_porcelain']=cmd('git','-C',str(R),'status','--porcelain');v['clean_fixed_head']=v['head']==SHA and not v['git_status_porcelain']
v['tree']=cmd('git','-C',str(R),'rev-parse','HEAD^{tree}');v['src_tree']=cmd('git','-C',str(R),'rev-parse','HEAD:src')
v['tracked_source_map_matches_manifest']=all(sha(R/p)==h for p,h in manifest['files_sha256'].items());v['tracked_source_map_count']=len(manifest['files_sha256'])
v['execution_final_source_matches_manifest']=all(execution['source_after'][k]==manifest[k] for k in ['sha','tree','src_tree','git_status','git_diff_sha256','files_sha256'])
pids=[state['scheduler_pid']]+[x['pid'] for x in state['jobs'].values()]
v['tracked_processes']={}
for pid in pids:
p=subprocess.run(['ps','-p',str(pid),'-o','pid=,ppid=,stat=,comm='],capture_output=True,text=True)
v['tracked_processes'][str(pid)]=p.stdout.strip() or 'absent'
v['no_tracked_process_running']=all(s=='absent' or ' Z' in s for s in v['tracked_processes'].values())
v['gpu_compute_processes']=cmd('nvidia-smi','--query-compute-apps=gpu_uuid,pid,process_name,used_gpu_memory','--format=csv')
out['hashes']={p.name:sha(p) for p in [Q/'manifest.json',Q/'execution.json',Q/'state.json',Q/'queue-plan.json',Q/'receipt-preflight.json',Q/'authorized_followup_queue.py',Q/'v4_external_timing.py']}
for label,sj in state['jobs'].items():
p=Q/label; r=read(p/'result.json'); c=out['cases'][label]=dict(status=sj['native_status'],exit_code=sj['exit_code'],timeout=sj['timeout'],process_wall_seconds=sj['wall_seconds'],pid=sj['pid'],gpu_uuid=sj['gpu_uuid'],source_unchanged=r['source_unchanged'])
c['source_matches_manifest']=all(r['source'][k]==manifest[k] and r['source_after'][k]==manifest[k] for k in ['sha','tree','src_tree','git_status','git_diff_sha256','files_sha256'])
c['external_manifest_hash_matches']=r['external_manifest_sha256']==sha(Q/'manifest.json')
c['hashes']={str(z.relative_to(p)):sha(z) for z in [p/'command.json',p/'result.json',*sorted((p/'output').rglob('*.json')),*sorted((p/'output').rglob('*.csv')),*sorted((p/'output').rglob('*.jsonl'))]}
if label.startswith('v4'):
n=v4_cases(read(p/'output/result.json'))[0]; rows=n['rows']
captured=r['native_results'][0]
c['native_receipt_matches_external_result']=all(n[k]==z for k,z in captured.items() if k not in ['last_row','rows_count']) and captured['last_row']==rows[-1] and captured['rows_count']==len(rows)
c['external_receipt_history_handling']='V4 external receipt stores row count and last row; full native rows are separately hashed'
c['native_all_finite']=finite(n)
c['native']={k:z for k,z in n.items() if k not in ['rows','lattice']}
c['full_series']={k:stats(rows,k) for k in ['cd','cl','p_diff']}
c['ten_tstar_windows']={str(a):{k:stats([z for z in rows if a<=z['tstar']<=a+10],k) for k in ['cd','cl','p_diff']} for a in range(0,60,10)}
c['last_window']=dict(tstar=[50,60],metrics={k:stats(rows[-21:],k) for k in ['cd','cl','p_diff']})
c['health']=dict(rho_min=min(z['rho_min'] for z in rows),rho_max=max(z['rho_max'] for z in rows),max_mach=max(z['max_mach'] for z in rows))
continue
case=label.rsplit('-',1)[1]; base=p/'output'/case;n=read(base/'summary.json');plan=read(p/'output/plan.json')
c['native_receipt_matches_external_result']={k:z for k,z in n.items() if k!='history'}==r['native_summary']
c['external_receipt_history_handling']='V2 external receipt omits the history path; complete history.csv is separately hashed'
c['native_all_finite']=finite(n)
c['native_runner_hash_matches']=plan['runner_sha256']==sha(R/'tests/validation/run_refined_dfg.py')
sm=plan['source_manifest'];c['native_source_capture_matches']=sm['checkout_revision']==SHA and not sm['checkout_dirty'] and all(sha(R/f)==h==manifest['files_sha256'][f] for f,h in sm['sha256'].items())
c['native_source_capture_file_count']=len(sm['sha256'])
c['native']={k:z for k,z in n.items() if k not in ['history','cycles','jet_diagnostic_windows']}; c['comparison']=read(p/'output/comparison.json');c['gates']=plan['gates'];c['reference_gates']=plan['reference_gates'];c['diagnostic_windows']=n['jet_diagnostic_windows']
rows=[{k:float(z) for k,z in row.items()} for row in csv.DictReader((base/'history.csv').open())];late=[x for x in rows if x['tstar']>=290]
c['health']=dict(rows=len(rows),tstar=[rows[0]['tstar'],rows[-1]['tstar']],all_finite=finite(rows),rho_min=min(z['rho_min'] for z in rows),rho_max=max(z['rho_max'] for z in rows),max_mach=max(z['max_mach'] for z in rows),mass_first=rows[0]['mass'],mass_last=rows[-1]['mass'],mass_change_fraction=(rows[-1]['mass']-rows[0]['mass'])/rows[0]['mass'],mass_late_range_fraction=span([z['mass'] for z in late])/np.mean([z['mass'] for z in late]))
if n['cycles']:
tail=n['cycles'][-5:]; periods=[z['period'] for z in tail]; masses=[z['mass_time_mean'] for z in tail]
c['periodic_actual']=dict(cycles_total=len(n['cycles']),tail_cycles=len(tail),tail_start=tail[0]['start'],tail_end=tail[-1]['end'],period_min=min(periods),period_max=max(periods),period_mean=float(np.mean(periods)),period_relative_range=span(periods)/np.mean(periods),last_cycle_age=rows[-1]['tstar_force']-tail[-1]['end'],consecutive_max_gap=max(abs(a['end']-b['start']) for a,b in zip(tail,tail[1:])),minimum_lift_amplitude=min(z['cl_amplitude'] for z in tail),extrema_ranges={k+'_'+e:span([z[k+'_'+e] for z in tail]) for k in ['cd','cl','dp'] for e in ['min','max']},mass_relative_range=span(masses)/abs(np.mean(masses)),max_cycle_flux_relative=max(z['flux_relative'] for z in tail),last_cycle={k:z for k,z in tail[-1].items() if not k.endswith('_waveform')})
cv=[json.loads(x) for x in (base/'cv_history.jsonl').read_text().splitlines()];t=np.array([x['time_root'] for x in cv]);inv=np.array([x['inventory'] for x in cv]);flux=np.array([x['outward_flux'] for x in cv]);j=np.array([x['interval_impulse_root'] for x in cv[1:]]).sum(axis=0);duration=t[-1]-t[0];norm=duration*.5*.01**2*40
cvout=c['cv']=dict(samples=len(cv),all_finite=finite(cv),tstar=[cv[0]['tstar'],cv[-1]['tstar']],duration_root=duration,coefficient_normalization=norm,raw_body_reaction_impulse_root=j.tolist(),first_preceding_impulse_excluded=True,contours_root=plan['cv_observation'][case+'_contours_root'],mass_qualification='Apparent mass source only: raw Jet mass_into_fluid ledger was not recorded, so mass-source-subtracted closure cannot be assessed',acceptance='offline diagnostics only; no CV pass threshold was defined',strides={})
for stride in [1,2,4]:
idx=np.arange(0,len(t),stride);assert idx[-1]==len(t)-1
integral=np.trapz(flux[idx],t[idx],axis=0); delta=inv[-1]-inv[0];jcv=-delta[:,1:]-integral[:,1:];res=jcv-j
cvout['strides'][str(stride)]=dict(samples=len(idx),momentum_delta_root=delta[:,1:].tolist(),outward_momentum_integral_root=integral[:,1:].tolist(),cv_reaction_impulse_root=jcv.tolist(),residual_impulse_root=res.tolist(),residual_coefficient=(res/norm).tolist(),apparent_mass_source_root=(delta[:,0]+integral[:,0]).tolist(),apparent_mass_source_fraction_initial_inventory=((delta[:,0]+integral[:,0])/inv[0,:,0]).tolist())
a=np.array(cvout['strides']['1']['residual_coefficient']);cvout['contour_residual_coefficient_difference']=(a[1]-a[0]).tolist();cvout['coarsening_coefficient_difference']={s:(np.array(cvout['strides'][s]['residual_coefficient'])-a).tolist() for s in ['2','4']}
v['all_six_source_unchanged']=all(c['source_unchanged'] and c['source_matches_manifest'] and c['external_manifest_hash_matches'] for c in out['cases'].values())
v['all_six_native_receipts_match']=all(c['native_receipt_matches_external_result'] and c['native_all_finite'] for c in out['cases'].values())
v['all_v2_native_captures_match']=all(c['native_runner_hash_matches'] and c['native_source_capture_matches'] for k,c in out['cases'].items() if k.startswith('v2'))
out['limitations']=['V1 historical source pin remains blocked','V3 original TG source guard blocked all four dry-runs before CUDA; no TG result or acceptance','Short suite remains 767 passed, 24 failed, 1 skipped; not a release pass','Matched_case_pass remains false because each invocation selected one case; native paired-case comparison branch did not execute','Fixed Jet CV is diagnostic; mass source ledger absent; no physical acceptance','V4 both NOT_STEADY with original ranges/drifts/bands; boundary deviations retained','No formal performance claim: timings include observation/profiling and startup is separately recorded']
dest=Q/'final-physical-digest.json';dest.write_text(json.dumps(out,indent=2,allow_nan=False)+'\n')
print(json.dumps(dict(output=str(dest),bytes=dest.stat().st_size,sha256=sha(dest),verification=v),indent=2))
+14 -4
View File
@@ -204,11 +204,21 @@ class PeriodicBlockStepper:
raise
def step(self, n=1):
"""Advance n steps: halo refresh, then core pull/collide, per step."""
"""Advance n steps: halo refresh, then core pull/collide, per step.
All n steps are enqueued in order on the one stream and the host waits
once before returning (same launches and bits as n step(1) calls).
Asynchronous device errors surface at that single wait, so they are not
attributed to the step that caused them; call step(1) repeatedly to
localize one. On any failure the instance is failed and step_count keeps
its entry value.
"""
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
raise ValueError("n must be a nonnegative integer")
with self._lock, self.ctx.activate():
self._require("ready")
if n == 0:
return
kw = dict(block=self._block, stream=self._stream)
try:
self._status = "advancing"
@@ -224,9 +234,9 @@ class PeriodicBlockStepper:
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._current, self._spare = self._spare, self._current # host handles only
self._stream.synchronize()
self._step_count += n
self._status = "ready"
except BaseException:
self._status = "failed"
+21 -7
View File
@@ -822,27 +822,41 @@ class FixedPatchStepper:
grid=((pairs + self._block[0] - 1) // self._block[0], 1), **kw)
def step(self, n=1):
"""Advance G→G by n base intervals: level l takes 2**l hops per interval."""
"""Advance G→G by n base intervals: level l takes 2**l hops per interval.
All n intervals are enqueued in order on the one stream, and the host waits
once, before returning; step(0) launches and waits for nothing. On return
the device work is complete, step_count and an open window cover all n
intervals, and the raw-exchange ledger holds the final interval only.
Asynchronous device errors surface at that single wait, so they are not
attributed to the interval that caused them; call step(1) repeatedly to
localize one (same launches, same bits). On any failure the instance is
failed, the exchange ledger and window are invalid, and step_count keeps
its value from entry to the failing call.
"""
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
raise ValueError("n must be a nonnegative integer")
with self._lock, self.ctx.activate():
self._require("ready")
if n == 0:
return
root = self._levels[0]
kw = dict(block=self._block, stream=self._stream)
try:
self._status = "advancing"
self._exchange_valid = False
for _ in range(n):
if self._observe_raw_exchange:
self._exchange_valid = False
if self._observe_raw_exchange: # stream-ordered: holds the last interval only
for b in self._level_boundaries:
if b is not None:
cuda.memset_d8_async(b.interval_integral, 0,
len(b.tables["cell"]) * 32, self._stream)
self._hop(0, root.current, root.spare, **kw)
self._stream.synchronize()
root.current, root.spare = root.spare, root.current
self._step_count += 1
self._exchange_valid = self._observe_raw_exchange
root.current, root.spare = root.spare, root.current # host handles only
self._stream.synchronize()
self._step_count += n
self._exchange_valid = self._observe_raw_exchange
self._status = "ready"
except BaseException:
self._window_valid = False
+104
View File
@@ -0,0 +1,104 @@
"""Host contract of PeriodicBlockStepper.step(n): one stream wait per call (mocked driver)."""
import ast
from contextlib import nullcontext
import importlib.util
from pathlib import Path
import types
from unittest.mock import Mock
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
def host_module():
"""block_stepper with pycuda/compiler replaced by mocks; no GPU context."""
# Locate the source without importing it: importing block_stepper loads pycuda.driver,
# which CPU-only import-boundary tests (test_build_contracts) must never see.
path = Path(importlib.util.find_spec("CelerisLab.lbm.block_stepper").origin)
tree = ast.parse(path.read_text())
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 == "cuda")]
module = types.ModuleType("CelerisLab.lbm._test_host_block_stepper")
module.__package__ = "CelerisLab.lbm"
module.__dict__.update(cuda=Mock(), compiler=Mock())
exec(compile(tree, str(path), "exec"), module.__dict__)
return module
def backend():
host, log = host_module(), []
host.cuda.mem_get_info.return_value = (1 << 40, 1 << 40)
host.cuda.mem_alloc.side_effect = lambda size: Mock(size=size) # distinct buffers
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[:2])))
host.compiler.load_module.return_value = module
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
run = host.PeriodicBlockStepper(cfg, BlockLayout2D.build(16, 16, 8, 8),
context=Mock(activate=nullcontext, sm_arch="sm_70"))
run._stream.synchronize.side_effect = lambda: log.append(("sync", ()))
run.initialize(np.full((9, 16, 16), 1 / 9))
log.clear()
return run, log
class BlockStepSync(unittest.TestCase):
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)
# Same launch order and buffer roles as three step(1) calls.
strip = lambda log: [(n, tuple(id(x) for x in a)) for n, a in log if n != "sync"]
roles = lambda run, log: [(n, tuple({id(run._current): "c", id(run._spare): "s"}.get(x, "-")
for x in ids)) for n, ids in strip(log)]
self.assertEqual([n for n, _ in strip(log_many)], [n for n, _ in strip(log_single)])
self.assertEqual(roles(many, log_many), roles(single, log_single))
self.assertIsNot(many._current, many._spare)
self.assertEqual((many.step_count, single.step_count), (3, 3))
def test_step_zero_launches_and_waits_for_nothing(self):
run, log = backend()
run.step(0)
self.assertEqual((log, run.step_count), ([], 0))
def test_failure_keeps_entry_step_count(self):
run, log = 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_launch_failure_mid_batch_fails_closed_without_waiting(self):
run, log = backend()
calls = []
step = run._step
def failing(*args, **kwargs):
calls.append(1)
if len(calls) == 2:
raise RuntimeError("launch failure")
return step(*args, **kwargs)
run._step = failing
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()
if __name__ == "__main__":
unittest.main()
+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.load_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()